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:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
@@ -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));
}