Give workspace computers to attached bots in their own chat

A computer linked to a workspace now shows in the composer (and is usable
by tasks and replies) when chatting with any bot attached to that
workspace, not only in the workspace conversation. Members see those
computers through their assigned workers too, and assign_computer still
describes direct grants only.
This commit is contained in:
2026-10-19 20:03:38 +01:00
parent f57b464e80
commit b0c9c84b2b
6 changed files with 52 additions and 17 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session via `createKeyLock`, so a bot's other sessions answer side by side; a task waiting for its busy computer shows face `asleep` with `BotComputerBusyStatusLine`; face `working` once it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face, `grabRunningBotJobFace`). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way as `server:workspace-created` (`WorkspaceCreatedData`: its rail row), from the tool and from the live relay. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, creates a workspace with `create_workspace`, and changes any workspace's name, description or context with `update_workspace` (chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed as `server:workspace-created`).
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): the workspace's computers in a workspace, its default first; in its own chat, its direct grants and those of the workspaces it's attached to (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
- **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`).
@@ -8,20 +8,28 @@ type Params = {
* workspace's, the default first, and nothing else.
*/
workspace_id?: number | string | null;
/**
* In its own chat, whether the computers of the workspaces it's
* attached to count too (they do by default). The `assign_computer`
* tool's view of direct access turns them off.
*/
include_workspace_computers?: boolean;
};
/**
* Every computer a bot can use where it's working. In a workspace, the
* workspace's computers (none linked: none at all), its default first. In
* its own chat, the ones it was given directly (`bot_computers`); the
* coordinator can use all of them. There the one it works on now
* (`bots.computer_id`) comes first, else the main one, so `computers[0]`
* is where its tasks run by default. The same for every user it works
* for: callers check the user reaches the bot.
* its own chat, the ones it was given directly (`bot_computers`) and those
* of the workspaces it's attached to, since a workspace's computer is
* every bit its own; the coordinator can use all of them. There the one it
* works on now (`bots.computer_id`) comes first, else the main one, so
* `computers[0]` is where its tasks run by default. The same for every
* user it works for: callers check the user reaches the bot.
*/
export default async function grabBotComputers({
bot_id,
workspace_id,
include_workspace_computers = true,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
if (workspace_id) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
@@ -39,15 +47,26 @@ export default async function grabBotComputers({
return res.payload || [];
}
const workspace_computers_sql = include_workspace_computers
? `OR c.id IN (
SELECT wc.computer_id FROM workspace_computers wc
JOIN workspaces w ON w.id = wc.workspace_id AND w.archived = 0
JOIN workspace_bots wb ON wb.workspace_id = w.id AND wb.bot_id = ?
)`
: "";
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
WHERE (
(SELECT kind FROM bots WHERE id = ?) = 'coordinator'
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
${workspace_computers_sql}
)
ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC,
c.kind = 'main' DESC, c.id ASC`,
values: [bot_id, bot_id, bot_id],
values: include_workspace_computers
? [bot_id, bot_id, bot_id, bot_id]
: [bot_id, bot_id, bot_id],
});
return res.payload || [];
@@ -28,9 +28,10 @@ type Args = {
};
/**
* Lets the coordinator give a crew member access to one of the user's
* computers in its own chat (`bot_computers`), or take it away. In a
* workspace, bots use the workspace's computers instead.
* Lets the coordinator give a crew member direct access to one of the
* user's computers (`bot_computers`), or take it away. The computers of
* the workspaces a bot is attached to are its own too, in its own chat as
* well as in the workspace.
*/
export default function assignComputerTool({
user_id,
@@ -76,7 +77,7 @@ export default function assignComputerTool({
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
return `${worker.name} can't use ${ref} in its own chat any more. In a workspace it still uses the workspace's computers.`;
return `${worker.name} can't use ${ref} directly any more. A workspace it's attached to may still give it.`;
},
};
}
+4 -1
View File
@@ -127,7 +127,10 @@ export default async function grabBotChatTools({
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0 }),
await grabBotComputers({
bot_id: worker.id || 0,
include_workspace_computers: false,
}),
]),
),
);
+9 -2
View File
@@ -3,7 +3,8 @@ import type { CrewAccessParams, SqlCondition } from "./types";
/**
* The computers a user opens and sees: super admins and admins every one,
* anyone else only those of the bots they chat with (the computers of
* their workspaces, and those their assigned workers were given).
* their workspaces, those their assigned workers were given, and those of
* the workspaces their assigned workers are attached to).
*/
export default function computersSql({ role, user_id, alias = "c" }: CrewAccessParams): SqlCondition {
if (role != "member") return { sql: `1`, values: [] };
@@ -16,7 +17,13 @@ export default function computersSql({ role, user_id, alias = "c" }: CrewAccessP
SELECT bc.computer_id FROM bot_computers bc
JOIN bot_users bu ON bu.bot_id = bc.bot_id AND bu.user_id = ?
JOIN bots b ON b.id = bc.bot_id AND b.kind != 'coordinator'
UNION
SELECT wc.computer_id FROM workspace_computers wc
JOIN workspaces w ON w.id = wc.workspace_id AND w.archived = 0
JOIN workspace_bots wb ON wb.workspace_id = w.id
JOIN bot_users bu ON bu.bot_id = wb.bot_id AND bu.user_id = ?
JOIN bots b ON b.id = wb.bot_id AND b.kind != 'coordinator'
)`,
values: [Number(user_id), Number(user_id)],
values: [Number(user_id), Number(user_id), Number(user_id)],
};
}
@@ -10,13 +10,13 @@ const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY, kind TEXT)`);
db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY)`);
db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY, archived INTEGER DEFAULT 0)`);
db.run(`CREATE TABLE bot_users (bot_id INTEGER, user_id INTEGER)`);
db.run(`CREATE TABLE workspace_users (workspace_id INTEGER, user_id INTEGER)`);
db.run(`CREATE TABLE workspace_bots (workspace_id INTEGER, bot_id INTEGER)`);
db.run(`INSERT INTO bots VALUES (1, 'coordinator'), (2, 'worker'), (3, 'worker')`);
db.run(`INSERT INTO workspaces VALUES (10), (11)`);
db.run(`INSERT INTO bot_users VALUES (2, 7), (1, 7)`);
db.run(`INSERT INTO workspaces VALUES (10, 0), (11, 0)`);
db.run(`INSERT INTO bot_users VALUES (2, 7), (1, 7), (3, 9)`);
db.run(`INSERT INTO workspace_users VALUES (11, 7)`);
db.run(`INSERT INTO workspace_bots VALUES (11, 3)`);
db.run(`CREATE TABLE computers (id INTEGER PRIMARY KEY)`);
@@ -46,6 +46,7 @@ describe("chatBotsSql", () => {
test("a member only with workers assigned to them, even if the coordinator was", () => {
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 7 }) })).toEqual([2]);
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 8 }) })).toEqual([]);
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 9 }) })).toEqual([3]);
});
});
@@ -72,4 +73,8 @@ describe("computersSql", () => {
expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 7 }) })).toEqual([21, 22]);
expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 8 }) })).toEqual([]);
});
test("a member sees the computers of the workspaces their assigned workers are in", () => {
expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 9 }) })).toEqual([21]);
});
});