Run computers with resources and health checks, add settings for bots, workspaces, computers and users

- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and
  editable; new resources stop, remove and recreate the container, its home
  dir kept. Every computer container has a docker health check and stays
  "creating" until it passes; only healthy computers are reached, by
  hostname, else by their IP on the network (dev outside docker).
- Docker is required: the app creates the cobble network on startup and
  exits without docker.
- Production container: deploy/run-app-container.sh mounts .env, the data
  dir and the docker socket; the database (DB_DIR), media, computers' homes
  and bun-sqlite's live schema now live on the data volume.
- Settings pages for bots, workspaces, computers and users, sharing list
  components; bots can be deleted; users are created with a password and
  confirmation, edited and deleted through RBAC-gated APIs.
- User types trimmed to super_admin, admin, member, read_only and revoked;
  admins manage members. UserAvatar shows profile photos from the user row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 10:26:44 +01:00
co-authored by Claude Opus 5.5
parent 0ce61cdb49
commit f24c0d7e82
91 changed files with 2692 additions and 331 deletions
@@ -1,13 +1,16 @@
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";
import waitForHealthyContainer from "./wait-for-healthy-container";
/**
* Docker takes health check durations in nanoseconds.
*/
const seconds = (value: number) => value * 1e9;
type Params = {
computer_id: number;
@@ -19,22 +22,36 @@ type Params = {
* The computer's home directory as this app sees it.
*/
data_dir: string;
/**
* CPU cores the container may use.
*/
cpus: number;
/**
* The container's memory limit in MB.
*/
memory_mb: number;
};
/**
* 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.
* Creates and starts (the API's `docker run`) an AIO sandbox container
* for a computer (`AppData.ComputerImage`), attached to the computers'
* network (created first when missing), with its home directory
* bind-mounted from `data_dir`, limited to `cpus` and `memory_mb`, with a
* health check (`AppData.ComputerHealthcheck`), and restarted with
* docker. A container left by an earlier try is stopped and replaced.
* Resolves once the health check passes, with its IP address on the network, which a dev
* server outside docker reaches it at, since it can't resolve the
* hostname.
*/
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
export default async function createComputerContainer({
computer_id,
name,
data_dir,
cpus,
memory_mb,
}: 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 });
@@ -49,18 +66,25 @@ export default async function createComputerContainer({ computer_id, name, data_
Labels: {
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
},
ExposedPorts: { [port_key]: {} },
ExposedPorts: { [`${AppData.ComputerPort}/tcp`]: {} },
Healthcheck: {
Test: [
"CMD-SHELL",
`curl -fs -o /dev/null http://localhost:${AppData.ComputerPort}/v1/sandbox && ([ "$DISABLE_BROWSER" = "true" ] || nc -z localhost "$BROWSER_REMOTE_DEBUGGING_PORT") || exit 1`,
],
Interval: seconds(AppData.ComputerHealthcheck.IntervalSeconds),
Timeout: seconds(AppData.ComputerHealthcheck.TimeoutSeconds),
StartPeriod: seconds(AppData.ComputerHealthcheck.StartPeriodSeconds),
Retries: AppData.ComputerHealthcheck.Retries,
},
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,
Memory: memory_mb * 1024 * 1024,
NanoCpus: Math.round(cpus * 1e9),
},
NetworkingConfig: {
EndpointsConfig: { [network]: { Aliases: [name] } },
@@ -81,13 +105,13 @@ export default async function createComputerContainer({ computer_id, name, data_
throw new Error(`Couldn't start the container: ${started.message}`);
}
const state = await inspectDockerContainer({ container: created.data.Id });
const state = await waitForHealthyContainer({ container: created.data.Id });
return {
docker_id: created.data.Id,
network,
status: state?.status || "unknown",
status: state.status,
host_dir,
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
ip_address: state.ip_address,
};
}
@@ -1,16 +0,0 @@
/**
* 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;
}
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import type { ContainerStatus } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request";
@@ -10,11 +11,19 @@ type Params = {
export type DockerContainerState = {
id: string;
/**
* `State.Status`, with `running` narrowed by the health check
* (`starting`, `unhealthy`). A container without one is `running`.
*/
status: ContainerStatus;
/**
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
*/
ports: Record<string, string>;
/**
* Its IP address on `AppData.DockerNetworkName`, when it's on it and running.
*/
ip_address: string | null;
};
/**
@@ -25,8 +34,14 @@ export default async function inspectDockerContainer({
}: Params): Promise<DockerContainerState | null> {
const res = await dockerRequest<{
Id: string;
State?: { Status?: ContainerStatus };
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
State?: {
Status?: ContainerStatus;
Health?: { Status?: "starting" | "healthy" | "unhealthy" | "none" };
};
NetworkSettings?: {
Ports?: Record<string, { HostPort?: string }[] | null>;
Networks?: Record<string, { IPAddress?: string }>;
};
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
if (res.status == 404) return null;
@@ -35,13 +50,20 @@ export default async function inspectDockerContainer({
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
}
const status = res.data.State?.Status || "unknown";
const health = res.data.State?.Health?.Status;
return {
id: res.data.Id,
status: res.data.State?.Status || "unknown",
status:
status == "running" && (health == "starting" || health == "unhealthy")
? health
: status,
ports: Object.fromEntries(
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
),
),
ip_address: res.data.NetworkSettings?.Networks?.[AppData.DockerNetworkName]?.IPAddress || null,
};
}
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import dockerRequest from "./docker-request";
type Params = {
@@ -8,10 +9,17 @@ type Params = {
};
/**
* Stops and removes a container. Its bind-mounted files stay on the host.
* A container docker doesn't have is already gone.
* Stops a container (killed after `AppData.ComputerStopTimeoutSeconds`),
* then removes it. Its bind-mounted files stay on the host. A container
* docker doesn't have is already gone.
*/
export default async function removeDockerContainer({ container }: Params) {
await dockerRequest({
method: "POST",
path: `/containers/${encodeURIComponent(container)}/stop`,
query: { t: AppData.ComputerStopTimeoutSeconds },
});
const res = await dockerRequest({
method: "DELETE",
path: `/containers/${encodeURIComponent(container)}`,
@@ -0,0 +1,37 @@
import { AppData } from "@/src/data/app-data";
import inspectDockerContainer from "./inspect-docker-container";
type Params = {
/**
* The container's id or name.
*/
container: string;
};
/**
* Waits until a container's health check passes (`running`), checking
* every `AppData.ComputerHealthPollMs` for at most
* `AppData.ComputerHealthyTimeoutMs`, and returns its state. Throws when
* it turns unhealthy, stops, or takes too long.
*/
export default async function waitForHealthyContainer({ container }: Params) {
const deadline = Date.now() + AppData.ComputerHealthyTimeoutMs;
while (true) {
const state = await inspectDockerContainer({ container });
if (state?.status == "running") return state;
if (!state || !["starting", "created", "restarting"].includes(state.status)) {
throw new Error(`The container is ${state?.status || "gone"} instead of passing its health check.`);
}
if (Date.now() > deadline) {
throw new Error(
`The container didn't pass its health check within ${Math.round(AppData.ComputerHealthyTimeoutMs / 60000)} minutes.`,
);
}
await Bun.sleep(AppData.ComputerHealthPollMs);
}
}