diff --git a/AGENTS.md b/AGENTS.md index 87b745f..e62ace6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - 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 `-` 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/` (`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 `-` 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/` (`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//` (`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 ", 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`). diff --git a/src/functions/backend/db/computers/grab-bot-computers.ts b/src/functions/backend/db/computers/grab-bot-computers.ts index 17c1136..cec3a41 100644 --- a/src/functions/backend/db/computers/grab-bot-computers.ts +++ b/src/functions/backend/db/computers/grab-bot-computers.ts @@ -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 { if (workspace_id) { const res = await BunSQLite.sql({ @@ -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({ 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 || []; diff --git a/src/harness/tools/bot/assign-computer-tool.ts b/src/harness/tools/bot/assign-computer-tool.ts index dd69805..c390a5c 100644 --- a/src/harness/tools/bot/assign-computer-tool.ts +++ b/src/harness/tools/bot/assign-computer-tool.ts @@ -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.`; }, }; } diff --git a/src/harness/tools/bot/grab-bot-chat-tools.ts b/src/harness/tools/bot/grab-bot-chat-tools.ts index 1b2af01..c75a4cb 100644 --- a/src/harness/tools/bot/grab-bot-chat-tools.ts +++ b/src/harness/tools/bot/grab-bot-chat-tools.ts @@ -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, + }), ]), ), ); diff --git a/src/utils/crew-access/computers-sql.ts b/src/utils/crew-access/computers-sql.ts index bf0ff76..b5d22b0 100644 --- a/src/utils/crew-access/computers-sql.ts +++ b/src/utils/crew-access/computers-sql.ts @@ -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)], }; } diff --git a/src/utils/crew-access/crew-access-sql.test.ts b/src/utils/crew-access/crew-access-sql.test.ts index 2355a3d..887e1cf 100644 --- a/src/utils/crew-access/crew-access-sql.test.ts +++ b/src/utils/crew-access/crew-access-sql.test.ts @@ -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]); + }); });