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
@@ -17,6 +17,15 @@ type Params = {
* `main` only for the owner's one main computer (`ensureMainComputer`).
*/
kind?: ComputerKind;
/**
* CPU cores its container may use. Defaults to `AppData.ComputerDefaultCpus`.
*/
cpus?: number;
/**
* Its container's memory limit in MB. Defaults to
* `AppData.ComputerDefaultMemoryMb`.
*/
memory_mb?: number;
};
/**
@@ -24,7 +33,8 @@ type Params = {
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
* in the background (`provisionComputer`). Returns the computer at once,
* still `creating`, and the setup to await when the caller must (a CLI
* script). The main computer of a server that reaches it at
* script). Its resources (`cpus`, `memory_mb`) are saved on the computer
* and applied to its container whenever it is set up. The main computer of a server that reaches it at
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
*/
export default async function createComputer({
@@ -32,6 +42,8 @@ export default async function createComputer({
name,
description,
kind = "sandbox",
cpus = AppData.ComputerDefaultCpus,
memory_mb = AppData.ComputerDefaultMemoryMb,
}: Params) {
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
@@ -70,6 +82,8 @@ export default async function createComputer({
name,
description: description || undefined,
kind,
cpus,
memory_mb,
},
],
});
@@ -3,30 +3,39 @@ import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_CONTAINERS,
} from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import { ComputerMcpPath } from "@/src/dict/computer-dict";
import { ComputerMcpPath, type ContainerStatus } from "@/src/dict/computer-dict";
import grabEnv from "../envs/grab-env";
import pickReachableAddress from "./pick-reachable-address";
import refreshComputerContainer from "./refresh-computer-container";
type Params = {
computer_id: number | string;
};
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> &
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">;
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "kind"> &
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "hostname" | "ip_address" | "port">;
export type ComputerAddress =
| { ready: true; url: string; base_url: string; name: string }
| { ready: false; status: ContainerStatus; name: string };
/**
* Where a computer's MCP server answers. In order: the container's
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app
* setting, `grabEnv`: for a server outside docker, e.g.
* `http://localhost:8170`), else its docker hostname and port. `base_url`
* is the same address without `/mcp`, for the sandbox's REST API. Null
* when the computer has no container.
* Where a computer's MCP server answers: `MAIN_COMPUTER_URL` for the
* main computer (an app setting, `grabEnv`: for a server outside docker,
* e.g. `http://localhost:8170`), else its docker hostname and port, or
* its IP address on the network when the hostname can't be reached (a
* dev server outside docker, `pickReachableAddress`). `base_url` is the
* same address without `/mcp`, for the sandbox's REST API. Only a
* computer whose health check passed (`running`) is reached; any other
* state is read again from docker first, since it may have come back
* since the last sync, and returned as `ready: false`. Null when the
* computer has no container.
*/
export default async function grabComputerMcpUrl({ computer_id }: Params) {
export default async function grabComputerMcpUrl({ computer_id }: Params): Promise<ComputerAddress | null> {
const row = (
await BunSQLite.sql<ComputerRow>({
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata
sql: `SELECT co.name, co.kind, c.id, c.docker_id, c.status, c.hostname, c.ip_address, c.port
FROM computers co JOIN containers c ON c.id = co.container_id
WHERE co.id = ? LIMIT 1`,
values: [computer_id],
@@ -35,23 +44,37 @@ export default async function grabComputerMcpUrl({ computer_id }: Params) {
if (!row?.hostname) return null;
const metadata = parseJobJson({ text: row.metadata }) || {};
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
return {
url: metadata.mcp_url,
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
name: row.name || "",
};
}
const name = row.name || "";
const main_override = row.kind == "main"
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
: undefined;
const base = main_override
? main_override.replace(/\/+$/, "")
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`;
if (main_override) {
const base_url = main_override.replace(/\/+$/, "");
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
}
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" };
let status = (row.status || "unknown") as ContainerStatus;
let ip_address: string | null | undefined = row.ip_address;
if (status != "running" && status != "creating" && status != "failed") {
const refreshed = await refreshComputerContainer({ computer_id, container: row });
if (refreshed) ({ status, ip_address } = refreshed);
}
if (status != "running") return { ready: false, status, name };
const port = row.port || AppData.ComputerPort;
const base_url = await pickReachableAddress({
key: `computer:${computer_id}`,
candidates: [
`http://${row.hostname}:${port}`,
...(ip_address ? [`http://${ip_address}:${port}`] : []),
],
});
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
}
@@ -0,0 +1,26 @@
import { afterAll, describe, expect, test } from "bun:test";
import pickReachableAddress from "./pick-reachable-address";
const server = Bun.serve({ port: 0, fetch: () => new Response("", { status: 404 }) });
const reachable = `http://127.0.0.1:${server.port}`;
const unreachable = "http://cobble-computer-test.invalid:8080";
afterAll(() => server.stop(true));
describe("pickReachableAddress", () => {
test("falls back to the next address when the first can't be reached", async () => {
expect(await pickReachableAddress({ key: "a", candidates: [unreachable, reachable] })).toBe(reachable);
});
test("keeps the first address when it answers, whatever the status", async () => {
expect(await pickReachableAddress({ key: "b", candidates: [reachable, unreachable] })).toBe(reachable);
});
test("returns the first address when none answers", async () => {
expect(await pickReachableAddress({ key: "c", candidates: [unreachable, "http://127.0.0.1:1"] })).toBe(unreachable);
});
test("returns a lone address without trying it", async () => {
expect(await pickReachableAddress({ key: "d", candidates: [unreachable] })).toBe(unreachable);
});
});
@@ -0,0 +1,53 @@
import { AppData } from "@/src/data/app-data";
type Params = {
/**
* Caches the pick, e.g. `computer:<id>`.
*/
key: string;
/**
* Base URLs in the order to try, e.g. the hostname, then the IP address.
*/
candidates: [string, ...string[]];
};
const picked = new Map<string, { base_url: string; at: number }>();
/**
* The first of `candidates` that answers over HTTP (any status counts),
* each given `AppData.ComputerReachTimeoutMs`. The pick is reused for
* `AppData.ComputerAddressCacheMs` while it is still a candidate. When
* none answers, the first, so the caller's own error names it.
*/
export default async function pickReachableAddress({ key, candidates }: Params) {
if (candidates.length < 2) return candidates[0];
const cached = picked.get(key);
if (
cached &&
Date.now() - cached.at < AppData.ComputerAddressCacheMs &&
candidates.includes(cached.base_url)
) {
return cached.base_url;
}
for (const base_url of candidates) {
const reached = await fetch(base_url, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(AppData.ComputerReachTimeoutMs),
})
.then(() => true)
.catch(() => false);
if (reached) {
picked.set(key, { base_url, at: Date.now() });
return base_url;
}
}
picked.delete(key);
return candidates[0];
}
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
@@ -17,15 +17,18 @@ type Params = {
/**
* Sets up a computer's container: prepares its home directory, pulls the
* sandbox image and starts the container, then records it (`containers`:
* docker id, state, and where to reach it outside docker). A failure is
* sandbox image and starts the container with the computer's resources
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
* docker id, state, and its IP address on the network). A failure is
* recorded as `failed` with its reason, which the computer lists show. The
* owner's tabs hear about it either way. Never throws.
*/
export default async function provisionComputer({ computer_id, fresh }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
await BunSQLite.sql<
BUN_SQLITE_COBBLE_CONTAINERS & Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">
>({
sql: `SELECT co.*, c.cpus, c.memory_mb FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
values: [computer_id],
})
).payload?.[0];
@@ -41,26 +44,28 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
});
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
const created = await createComputerContainer({ computer_id, name: row.name, data_dir });
const created = await createComputerContainer({
computer_id,
name: row.name,
data_dir,
cpus: Number(row.cpus) || AppData.ComputerDefaultCpus,
memory_mb: Number(row.memory_mb) || AppData.ComputerDefaultMemoryMb,
});
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
await BunSQLite.sql({
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?,
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, ip_address = ?,
port = ?, status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [
created.docker_id,
AppData.ComputerImage,
created.network,
row.name,
created.ip_address,
AppData.ComputerPort,
created.status,
JSON.stringify({
...kept,
data_dir,
host_dir: created.host_dir,
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
}),
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
Date.now(),
Date.now(),
row.id,
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import type { ContainerStatus } from "@/src/dict/computer-dict";
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
import pushComputerUpdate from "./push-computer-update";
type Params = {
computer_id: number | string;
container: Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address">;
};
/**
* Reads a computer container's state from docker and records it
* (`status`, `ip_address`, which can change when it restarts,
* `last_seen_at`), telling the owner when its state changed. A container
* docker no longer has is `dead`. Returns the state recorded, or null
* when docker couldn't be asked.
*/
export default async function refreshComputerContainer({ computer_id, container }: Params) {
if (!container.id || !container.docker_id) return null;
const state = await inspectDockerContainer({ container: container.docker_id }).catch(() => undefined);
if (state === undefined) return null;
const status: ContainerStatus = state?.status || "dead";
const ip_address = state?.ip_address || container.ip_address || null;
await BunSQLite.sql({
sql: `UPDATE containers SET status = ?, ip_address = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [status, ip_address, Date.now(), Date.now(), container.id],
});
const changed = status != container.status;
if (changed) await pushComputerUpdate({ computer_id }).catch(() => {});
return { status, ip_address, changed };
}
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BunSQLiteTables,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import _ from "lodash";
import grabEnv from "../envs/grab-env";
import grabUserComputers from "./grab-user-computers";
import grabUserComputer from "./grab-user-computer";
import provisionComputer from "./provision-computer";
type Params = {
user_id: number | string;
computer_id: number | string;
/**
* Only the fields given are changed.
*/
data: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "description" | "cpus" | "memory_mb">;
};
/**
* Updates one of the user's computers. When its container's settings
* change (`cpus`, `memory_mb`), the container is stopped, removed and
* run again with them in the background (`provisionComputer`); its home
* directory is bind-mounted, so its files stay. A name or description
* alone leaves the container running. The main computer of a server that
* reaches it at `MAIN_COMPUTER_URL` has no container to recreate.
*/
export default async function updateComputer({ user_id, computer_id, data }: Params) {
const computer = await grabUserComputer({ computer_id, user_id });
if (!computer?.id) {
throw new AppError(`Computer not found.`);
}
const recreate =
(data.cpus !== undefined && Number(data.cpus) != Number(computer.cpus)) ||
(data.memory_mb !== undefined && Number(data.memory_mb) != Number(computer.memory_mb));
if (recreate && (await grabUserComputers({ computer_id: computer.id }))[0]?.status == "creating") {
throw new AppError(`${computer.name || "The computer"} is still being set up. Try again once it's ready.`);
}
const res = await BunSQLite.update<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
targetId: computer.id,
/**
* `BunSQLite.update` writes every key it gets, so a field left out
* here would be set to null.
*/
data: _.omitBy(
{
name: data.name,
description: data.description,
cpus: data.cpus,
memory_mb: data.memory_mb,
},
_.isUndefined,
),
});
if (!res.success) {
throw new Error(`Couldn't update the computer: ${res.msg}`);
}
const external =
computer.kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
const recreating = recreate && !external && Boolean(computer.container_id);
const provisioning = recreating
? provisionComputer({ computer_id: Number(computer.id) })
: Promise.resolve();
return { recreating, provisioning };
}