Add workspaces, conversation sessions, app-managed computers and pictures
- Rail: Add icon with stacked form modals (bot, workspace, computer), coordinator, recent bots with "See all bots", Workspaces section, and a workspace mode listing only its crew - Workspaces: page for context, bots (per-workspace instructions and memory), computers (default, link, unlink), models and delete; conversations scoped per bot per workspace - Sessions: conversations split into searchable, reopenable sessions with carry-over summaries; sessions and new-session header icons - Computers: created and provisioned over the docker socket, home dir kept at DATA_DIR/computers/<id> after delete; super admins only - Models: workspace settings win over a bot's own, then the defaults - Pictures for bots and workspaces; bots editable from the header - Coordinator tools: update_workspace, set_bot_workspace_instructions Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import dockerRequest from "./docker-request";
|
||||
import ensureDockerNetwork from "./ensure-docker-network";
|
||||
import grabAppContainer from "./grab-app-container";
|
||||
import grabFreePort from "./grab-free-port";
|
||||
import inspectDockerContainer from "./inspect-docker-container";
|
||||
import pullDockerImage from "./pull-docker-image";
|
||||
import removeDockerContainer from "./remove-docker-container";
|
||||
import resolveHostPath from "./resolve-host-path";
|
||||
|
||||
type Params = {
|
||||
computer_id: number;
|
||||
/**
|
||||
* The container's name, also its hostname on the network.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The computer's home directory as this app sees it.
|
||||
*/
|
||||
data_dir: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and starts an AIO sandbox container for a computer
|
||||
* (`AppData.ComputerImage`) on the computers' network, with its home
|
||||
* directory bind-mounted from `data_dir` and restarted with docker. A
|
||||
* container left by an earlier try is replaced. When the app runs outside
|
||||
* docker, the sandbox is also published on a localhost port, returned as
|
||||
* `mcp_url`'s host.
|
||||
*/
|
||||
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
|
||||
const network = await ensureDockerNetwork();
|
||||
const in_docker = Boolean(await grabAppContainer());
|
||||
const host_dir = await resolveHostPath({ local_path: data_dir });
|
||||
const port_key = `${AppData.ComputerPort}/tcp`;
|
||||
const host_port = in_docker ? undefined : grabFreePort();
|
||||
|
||||
await pullDockerImage({ image: AppData.ComputerImage });
|
||||
await removeDockerContainer({ container: name });
|
||||
|
||||
const created = await dockerRequest<{ Id: string }>({
|
||||
method: "POST",
|
||||
path: `/containers/create`,
|
||||
query: { name },
|
||||
body: {
|
||||
Image: AppData.ComputerImage,
|
||||
Hostname: name,
|
||||
Labels: {
|
||||
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
|
||||
},
|
||||
ExposedPorts: { [port_key]: {} },
|
||||
HostConfig: {
|
||||
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
|
||||
NetworkMode: network,
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
SecurityOpt: ["seccomp=unconfined"],
|
||||
ShmSize: AppData.ComputerShmBytes,
|
||||
Memory: AppData.ComputerMemoryBytes,
|
||||
NanoCpus: AppData.ComputerCpus * 1e9,
|
||||
PortBindings: host_port
|
||||
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
|
||||
: undefined,
|
||||
},
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: { [network]: { Aliases: [name] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!created.ok || !created.data?.Id) {
|
||||
throw new Error(`Couldn't create the container: ${created.message}`);
|
||||
}
|
||||
|
||||
const started = await dockerRequest({
|
||||
method: "POST",
|
||||
path: `/containers/${created.data.Id}/start`,
|
||||
});
|
||||
|
||||
if (!started.ok && started.status != 304) {
|
||||
throw new Error(`Couldn't start the container: ${started.message}`);
|
||||
}
|
||||
|
||||
const state = await inspectDockerContainer({ container: created.data.Id });
|
||||
|
||||
return {
|
||||
docker_id: created.data.Id,
|
||||
network,
|
||||
status: state?.status || "unknown",
|
||||
host_dir,
|
||||
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
method?: "GET" | "POST" | "DELETE";
|
||||
/**
|
||||
* Docker engine API path, e.g. `/containers/json`.
|
||||
*/
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
timeout_ms?: number;
|
||||
};
|
||||
|
||||
export type DockerResponse<T> = {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
data: T | null;
|
||||
/**
|
||||
* The engine's own message when the call failed.
|
||||
*/
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One call to the docker engine API over its unix socket
|
||||
* (`AppData.DockerSocketPath`, or `DOCKER_SOCKET` in the process env).
|
||||
* Never throws for an HTTP error; check `ok`. Throws when the engine
|
||||
* can't be reached at all.
|
||||
*/
|
||||
export default async function dockerRequest<T = unknown>({
|
||||
method = "GET",
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
timeout_ms = 60 * 1000,
|
||||
}: Params): Promise<DockerResponse<T>> {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(query || {})) {
|
||||
if (value !== undefined) search.set(key, String(value));
|
||||
}
|
||||
|
||||
const url = `http://docker${path}${search.size ? `?${search}` : ""}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
unix: process.env.DOCKER_SOCKET || AppData.DockerSocketPath,
|
||||
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout_ms),
|
||||
}).catch((error) => {
|
||||
throw new Error(
|
||||
`Can't reach docker: ${error instanceof Error ? error.message : String(error)}. The app needs the docker socket to create computers.`,
|
||||
);
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
data: res.ok ? (data as T) : null,
|
||||
message: res.ok
|
||||
? undefined
|
||||
: (data as { message?: string } | null)?.message || text || `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import dockerRequest from "./docker-request";
|
||||
import grabAppContainer from "./grab-app-container";
|
||||
|
||||
/**
|
||||
* Makes sure the network computers run on (`AppData.DockerNetworkName`)
|
||||
* exists and, when the app runs in docker, that its container is on it,
|
||||
* so it reaches computers by name.
|
||||
*/
|
||||
export default async function ensureDockerNetwork() {
|
||||
const name = AppData.DockerNetworkName;
|
||||
const existing = await dockerRequest({ path: `/networks/${encodeURIComponent(name)}` });
|
||||
|
||||
if (existing.status == 404) {
|
||||
const created = await dockerRequest({
|
||||
method: "POST",
|
||||
path: `/networks/create`,
|
||||
body: { Name: name, Driver: "bridge", CheckDuplicate: true },
|
||||
});
|
||||
|
||||
if (!created.ok && created.status != 409) {
|
||||
throw new Error(`Couldn't create the ${name} network: ${created.message}`);
|
||||
}
|
||||
} else if (!existing.ok) {
|
||||
throw new Error(`Couldn't read the ${name} network: ${existing.message}`);
|
||||
}
|
||||
|
||||
const container = await grabAppContainer();
|
||||
|
||||
if (!container || container.networks.includes(name)) return name;
|
||||
|
||||
const connected = await dockerRequest({
|
||||
method: "POST",
|
||||
path: `/networks/${encodeURIComponent(name)}/connect`,
|
||||
body: { Container: container.id },
|
||||
});
|
||||
|
||||
if (!connected.ok && connected.status != 403) {
|
||||
throw new Error(`Couldn't connect the app to the ${name} network: ${connected.message}`);
|
||||
}
|
||||
|
||||
container.networks.push(name);
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
export type AppContainer = {
|
||||
id: string;
|
||||
mounts: { Source: string; Destination: string }[];
|
||||
networks: string[];
|
||||
};
|
||||
|
||||
let cached: Promise<AppContainer | null> | undefined;
|
||||
|
||||
/**
|
||||
* The docker container this app runs in, or null when it runs straight on
|
||||
* the host (a dev server). Read once per process: docker sets the
|
||||
* container's hostname to its id.
|
||||
*/
|
||||
export default function grabAppContainer(): Promise<AppContainer | null> {
|
||||
cached ??= inspectSelf();
|
||||
return cached;
|
||||
}
|
||||
|
||||
async function inspectSelf(): Promise<AppContainer | null> {
|
||||
if (!fs.existsSync("/.dockerenv")) return null;
|
||||
|
||||
const res = await dockerRequest<{
|
||||
Id: string;
|
||||
Mounts?: AppContainer["mounts"];
|
||||
NetworkSettings?: { Networks?: Record<string, unknown> };
|
||||
}>({ path: `/containers/${encodeURIComponent(os.hostname())}/json` });
|
||||
|
||||
if (!res.ok || !res.data?.Id) {
|
||||
throw new Error(`The app runs in docker but can't find its own container: ${res.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: res.data.Id,
|
||||
mounts: res.data.Mounts || [],
|
||||
networks: Object.keys(res.data.NetworkSettings?.Networks || {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A port nothing on this host listens on right now, for publishing a
|
||||
* computer when the app runs outside docker and can't reach it by name.
|
||||
*/
|
||||
export default function grabFreePort() {
|
||||
const listener = Bun.listen({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
socket: { data() {} },
|
||||
});
|
||||
const port = listener.port;
|
||||
|
||||
listener.stop(true);
|
||||
|
||||
return port;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The container's id or name.
|
||||
*/
|
||||
container: string;
|
||||
};
|
||||
|
||||
export type DockerContainerState = {
|
||||
id: string;
|
||||
status: ContainerStatus;
|
||||
/**
|
||||
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
|
||||
*/
|
||||
ports: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A container's state, or null when docker has no such container.
|
||||
*/
|
||||
export default async function inspectDockerContainer({
|
||||
container,
|
||||
}: Params): Promise<DockerContainerState | null> {
|
||||
const res = await dockerRequest<{
|
||||
Id: string;
|
||||
State?: { Status?: ContainerStatus };
|
||||
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
|
||||
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
|
||||
|
||||
if (res.status == 404) return null;
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: res.data.Id,
|
||||
status: res.data.State?.Status || "unknown",
|
||||
ports: Object.fromEntries(
|
||||
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
|
||||
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* e.g. `ghcr.io/agent-infra/sandbox:latest`.
|
||||
*/
|
||||
image: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pulls an image unless docker already has it. Waits for the whole pull,
|
||||
* which can take minutes for a large image.
|
||||
*/
|
||||
export default async function pullDockerImage({ image }: Params) {
|
||||
const present = await dockerRequest({ path: `/images/${encodeURIComponent(image)}/json` });
|
||||
|
||||
if (present.ok) return;
|
||||
|
||||
const slash = image.lastIndexOf("/");
|
||||
const colon = image.lastIndexOf(":");
|
||||
const has_tag = colon > slash;
|
||||
|
||||
const res = await dockerRequest<string>({
|
||||
method: "POST",
|
||||
path: `/images/create`,
|
||||
query: {
|
||||
fromImage: has_tag ? image.slice(0, colon) : image,
|
||||
tag: has_tag ? image.slice(colon + 1) : "latest",
|
||||
},
|
||||
timeout_ms: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
* The pull streams JSON lines; a failure mid-way is a line with
|
||||
* `error`, under a 200.
|
||||
*/
|
||||
const failed = String(res.data || "")
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as { error?: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.find((line) => line?.error);
|
||||
|
||||
if (!res.ok || failed) {
|
||||
throw new Error(`Couldn't pull ${image}: ${failed?.error || res.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The container's id or name.
|
||||
*/
|
||||
container: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops and removes a container. Its bind-mounted files stay on the host.
|
||||
* A container docker doesn't have is already gone.
|
||||
*/
|
||||
export default async function removeDockerContainer({ container }: Params) {
|
||||
const res = await dockerRequest({
|
||||
method: "DELETE",
|
||||
path: `/containers/${encodeURIComponent(container)}`,
|
||||
query: { force: true },
|
||||
});
|
||||
|
||||
if (!res.ok && res.status != 404) {
|
||||
throw new Error(`Couldn't remove ${container}: ${res.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from "path";
|
||||
import grabAppContainer from "./grab-app-container";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A path as this app sees it.
|
||||
*/
|
||||
local_path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The same path as the docker host sees it, for a bind mount: docker
|
||||
* resolves mount sources on the host, not in this app's container. In a
|
||||
* container the path must sit under one of its mounts (the deepest one
|
||||
* wins); on the host it's returned as it is.
|
||||
*/
|
||||
export default async function resolveHostPath({ local_path }: Params) {
|
||||
const container = await grabAppContainer();
|
||||
const target = path.resolve(local_path);
|
||||
|
||||
if (!container) return target;
|
||||
|
||||
const mount = container.mounts
|
||||
.filter(
|
||||
(m) => target == m.Destination || target.startsWith(`${m.Destination.replace(/\/+$/, "")}/`),
|
||||
)
|
||||
.sort((a, b) => b.Destination.length - a.Destination.length)[0];
|
||||
|
||||
if (!mount) {
|
||||
throw new Error(
|
||||
`${target} isn't on a volume, so computers can't keep their files there. Mount DATA_DIR into the app's container.`,
|
||||
);
|
||||
}
|
||||
|
||||
return path.join(mount.Source, path.relative(mount.Destination, target));
|
||||
}
|
||||
Reference in New Issue
Block a user