Add computer switching, crew reporting, job steps and markdown chat

- Bots have a current computer (bots.computer_id), switched from the chat
  header beside the model button or by clicking a computer the bot names
  ([computer:ID]); tasks run on it by default
- search_computers runs one read-only command on all of a bot's computers;
  chat replies know their computers (compose-computers-note)
- The coordinator can use every computer and grants or revokes crew access
  with assign_computer
- Delegated jobs record requested_by_bot_id; the worker's result or failure
  is reported back in the coordinator's conversation
- bots_queue_updates records each model turn of a job, pushed live and shown
  in the working-on panel
- Live relay: every process pushes new messages, job steps and bot changes
  from the database to its own sockets, so work done elsewhere shows live
- Bot messages render as markdown (react-markdown + remark-gfm, no raw HTML)
  with twui components; rail previews are plain text
- Fix header button overflow in the model and computer selects
- Bump @moduletrace/twui to 0.1.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 04:44:35 +01:00
co-authored by Claude Opus 5.5
parent 6a2665dae9
commit 7b580d782e
64 changed files with 1792 additions and 86 deletions
+9 -8
View File
@@ -19,7 +19,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w
- `/media/*` short-circuits to the media server handler - `/media/*` short-circuits to the media server handler
- `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/` - `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/`
- everything else goes to `bunext.bunextRequestHandler` - everything else goes to `bunext.bunextRequestHandler`
- `cron()` and the bots queue handler (`botsQueue()`, `src/bots-queue/`) start after the server is up; in production both are gated to the `MAIN_SERVER` container - `cron()` and the bots queue handler (`botsQueue()`, `src/bots-queue/`) start after the server is up, then `liveRelay()` (`src/live-relay/`, every process, ungated); in production cron and the queue are gated to the `MAIN_SERVER` container
## Commands ## Commands
@@ -87,16 +87,17 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Coordinator:** every user has exactly one `kind: coordinator` bot that coordinates the crew and delegates to the workers. It is created with the account (signup, `bun run seed`) and, as a fallback, by `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Coordinator:** every user has exactly one `kind: coordinator` bot that coordinates the crew and delegates to the workers. It is created with the account (signup, `bun run seed`) and, as a fallback, by `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
- **Routes:** `/admin/bots` ensures the coordinator and redirects to its chat (`/admin/bots/1` on a fresh install); `/admin/bots/[bot_id]` renders conversation + working-on panel; `/admin/bots/[bot_id]/models` is a worker bot's own model settings (the coordinator's redirects to its chat). Both render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. - **Routes:** `/admin/bots` ensures the coordinator and redirects to its chat (`/admin/bots/1` on a fresh install); `/admin/bots/[bot_id]` renders conversation + working-on panel; `/admin/bots/[bot_id]/models` is a worker bot's own model settings (the coordinator's redirects to its chat). Both render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `mark-bot-read`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot owned by the session user (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears unread for a message that arrives in the open conversation; `save-harness-settings.ts` saves a worker bot's models. - **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `mark-bot-read`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot owned by the session user (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears unread for a message that arrives in the open conversation; `save-harness-settings.ts` saves a worker bot's models.
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message. Live notices come from the process that ran the job, so two dev servers on one database split them. - **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). User messages stay plain text.
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text. - **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
- **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only). - **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only).
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate `memory` (markdown, what it must always remember). The bot rewrites both as it adapts to the user (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then each non-archived workspace's `system_prompt` under `# Workspace: <name>`, then `# Memory`. - **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate `memory` (markdown, what it must always remember). The bot rewrites both as it adapts to the user (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then each non-archived workspace's `system_prompt` under `# Workspace: <name>`, then `# Memory`.
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created for the working-on panel unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. - **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created for the working-on panel unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The working-on panel shows the active task's latest `AppData.BotJobUpdatesShown` steps (`grabBotJobUpdates`, on `BotTaskWithSteps.updates`) when the task has no planned `bot_task_steps`, the newest as in progress.
- **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 bot via `createKeyLock`, face `working` once it starts, outcome pushed live). 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`. - **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 bot via `createKeyLock`, face `working` once it starts, outcome pushed live). 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; the working-on panel takes the pushed task. - **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; the working-on panel takes the pushed task. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` 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 computer select in step across tabs. The rail previews a message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `archived`), with bots assigned through `workspace_bots` and computers through `workspace_computers`. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `add/remove-bot-*`, `add/remove-computer-*`), all ownership-checked against the session user. - **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `archived`), with bots assigned through `workspace_bots` and computers through `workspace_computers`. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `add/remove-bot-*`, `add/remove-computer-*`), all ownership-checked against the session user.
- **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` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). Each user has one main computer, recorded with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which also records its container (`AppData.MainComputerContainerName`, created by the installer) and grants the coordinator access. 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`) are its direct grants plus every computer assigned to a non-archived workspace it's in. 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` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). Each user has one main computer, recorded with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which also records its container (`AppData.MainComputerContainerName`, created by the installer) and grants the coordinator access. 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`) are its direct grants plus every computer assigned to a non-archived workspace it's in (the coordinator's are all of the user's computers), 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-computer-select.tsx`, beside the model button) 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`.
- **Not built yet:** add-bot flow, routines, help, task steps (the working-on panel shows the task title; nothing writes `bot_task_steps` yet), telling the coordinator when a delegated task finishes. - **Not built yet:** add-bot flow, routines, help, task steps (the working-on panel shows the task title; nothing writes `bot_task_steps` yet).
### Envs ### Envs
@@ -116,9 +117,9 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Automatic picks** (`rank-auto-models`): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first. - **Automatic picks** (`rank-auto-models`): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first.
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`). - **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
- **Per-bot models:** a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a model button (the text model it runs on) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the button is read only for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Per-bot models:** a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a model button (the text model it runs on) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the button is read only for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only). - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (container `metadata.mcp_url`, else the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (container `metadata.mcp_url`, else the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote` and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`. - **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data. - **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
## Admin Route Hierarchy ## Admin Route Hierarchy
+2 -2
View File
@@ -8,7 +8,7 @@
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3", "@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.1", "@moduletrace/twui": "^0.1.2",
"ace-builds": "^1.44.0", "ace-builds": "^1.44.0",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -234,7 +234,7 @@
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="], "@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
"@moduletrace/twui": ["@moduletrace/[email protected].1", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.1/twui-0.1.1.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-U/bLK1uqZW1G8+LqhP9WPyw66Z6c2xjnTJDJSu9cev4Lij7vGQ410bNb8vMjSRd8KBXBxs7jIP+KqzFK/DcOWQ=="], "@moduletrace/twui": ["@moduletrace/[email protected].2", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.2/twui-0.1.2.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-90j6jKyMWlgPAENTKW89Yj74tEA9cXXHQYKmxZKqL0T9gK/tYCSM+Rd/mo+1p3vJmNwTOHLViksL7+0v5Wk7Zg=="],
"@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="], "@next/mdx": ["@next/[email protected]", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="],
+63 -1
View File
@@ -15,7 +15,7 @@ import {
} from "@/src/dict/computer-dict"; } from "@/src/dict/computer-dict";
import { Harnesses } from "@/src/dict/harness-dict"; import { Harnesses } from "@/src/dict/harness-dict";
import { HarnessProviderKinds } from "@/src/dict/harness-provider-dict"; import { HarnessProviderKinds } from "@/src/dict/harness-provider-dict";
import { BotJobKinds } from "@/src/dict/bot-queue-dict"; import { BotJobKinds, BotJobUpdateKinds } from "@/src/dict/bot-queue-dict";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types"; import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
import { EnvParadigms } from "@/src/dict/env-dict"; import { EnvParadigms } from "@/src/dict/env-dict";
@@ -272,6 +272,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldDescription: `Unix timestamp of the latest message, for rail ordering`, fieldDescription: `Unix timestamp of the latest message, for rail ordering`,
dataType: "INTEGER", dataType: "INTEGER",
}, },
{
fieldName: "computer_id",
fieldDescription: `The computer the bot works on now. Not a foreign key: null, or a computer it lost access to, means its main one`,
dataType: "INTEGER",
},
], ],
indexes: [ indexes: [
{ {
@@ -448,6 +453,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldDescription: `The computer to run on, if any. Checked against the bot's access when queued`, fieldDescription: `The computer to run on, if any. Checked against the bot's access when queued`,
dataType: "INTEGER", dataType: "INTEGER",
}, },
{
fieldName: "requested_by_bot_id",
fieldDescription: `The bot that handed this job over (the coordinator, through delegate_task). It gets the result or failure in its own conversation. Not a foreign key, so the job outlives it`,
dataType: "INTEGER",
},
{ {
fieldName: "title", fieldName: "title",
fieldDescription: `Short label for the job`, fieldDescription: `Short label for the job`,
@@ -498,6 +508,58 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "bots_queue_updates",
tableDescription: `What a bot did while working through a queued job, one row per step, oldest first. Live in the working-on panel while the job runs; deleted with the job when it's pruned`,
fields: [
{
fieldName: "bots_queue_id",
fieldDescription: `The job`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots_queue",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bots_queue_update_job_fk",
},
},
{
fieldName: "bot_id",
fieldDescription: `The bot doing the work (the job's), to read a bot's updates without a join`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bots_queue_update_bot_fk",
},
},
{
fieldName: "kind",
fieldDescription: `tool (the bot called tools) or note (it wrote something without calling one)`,
dataType: "TEXT",
options: BotJobUpdateKinds.map((k) => k.value),
},
{
fieldName: "text",
fieldDescription: `The update in one line, e.g. "Using sandbox_execute_bash"`,
dataType: "TEXT",
},
{
fieldName: "metadata",
fieldDescription: `JSON details: the step index, tool names, the model`,
dataType: "TEXT",
},
],
indexes: [
{
indexName: "idx_bots_queue_updates_job_created_at",
indexTableFields: ["bots_queue_id", "created_at"],
},
],
},
{ {
tableName: "workspaces", tableName: "workspaces",
fields: [ fields: [
+45 -1
View File
@@ -10,6 +10,7 @@ export const BunSQLiteTables = [
"bot_tasks", "bot_tasks",
"bot_task_steps", "bot_task_steps",
"bots_queue", "bots_queue",
"bots_queue_updates",
"workspaces", "workspaces",
"workspace_bots", "workspace_bots",
"bot_message_media", "bot_message_media",
@@ -193,6 +194,10 @@ export type BUN_SQLITE_COBBLE_BOTS = {
* Unix timestamp of the latest message, for rail ordering * Unix timestamp of the latest message, for rail ordering
*/ */
last_activity_at?: number | ""; last_activity_at?: number | "";
/**
* The computer the bot works on now. Not a foreign key: null, or a computer it lost access to, means its main one
*/
computer_id?: number | "";
} }
export type BUN_SQLITE_COBBLE_BOT_MESSAGES = { export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
@@ -291,6 +296,10 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
* The computer to run on, if any. Checked against the bot's access when queued * The computer to run on, if any. Checked against the bot's access when queued
*/ */
computer_id?: number | ""; computer_id?: number | "";
/**
* The bot that handed this job over (the coordinator, through delegate_task). It gets the result or failure in its own conversation. Not a foreign key, so the job outlives it
*/
requested_by_bot_id?: number | "";
/** /**
* Short label for the job * Short label for the job
*/ */
@@ -349,6 +358,41 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
last_error?: string; last_error?: string;
} }
export type BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* The job
*/
bots_queue_id?: number | "";
/**
* The bot doing the work (the job's), to read a bot's updates without a join
*/
bot_id?: number | "";
/**
* tool (the bot called tools) or note (it wrote something without calling one)
*/
kind?: "tool" | "note" | "";
/**
* The update in one line, e.g. "Using sandbox_execute_bash"
*/
text?: string;
/**
* JSON details: the step index, tool names, the model
*/
metadata?: string;
}
export type BUN_SQLITE_COBBLE_WORKSPACES = { export type BUN_SQLITE_COBBLE_WORKSPACES = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -792,4 +836,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
expose_to_computers?: 0 | 1 | ""; expose_to_computers?: 0 | 1 | "";
} }
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
+1 -1
View File
@@ -38,7 +38,7 @@
"@modelcontextprotocol/sdk": "^1.32.1", "@modelcontextprotocol/sdk": "^1.32.1",
"@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bun-sqlite": "^1.3.0",
"@moduletrace/bunext": "^1.3.3", "@moduletrace/bunext": "^1.3.3",
"@moduletrace/twui": "^0.1.1", "@moduletrace/twui": "^0.1.2",
"ace-builds": "^1.44.0", "ace-builds": "^1.44.0",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import composeComputersNote from "./compose-computers-note";
describe("composeComputersNote", () => {
test("lists each computer by ref, the current one first", () => {
const note = composeComputersNote({
computers: [
{ id: 4, name: "Scraper", kind: "sandbox", description: "Runs the crawlers" },
{ id: 1, name: "Main computer", kind: "main" },
],
});
expect(note).toContain(
"- [computer:4] Scraper (you're on it): Runs the crawlers\n- [computer:1] Main computer (main)",
);
expect(note).toContain("search_computers");
expect(note).toContain("start_task");
});
test("tells the coordinator it can use and assign every computer", () => {
const computers = [{ id: 1, name: "Main computer", kind: "main" as const }];
expect(composeComputersNote({ computers, is_coordinator: true })).toContain("assign_computer");
expect(composeComputersNote({ computers })).not.toContain("assign_computer");
});
test("says so when the bot has no computer", () => {
expect(composeComputersNote({ computers: [] })).toContain("You have no computer");
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import computerRef from "@/src/utils/computer-ref";
type Params = {
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The coordinator can use every computer and give the crew access.
*/
is_coordinator?: boolean;
};
/**
* Added to a bot's chat prompt so it knows which computers it has, which
* one it's on, and how it reaches them (markdown). Without it a bot asked
* "can you see the computer?" says it can't.
*/
export default function composeComputersNote({ computers, is_coordinator }: Params) {
if (!computers.length) {
return `# Your computers\n\nYou have no computer, so you can't run commands, open files or browse. If the user asks for that, say so.`;
}
const listed = computers
.map(
(computer, index) =>
`- ${computerRef({ id: computer.id || 0 })} ${computer.name || "Computer"}${
[index == 0 ? "you're on it" : "", computer.kind == "main" ? "main" : ""]
.filter(Boolean)
.map((tag) => ` (${tag})`)
.join("")
}${computer.description ? `: ${computer.description}` : ""}`,
)
.join("\n");
return `# Your computers
${listed}
Each is a Linux sandbox of your own (a shell, files, code runners and a browser), set up for the crew. It isn't the user's desktop: you can't see their screen or their own files unless they're put on it. The user picks which one you're on; your tasks run there.${
is_coordinator
? ` As the coordinator you can use every computer the user has, and you decide which ones each crew member can use with assign_computer.`
: ""
}
- To find which computer holds something (a file, a running app, a logged-in session), use search_computers: it runs one read-only command on all of them at once.
- For anything else on a computer, start_task: the task runs on it with its tools and you post what you found here. Don't say you can't see a computer; search it or start a task.
- When you name a computer, write its ref exactly, e.g. ${computerRef({ id: computers[0]!.id || 0 })}. The user sees it as a button that switches you to that computer, so when they're looking for one, say which and that they can click it.`;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import type { HarnessCandidate } from "@/src/harness/types";
import describeJobStep from "./describe-job-step";
const candidate = { model: { slug: "deepseek/deepseek-chat:free" } } as HarnessCandidate;
describe("describeJobStep", () => {
test("names each tool once, never its arguments", () => {
expect(
describeJobStep({
index: 2,
text: "Let me look.",
candidate,
tool_calls: [
{ id: "a", name: "sandbox_execute_bash", arguments: '{"command":"cat secret"}' },
{ id: "b", name: "sandbox_execute_bash", arguments: "{}" },
{ id: "c", name: "update_memory", arguments: "{}" },
],
}),
).toEqual({
kind: "tool",
text: "Using sandbox_execute_bash, update_memory",
metadata: {
step: 2,
model: "deepseek/deepseek-chat:free",
tools: ["sandbox_execute_bash", "update_memory"],
},
});
});
test("keeps the first line of a text-only turn, cut to one line", () => {
const step = describeJobStep({
index: 0,
text: `\n ${"a".repeat(250)}\nSecond line`,
candidate,
tool_calls: [],
});
expect(step?.kind).toBe("note");
expect(step?.text).toHaveLength(200);
expect(step?.text.endsWith("…")).toBe(true);
});
test("skips an empty turn", () => {
expect(describeJobStep({ index: 0, text: " ", candidate, tool_calls: [] })).toBeNull();
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { BotJobUpdateKind } from "@/src/dict/bot-queue-dict";
import type { TextLoopStep } from "@/src/harness/text/run-text-loop";
/**
* Longest step line kept: the panel shows one line per step.
*/
const MaxStepChars = 200;
/**
* One model turn as a `bots_queue_updates` row: the tools it called, or
* the first line of what it wrote. Null for an empty turn. Never the tool
* arguments, which can hold anything the bot typed.
*/
export default function describeJobStep({
text,
tool_calls,
index,
candidate,
}: TextLoopStep): {
kind: BotJobUpdateKind;
text: string;
metadata: Record<string, unknown>;
} | null {
const metadata = { step: index, model: candidate.model.slug };
if (tool_calls.length) {
const tools = [...new Set(tool_calls.map((call) => call.name))];
return {
kind: "tool",
text: `Using ${tools.join(", ")}`,
metadata: { ...metadata, tools },
};
}
const line = text?.trim().split("\n").find((l) => l.trim())?.trim();
if (!line) return null;
return {
kind: "note",
text: line.length > MaxStepChars ? `${line.slice(0, MaxStepChars - 1)}…` : line,
metadata,
};
}
+27
View File
@@ -4,15 +4,19 @@ import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
import completeBotJob from "@/src/functions/backend/db/bots-queue/complete-bot-job"; import completeBotJob from "@/src/functions/backend/db/bots-queue/complete-bot-job";
import failBotJob from "@/src/functions/backend/db/bots-queue/fail-bot-job"; import failBotJob from "@/src/functions/backend/db/bots-queue/fail-bot-job";
import updateBotJob from "@/src/functions/backend/db/bots-queue/update-bot-job"; import updateBotJob from "@/src/functions/backend/db/bots-queue/update-bot-job";
import insertBotJobUpdate from "@/src/functions/backend/db/bots-queue/insert-bot-job-update";
import { logError } from "@/src/functions/backend/logger"; import { logError } from "@/src/functions/backend/logger";
import createKeyLock from "@/src/utils/create-key-lock"; import createKeyLock from "@/src/utils/create-key-lock";
import HarnessError from "@/src/harness/harness-error"; import HarnessError from "@/src/harness/harness-error";
import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job"; import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job";
import { BotWaitingStatusLine } from "@/src/dict/bot-queue-dict"; import { BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
import BotJobFatalError from "./bot-job-fatal-error"; import BotJobFatalError from "./bot-job-fatal-error";
import describeJobStep from "./describe-job-step";
import pushToBotOwner from "./push-to-bot-owner";
import pushBotNotice from "./push-bot-notice"; import pushBotNotice from "./push-bot-notice";
import runBotJob from "./run-bot-job"; import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json"; import parseJobJson from "./parse-job-json";
import type { BotJobUpdateData } from "@/src/types";
import type { BotJobContext, BotJobRunner } from "./types"; import type { BotJobContext, BotJobRunner } from "./types";
/** /**
@@ -66,6 +70,28 @@ export default async function processBotJob({
return held; return held;
}; };
const report: BotJobContext["report"] = async (step) => {
const described = describeJobStep(step);
if (!described || controller.signal.aborted) return;
try {
if (!(await update({ progress: described.text }))) return;
const row = await insertBotJobUpdate({ job_id, bot_id, ...described });
await pushToBotOwner({
bot_id,
data: {
event: "server:bot-job-update",
data: { ...row, task_id: job.task_id || null } satisfies BotJobUpdateData,
},
});
} catch (error) {
await logError({ title: `Bot job #${job_id} step not saved`, error }).catch(() => {});
}
};
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
update({}).catch(() => {}); update({}).catch(() => {});
}, HEARTBEAT_MS); }, HEARTBEAT_MS);
@@ -91,6 +117,7 @@ export default async function processBotJob({
checkpoint: parseJobJson({ text: job.checkpoint }), checkpoint: parseJobJson({ text: job.checkpoint }),
signal: controller.signal, signal: controller.signal,
update, update,
report,
}); });
}; };
+15 -2
View File
@@ -8,14 +8,20 @@ import pushToBotOwner from "./push-to-bot-owner";
type Params = { type Params = {
bot_id: number | string; bot_id: number | string;
message?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null; message?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null;
bot?: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line"> | null; bot?: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id"> | null;
/**
* A job's result reported to the bot that handed it over
* (`relayJobToRequester`): a message in that bot's conversation.
*/
relay?: { bot_id: number; message: BUN_SQLITE_COBBLE_BOT_MESSAGES } | null;
}; };
/** /**
* Pushes a new bot message and/or face change to the owner's open tabs. * Pushes a new bot message and/or face change to the owner's open tabs.
* A face change carries the bot's active task, for the working-on panel. * A face change carries the bot's active task, for the working-on panel.
* A relayed report goes to the conversation it was posted in.
*/ */
export default async function pushBotNotice({ bot_id, message, bot }: Params) { export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
if (message) { if (message) {
await pushToBotOwner({ await pushToBotOwner({
bot_id, bot_id,
@@ -23,6 +29,13 @@ export default async function pushBotNotice({ bot_id, message, bot }: Params) {
}); });
} }
if (relay) {
await pushToBotOwner({
bot_id: relay.bot_id,
data: { event: "server:bot-message", data: relay.message },
});
}
if (bot) { if (bot) {
await pushToBotOwner({ await pushToBotOwner({
bot_id, bot_id,
+8 -1
View File
@@ -3,9 +3,11 @@ import { AppData } from "@/src/data/app-data";
import { ChatReplyNote, CoordinatorReplyNote } from "@/src/data/harness-prompts"; import { ChatReplyNote, CoordinatorReplyNote } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt"; import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import runTextLoop from "@/src/harness/text/run-text-loop"; import runTextLoop from "@/src/harness/text/run-text-loop";
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools"; import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
import botMessagesToHarness from "./bot-messages-to-harness"; import botMessagesToHarness from "./bot-messages-to-harness";
import composeComputersNote from "./compose-computers-note";
import loadAttachmentParts from "./load-attachment-parts"; import loadAttachmentParts from "./load-attachment-parts";
import type { BotJobContext, BotJobOutcome } from "./types"; import type { BotJobContext, BotJobOutcome } from "./types";
@@ -16,7 +18,7 @@ type Params = BotJobContext & {
/** /**
* Answers the user in chat: the bot reads the recent conversation (and * Answers the user in chat: the bot reads the recent conversation (and
* the files of the latest messages, `loadAttachmentParts`) and * the files of the latest messages, `loadAttachmentParts`) and
* replies, with its chat tools (memory, instructions, start_task, and for * replies, knowing its computers (`composeComputersNote`), with its chat tools (memory, instructions, start_task, and for
* the coordinator delegate_task). Finishes silently when the last message * the coordinator delegate_task). Finishes silently when the last message
* is already the bot's, i.e. an earlier reply answered everything. * is already the bot's, i.e. an earlier reply answered everything.
*/ */
@@ -24,6 +26,7 @@ export default async function runBotReply({
bot, bot,
job, job,
signal, signal,
report,
}: Params): Promise<BotJobOutcome> { }: Params): Promise<BotJobOutcome> {
const history = await grabBotMessages({ const history = await grabBotMessages({
bot_id: bot.id, bot_id: bot.id,
@@ -35,11 +38,13 @@ export default async function runBotReply({
} }
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id }); const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const result = await runTextLoop({ const result = await runTextLoop({
system: [ system: [
prompt, prompt,
ChatReplyNote, ChatReplyNote,
composeComputersNote({ computers, is_coordinator: bot.kind == "coordinator" }),
bot.kind == "coordinator" ? CoordinatorReplyNote : "", bot.kind == "coordinator" ? CoordinatorReplyNote : "",
`Now: ${new Date().toUTCString()}.`, `Now: ${new Date().toUTCString()}.`,
] ]
@@ -52,11 +57,13 @@ export default async function runBotReply({
tools: await grabBotChatTools({ tools: await grabBotChatTools({
bot, bot,
user_id: bot.user_id, user_id: bot.user_id,
computers,
message_id: job.message_id || undefined, message_id: job.message_id || undefined,
}), }),
max_steps: AppData.BotReplyMaxToolSteps, max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id, bot_id: bot.id,
signal, signal,
onStep: report,
}); });
return { result: result.text }; return { result: result.text };
+14 -11
View File
@@ -1,8 +1,11 @@
import { TaskRunNote } from "@/src/data/harness-prompts"; import { TaskRunNote } from "@/src/data/harness-prompts";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt"; import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import runTextLoop from "@/src/harness/text/run-text-loop"; import runTextLoop from "@/src/harness/text/run-text-loop";
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools"; import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools"; import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
import computerRef from "@/src/utils/computer-ref";
import composeJobBrief from "./compose-job-brief"; import composeJobBrief from "./compose-job-brief";
import type { BotJobContext, BotJobOutcome } from "./types"; import type { BotJobContext, BotJobOutcome } from "./types";
@@ -12,16 +15,16 @@ type Params = BotJobContext & {
/** /**
* Works through a task: the bot follows its brief with its full prompt, * Works through a task: the bot follows its brief with its full prompt,
* its own tools and, when the job has a computer, that computer's tools * its own tools, search_computers across all its computers and, when the
* (over MCP), until it answers. That answer is the report for the user. * job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
* Progress is saved after every model turn. * Every model turn is reported as a step (`report`).
*/ */
export default async function runBotTask({ export default async function runBotTask({
bot, bot,
job, job,
params, params,
signal, signal,
update, report,
}: Params): Promise<BotJobOutcome> { }: Params): Promise<BotJobOutcome> {
const computer = job.computer_id const computer = job.computer_id
? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal }) ? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal })
@@ -29,6 +32,7 @@ export default async function runBotTask({
try { try {
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id }); const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const result = await runTextLoop({ const result = await runTextLoop({
system: [ system: [
@@ -37,6 +41,9 @@ export default async function runBotTask({
computer computer
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything.` ? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything.`
: `You have no computer for this task.`, : `You have no computer for this task.`,
computers.length > 1
? `search_computers runs a read-only command on every computer you have. When you name one in the report, write its ref, e.g. ${computerRef({ id: computers[0]!.id || 0 })}: the user can click it to switch you to that computer.`
: "",
computer?.envs.length computer?.envs.length
? `Environment variables set on this computer (use them by name, e.g. $KEY; never print their values):\n${computer.envs ? `Environment variables set on this computer (use them by name, e.g. $KEY; never print their values):\n${computer.envs
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`) .map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
@@ -57,17 +64,13 @@ export default async function runBotTask({
], ],
tools: [ tools: [
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }), ...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }),
...(computers.length ? [searchComputersTool({ bot_id: bot.id, computers })] : []),
...(computer?.tools || []), ...(computer?.tools || []),
], ],
bot_id: bot.id, bot_id: bot.id,
signal, signal,
onStep: async ({ text, tool_calls }) => { onStep: (step) =>
await update({ report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }),
progress: tool_calls.length
? `Using ${tool_calls.map((call) => call.name).join(", ")}`
: text?.slice(0, 500) || undefined,
});
},
}); });
return { result: computer ? computer.redact(result.text) : result.text }; return { result: computer ? computer.redact(result.text) : result.text };
+7
View File
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import type { TextLoopStep } from "@/src/harness/text/run-text-loop";
export type BotJobContext = { export type BotJobContext = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE; job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -21,6 +22,12 @@ export type BotJobContext = {
progress?: string; progress?: string;
checkpoint?: Record<string, unknown>; checkpoint?: Record<string, unknown>;
}) => Promise<boolean>; }) => Promise<boolean>;
/**
* Records a model turn as a step (`bots_queue_updates`), saves it as
* the job's progress and pushes it live. Pass it as the text loop's
* `onStep`. Best effort: a failed write never stops the job.
*/
report: (step: TextLoopStep) => Promise<void>;
}; };
export type BotJobOutcome = { export type BotJobOutcome = {
+16
View File
@@ -108,6 +108,22 @@ export const AppData = {
* One MCP tool call gives up after this long without progress. * One MCP tool call gives up after this long without progress.
*/ */
McpToolTimeoutMs: 5 * 60 * 1000, McpToolTimeoutMs: 5 * 60 * 1000,
/**
* `search_computers`: how long the command may run on each computer,
* and how much of each computer's output the bot reads.
*/
ComputerSearchTimeoutMs: 30 * 1000,
ComputerSearchMaxOutputChars: 4000,
/**
* How many of a job's latest steps (`bots_queue_updates`) the
* working-on panel shows.
*/
BotJobUpdatesShown: 12,
/**
* How often each server process reads new bot messages, job steps and
* bot changes from the database for its own sockets (`liveRelay`).
*/
LiveRelayPollMs: 1500,
/** /**
* How much of the conversation a bot sees when it replies. * How much of the conversation a bot sees when it replies.
*/ */
+1 -1
View File
@@ -32,7 +32,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
/** /**
* Added to the coordinator's chat prompt: it hands work to the crew. * Added to the coordinator's chat prompt: it hands work to the crew.
*/ */
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, so tell the user who is on it.`; export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user and follow up on anything left.`;
/** /**
* Added to a bot's prompt when it works through a queued task. * Added to a bot's prompt when it works through a queued task.
+11
View File
@@ -21,6 +21,17 @@ export const BotJobKinds = [
export type BotJobKind = (typeof BotJobKinds)[number]["value"]; export type BotJobKind = (typeof BotJobKinds)[number]["value"];
/**
* A `bots_queue_updates` row: a model turn that called tools, or one that
* only wrote text.
*/
export const BotJobUpdateKinds = [
{ title: "Tool", value: "tool" },
{ title: "Note", value: "note" },
] as const;
export type BotJobUpdateKind = (typeof BotJobUpdateKinds)[number]["value"];
/** /**
* A reply job's title, and the bot's status line while it runs. The chat * A reply job's title, and the bot's status line while it runs. The chat
* shows a thinking row while a bot has it. * shows a thinking row while a bot has it.
@@ -4,6 +4,7 @@ import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face"; import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs"; import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job"; import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
type Params = { type Params = {
job_id: number | string; job_id: number | string;
@@ -27,8 +28,8 @@ type Params = {
* Marks a job done, closes its task and sends the completion message back * Marks a job done, closes its task and sends the completion message back
* to the user in the bot's conversation. Unless it has other jobs open, * to the user in the bot's conversation. Unless it has other jobs open,
* the bot shows needs-you if asked, else done after a task and idle after * the bot shows needs-you if asked, else done after a task and idle after
* a reply. Throws if this handler no longer holds * a reply. A handed-over job's result also goes to the bot that handed
* the job. * it over (`relay`). Throws if this handler no longer holds the job.
*/ */
export default async function completeBotJob({ export default async function completeBotJob({
job_id, job_id,
@@ -87,5 +88,7 @@ export default async function completeBotJob({
face: job.kind == "reply" ? "idle" : "done", face: job.kind == "reply" ? "idle" : "done",
}); });
return { message, bot }; const relay = silent ? null : await relayJobToRequester({ job, text: result });
return { message, bot, relay };
} }
@@ -41,6 +41,11 @@ type Params = {
* Milliseconds to hold the job back before it can be claimed. * Milliseconds to hold the job back before it can be claimed.
*/ */
delay?: number; delay?: number;
/**
* The user's bot handing this job over (the coordinator). It gets the
* result or failure in its own conversation (`relayJobToRequester`).
*/
requested_by_bot_id?: number | string;
}; };
/** /**
@@ -60,6 +65,7 @@ export default async function enqueueBotJob({
task_id, task_id,
computer_id, computer_id,
delay, delay,
requested_by_bot_id,
}: Params) { }: Params) {
const bot = await grabUserBot({ bot_id, user_id }); const bot = await grabUserBot({ bot_id, user_id });
@@ -67,6 +73,10 @@ export default async function enqueueBotJob({
throw new AppError(`Bot not found.`); throw new AppError(`Bot not found.`);
} }
if (requested_by_bot_id && !(await grabUserBot({ bot_id: requested_by_bot_id, user_id }))?.id) {
throw new AppError(`Bot not found.`);
}
if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) { if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) {
throw new AppError(`Message not found.`); throw new AppError(`Message not found.`);
} }
@@ -103,6 +113,7 @@ export default async function enqueueBotJob({
message_id: message_id ? Number(message_id) : undefined, message_id: message_id ? Number(message_id) : undefined,
task_id: final_task_id ? Number(final_task_id) : undefined, task_id: final_task_id ? Number(final_task_id) : undefined,
computer_id: computer_id ? Number(computer_id) : undefined, computer_id: computer_id ? Number(computer_id) : undefined,
requested_by_bot_id: requested_by_bot_id ? Number(requested_by_bot_id) : undefined,
}, },
], ],
delay, delay,
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
type Params = {
task_id: number | string;
};
/**
* The latest steps of the newest job working on a task, oldest first,
* capped at `AppData.BotJobUpdatesShown`.
*/
export default async function grabBotJobUpdates({
task_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES[]> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES>({
sql: `SELECT * FROM (
SELECT u.* FROM bots_queue_updates u
WHERE u.bots_queue_id = (
SELECT id FROM bots_queue WHERE task_id = ? ORDER BY id DESC LIMIT 1
)
ORDER BY u.created_at DESC, u.id DESC LIMIT ?
) ORDER BY created_at ASC, id ASC`,
values: [task_id, AppData.BotJobUpdatesShown],
});
return res.payload || [];
}
@@ -0,0 +1,47 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES, BunSQLiteTables } from "@/db/types/db";
import type { BotJobUpdateKind } from "@/src/dict/bot-queue-dict";
type Params = {
job_id: number;
bot_id: number;
kind: BotJobUpdateKind;
text: string;
metadata?: Record<string, unknown>;
};
/**
* Records one step of a running job (`bots_queue_updates`) and returns the
* row, for the live push.
*/
export default async function insertBotJobUpdate({
job_id,
bot_id,
kind,
text,
metadata,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES> {
const row: BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES = {
bots_queue_id: job_id,
bot_id,
kind,
text,
metadata: metadata ? JSON.stringify(metadata) : undefined,
};
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES,
(typeof BunSQLiteTables)[number]
>({
table: "bots_queue_updates",
data: [row],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Failed to save the job update: ${res.msg}`);
}
return { ...row, id, created_at: Date.now() };
}
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message"; import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face"; import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
type Params = { type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE; job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -11,7 +12,8 @@ type Params = {
/** /**
* Wraps up a job that is out of attempts: stamps `finished_at` (so it's * Wraps up a job that is out of attempts: stamps `finished_at` (so it's
* reported once), tells the user in the bot's conversation and sets the * reported once), tells the user in the bot's conversation and sets the
* bot to needs-you. Its task stays active, since the work isn't done. * bot to needs-you. Its task stays active, since the work isn't done. A
* handed-over job's failure also goes to the bot that handed it over.
*/ */
export default async function notifyBotJobFailed({ export default async function notifyBotJobFailed({
job, job,
@@ -47,5 +49,11 @@ export default async function notifyBotJobFailed({
status_line: "Waiting on you", status_line: "Waiting on you",
}); });
return { message, bot }; const relay = await relayJobToRequester({
job,
text: failure_message || job.last_error || "",
failed: true,
});
return { message, bot, relay };
} }
@@ -0,0 +1,53 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
/**
* The worker's report, or why it failed (markdown).
*/
text: string;
failed?: boolean;
};
/**
* Reports a handed-over job back to the bot that handed it over
* (`requested_by_bot_id`, the coordinator): a message in its conversation
* with the worker's report, flagged needs-you when the job failed. Returns
* the message for the live push, or null when nobody handed the job over.
*/
export default async function relayJobToRequester({ job, text, failed }: Params) {
if (!job.requested_by_bot_id || !job.bot_id || job.requested_by_bot_id == job.bot_id) {
return null;
}
const worker = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
values: [job.bot_id],
})
).payload?.[0];
const requester = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id FROM bots WHERE id = ? LIMIT 1`,
values: [job.requested_by_bot_id],
})
).payload?.[0];
if (!requester?.id) return null;
const name = worker?.name || "A crew member";
const title = job.title || "the job";
const message = await insertBotMessage({
bot_id: requester.id,
text: failed
? `${name} couldn't finish **${title}**. ${text}`.trim()
: `${name} finished **${title}**. Their report:\n\n${text}`,
needs_you: failed,
});
return { bot_id: requester.id, message };
}
@@ -4,13 +4,15 @@ import type {
} from "@/db/types/db"; } from "@/db/types/db";
import type { BotTaskWithSteps } from "@/src/types"; import type { BotTaskWithSteps } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import grabBotJobUpdates from "../bots-queue/grab-bot-job-updates";
type Params = { type Params = {
bot_id: number | string; bot_id: number | string;
}; };
/** /**
* The bot's most recent active task and its steps, for the working-on panel. * The bot's most recent active task, its steps and what its job has done
* so far, for the working-on panel.
*/ */
export default async function grabActiveBotTask({ export default async function grabActiveBotTask({
bot_id, bot_id,
@@ -31,5 +33,9 @@ export default async function grabActiveBotTask({
values: [task.id], values: [task.id],
}); });
return { ...task, steps: steps_res.payload || [] }; return {
...task,
steps: steps_res.payload || [],
updates: await grabBotJobUpdates({ task_id: task.id }),
};
} }
@@ -9,7 +9,10 @@ type Params = {
/** /**
* Every computer a bot can use: the ones it was given directly * Every computer a bot can use: the ones it was given directly
* (`bot_computers`) plus the ones assigned to any workspace it's in * (`bot_computers`) plus the ones assigned to any workspace it's in
* (`workspace_computers`). Only the owner's computers, each once. * (`workspace_computers`); the coordinator can use all of them. Only the
* owner's computers, each once. The
* one it works on now (`bots.computer_id`) comes first, else its main one,
* so `computers[0]` is where its tasks run by default.
*/ */
export default async function grabBotComputers({ export default async function grabBotComputers({
bot_id, bot_id,
@@ -19,7 +22,8 @@ export default async function grabBotComputers({
sql: `SELECT c.* FROM computers c sql: `SELECT c.* FROM computers c
WHERE c.user_id = ? WHERE c.user_id = ?
AND ( AND (
c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?) (SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
OR c.id IN ( OR c.id IN (
SELECT wc.computer_id FROM workspace_computers wc SELECT wc.computer_id FROM workspace_computers wc
JOIN workspace_bots wb ON wb.workspace_id = wc.workspace_id JOIN workspace_bots wb ON wb.workspace_id = wc.workspace_id
@@ -27,8 +31,9 @@ export default async function grabBotComputers({
WHERE wb.bot_id = ? AND w.archived = 0 WHERE wb.bot_id = ? AND w.archived = 0
) )
) )
ORDER BY c.kind = 'main' DESC, c.id ASC`, ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC,
values: [user_id, bot_id, bot_id], c.kind = 'main' DESC, c.id ASC`,
values: [user_id, bot_id, bot_id, bot_id, bot_id],
}); });
return res.payload || []; return res.payload || [];
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabBotComputers from "./grab-bot-computers";
type Params = {
bot_id: number | string;
user_id: number | string;
computer_id: number | string;
};
/**
* Switches the computer a bot works on (`bots.computer_id`) to one of its
* own (`grabBotComputers`). Returns its computers, the new one first.
*/
export default async function setBotComputer({ bot_id, user_id, computer_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
const computer = computers.find((c) => String(c.id) == String(computer_id));
if (!computer?.id) {
throw new AppError(`${bot.name || "This bot"} has no access to that computer.`);
}
await BunSQLite.sql({
sql: `UPDATE bots SET computer_id = ?, updated_at = ? WHERE id = ?`,
values: [computer.id, Date.now(), bot.id],
});
return {
bot,
computer,
computers: [computer, ...computers.filter((c) => c.id != computer.id)],
};
}
@@ -0,0 +1,89 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
import computerRef from "@/src/utils/computer-ref";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The user's worker bots.
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
/**
* Every computer the user has (the coordinator's).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* Each worker's computers now, by bot id, for the description.
*/
access: Record<number, BUN_SQLITE_COBBLE_COMPUTERS[]>;
};
type Args = {
bot_id: number;
computer_id: number;
access: "grant" | "revoke";
};
/**
* Lets the coordinator give a crew member access to one of the user's
* computers (`bot_computers`), or take a direct grant away. Access that
* comes through a workspace stays, and the result says so.
*/
export default function assignComputerTool({
user_id,
workers,
computers,
access,
}: Params): HarnessTool<Args> {
const crew = workers
.map((bot) => {
const has = access[bot.id || 0] || [];
return `${bot.id}: ${bot.name} (${has.length ? has.map((c) => computerRef({ id: c.id || 0 })).join(", ") : "no computer"})`;
})
.join("; ");
return {
name: "assign_computer",
description: `Give a crew member access to a computer, or take it away. A bot works on its computers and its delegated tasks run on the one it's on. Crew and their computers now: ${crew}. Computers: ${computers
.map((c) => `${computerRef({ id: c.id || 0 })} ${c.name || "Computer"}`)
.join(", ")}.`,
schema: z.object({
bot_id: z.number().int(),
computer_id: z.number().int(),
access: z.enum(["grant", "revoke"]),
}),
run: async ({ args }) => {
const worker = workers.find((bot) => bot.id == args.bot_id);
const computer = computers.find((c) => c.id == args.computer_id);
if (!worker?.id) {
throw new Error(`No crew member has id ${args.bot_id}.`);
}
if (!computer?.id) {
throw new Error(`No computer has id ${args.computer_id}.`);
}
const ref = computerRef({ id: computer.id });
if (args.access == "grant") {
await addBotToComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
return `${worker.name} can use ${ref} now.`;
}
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
const still = (await grabBotComputers({ bot_id: worker.id, user_id })).some(
(c) => c.id == computer.id,
);
return still
? `Removed ${worker.name}'s own access to ${ref}, but it still has it through a workspace.`
: `${worker.name} can't use ${ref} any more.`;
},
};
}
+11 -4
View File
@@ -8,6 +8,10 @@ import type { HarnessTool } from "../../types";
type Params = { type Params = {
user_id: number | string; user_id: number | string;
/**
* The coordinator: the worker's result comes back to its conversation.
*/
coordinator_id: number | string;
/** /**
* The coordinator's name, shown in the worker's conversation. * The coordinator's name, shown in the worker's conversation.
*/ */
@@ -26,11 +30,13 @@ type Args = {
/** /**
* Lets the coordinator hand a task to a worker bot. The worker runs it on * Lets the coordinator hand a task to a worker bot. The worker runs it on
* its own main computer (if it has one) and posts the result in its own * the computer it's on (if it has one) and posts the result in its own
* conversation, where a note says who asked. * conversation, where a note says who asked; the result (or failure) is
* also reported back in the coordinator's conversation.
*/ */
export default function delegateTaskTool({ export default function delegateTaskTool({
user_id, user_id,
coordinator_id,
coordinator_name, coordinator_name,
workers, workers,
}: Params): HarnessTool<Args> { }: Params): HarnessTool<Args> {
@@ -40,7 +46,7 @@ export default function delegateTaskTool({
return { return {
name: "delegate_task", name: "delegate_task",
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.`, description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.`,
schema: z.object({ schema: z.object({
bot_id: z.number().int(), bot_id: z.number().int(),
title: z.string().min(1).max(80), title: z.string().min(1).max(80),
@@ -61,6 +67,7 @@ export default function delegateTaskTool({
title: args.title, title: args.title,
instructions: args.instructions, instructions: args.instructions,
computer_id: computers[0]?.id || undefined, computer_id: computers[0]?.id || undefined,
requested_by_bot_id: coordinator_id,
}); });
await pushBotNotice({ await pushBotNotice({
@@ -71,7 +78,7 @@ export default function delegateTaskTool({
}), }),
}); });
return `Task #${job_id} is with ${worker.name}. Their result will be in their conversation.`; return `Task #${job_id} is with ${worker.name}. Their result will be in their conversation and reported back here.`;
}, },
}; };
} }
+28 -4
View File
@@ -1,7 +1,9 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers"; import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import type { HarnessTool } from "../../types"; import type { HarnessTool } from "../../types";
import searchComputersTool from "../computer/search-computers-tool";
import assignComputerTool from "./assign-computer-tool";
import delegateTaskTool from "./delegate-task-tool"; import delegateTaskTool from "./delegate-task-tool";
import grabBotTools from "./grab-bot-tools"; import grabBotTools from "./grab-bot-tools";
import startTaskTool from "./start-task-tool"; import startTaskTool from "./start-task-tool";
@@ -9,6 +11,10 @@ import startTaskTool from "./start-task-tool";
type Params = { type Params = {
bot: BUN_SQLITE_COBBLE_BOTS; bot: BUN_SQLITE_COBBLE_BOTS;
user_id: number | string; user_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/** /**
* The user message being answered. * The user message being answered.
*/ */
@@ -17,12 +23,15 @@ type Params = {
/** /**
* The tools a bot replies in chat with: its own (memory, instructions), * The tools a bot replies in chat with: its own (memory, instructions),
* start_task for longer work, and for the coordinator delegate_task to * start_task for longer work, search_computers to find what's on its
* the crew. Computers are reached through tasks, never mid-reply. * computers, and for the coordinator delegate_task and assign_computer
* (which computers each crew member can use). Anything
* else on a computer goes through a task, never mid-reply.
*/ */
export default async function grabBotChatTools({ export default async function grabBotChatTools({
bot, bot,
user_id, user_id,
computers,
message_id, message_id,
}: Params): Promise<HarnessTool[]> { }: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0; const bot_id = bot.id || 0;
@@ -31,9 +40,10 @@ export default async function grabBotChatTools({
startTaskTool({ startTaskTool({
user_id, user_id,
bot_id, bot_id,
computers: await grabBotComputers({ bot_id, user_id }), computers,
message_id, message_id,
}), }),
...(computers.length ? [searchComputersTool({ bot_id, computers })] : []),
]; ];
if (bot.kind != "coordinator") return tools; if (bot.kind != "coordinator") return tools;
@@ -50,10 +60,24 @@ export default async function grabBotChatTools({
tools.push( tools.push(
delegateTaskTool({ delegateTaskTool({
user_id, user_id,
coordinator_id: bot_id,
coordinator_name: bot.name || "The coordinator", coordinator_name: bot.name || "The coordinator",
workers, workers,
}), }),
); );
if (computers.length) {
const access = Object.fromEntries(
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
]),
),
);
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
} }
return tools; return tools;
+2 -2
View File
@@ -7,7 +7,7 @@ type Params = {
user_id: number | string; user_id: number | string;
bot_id: number | string; bot_id: number | string;
/** /**
* The bot's computers, its main one first (`grabBotComputers`). * The bot's computers, the current one first (`grabBotComputers`).
*/ */
computers: BUN_SQLITE_COBBLE_COMPUTERS[]; computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/** /**
@@ -40,7 +40,7 @@ export default function startTaskTool({
name: "start_task", name: "start_task",
description: `Queue work that takes a while or needs a computer. It runs in the background and its result is posted in this chat. Write the instructions as a full brief for yourself: what to do, where, and how you'll know it's done. ${ description: `Queue work that takes a while or needs a computer. It runs in the background and its result is posted in this chat. Write the instructions as a full brief for yourself: what to do, where, and how you'll know it's done. ${
computers.length computers.length
? `Computers you can use: ${listed}. Defaults to ${computers[0]!.id}.` ? `Computers you can use: ${listed}. Defaults to ${computers[0]!.id}, the one you're on.`
: `You have no computer, so the task can't run commands or browse.` : `You have no computer, so the task can't run commands or browse.`
}`, }`,
schema: z.object({ schema: z.object({
@@ -0,0 +1,41 @@
type Params = {
/**
* The sandbox's REST address (no `/mcp`).
*/
base_url: string;
command: string;
timeout_ms: number;
signal?: AbortSignal;
};
/**
* Runs one shell command on a computer over the sandbox's REST API
* (`/v1/shell/exec`), outside any MCP session. Throws when the computer
* can't be reached or refuses the command.
*/
export default async function runComputerCommand({
base_url,
command,
timeout_ms,
signal,
}: Params): Promise<{ output: string; exit_code: number | null }> {
const res = await fetch(`${base_url}/v1/shell/exec`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ command }),
signal: AbortSignal.any([
...(signal ? [signal] : []),
AbortSignal.timeout(timeout_ms),
]),
});
const body: any = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
throw new Error(`The computer refused the command: ${body?.message || res.status}`);
}
return {
output: String(body?.data?.output ?? ""),
exit_code: typeof body?.data?.exit_code == "number" ? body.data.exit_code : null,
};
}
@@ -0,0 +1,82 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerRef from "@/src/utils/computer-ref";
import redactSecrets from "@/src/utils/redact-secrets";
import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command";
type Params = {
bot_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
type Args = {
command: string;
};
/**
* Lets a bot look for something across every computer it has: one shell
* command runs on each at once (`runComputerCommand`), and the outputs come
* back labelled with each computer's ref (`computerRef`), secrets redacted,
* so the bot can tell the user exactly which computer holds what.
*/
export default function searchComputersTool({
bot_id,
computers,
}: Params): HarnessTool<Args> {
return {
name: "search_computers",
description: `Run one read-only shell command (bash) on every computer you have at once, to find which one holds something: a file, a running app, a logged-in browser session, a setting. Each computer's output comes back under its ref, e.g. ${computerRef({ id: computers[0]?.id || 1 })}. For a logged-in site, look for its domain in the browser profiles' cookie databases (copy the file first, since the browser locks it) and list only host names, never cookie values. Don't change anything with it; use start_task for that. Output past ${AppData.ComputerSearchMaxOutputChars} characters per computer is cut, so filter in the command.`,
schema: z.object({
command: z.string().min(1).max(4000),
}),
run: async ({ args, signal }) => {
const results = await Promise.all(
computers.map(async (computer) => {
const label = `## ${computerRef({ id: computer.id || 0 })} ${computer.name || "Computer"}`;
try {
const target = await grabComputerMcpUrl({ computer_id: computer.id || 0 });
if (!target) return `${label}\nNot reachable: it has no container.`;
const [result, envs] = await Promise.all([
runComputerCommand({
base_url: target.base_url,
command: args.command,
timeout_ms: AppData.ComputerSearchTimeoutMs,
signal,
}),
grabComputerEnvs({ computer_id: computer.id || 0, bot_id }),
]);
const output = redactSecrets({
text: result.output.trim(),
secrets: envs.filter((env) => env.secret),
});
const cut = output.length > AppData.ComputerSearchMaxOutputChars;
return [
label,
`Exit code: ${result.exit_code ?? "unknown"}`,
output ? output.slice(0, AppData.ComputerSearchMaxOutputChars) : "(no output)",
cut ? `(output cut at ${AppData.ComputerSearchMaxOutputChars} characters)` : "",
]
.filter(Boolean)
.join("\n");
} catch (error) {
return `${label}\nNot reachable: ${error instanceof Error ? error.message : String(error)}`;
}
}),
);
return results.join("\n\n");
},
};
}
+6 -1
View File
@@ -6,6 +6,7 @@ import { twMerge } from "tailwind-merge";
import BotStone from "@/src/components/general/bot-stone"; import BotStone from "@/src/components/general/bot-stone";
import type { BotWithLastMessage } from "@/src/types"; import type { BotWithLastMessage } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict"; import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot"; import UnreadDot from "./unread-dot";
@@ -24,7 +25,11 @@ type Props = {
*/ */
export default function BotRow({ bot, selected, subtitle }: Props) { export default function BotRow({ bot, selected, subtitle }: Props) {
const hue = (bot.hue || null) as BotHue | null; const hue = (bot.hue || null) as BotHue | null;
const snippet = subtitle ?? bot.last_message_text ?? bot.role ?? ""; const snippet =
subtitle ??
(bot.last_message_text ? messagePreview({ text: bot.last_message_text }) : null) ??
bot.role ??
"";
const at = bot.last_message_at || bot.last_activity_at; const at = bot.last_message_at || bot.last_activity_at;
return ( return (
+61
View File
@@ -0,0 +1,61 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import { logError } from "@/src/functions/backend/logger";
import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids";
import relayChangedBots from "./relay-changed-bots";
import relayNewBotMessages from "./relay-new-bot-messages";
import relayNewJobUpdates from "./relay-new-job-updates";
import type { LiveRelayCursor } from "./types";
/**
* Keeps this process's sockets current with work done anywhere: every
* `AppData.LiveRelayPollMs` it reads new bot messages, new job steps and
* changed bots from the database and pushes them to their owners'
* sockets here. The process that did the work pushes at once
* (`pushBotNotice`); this catches the rest, e.g. a job run by another
* server on the same database. Clients ignore what they already have.
* Runs in every server process, ungated.
*/
export default async function liveRelay() {
const latest = async (table: "bot_messages" | "bots_queue_updates") =>
Number(
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT MAX(id) AS id FROM ${table}`,
})
).payload?.[0]?.id,
) || 0;
const cursor: LiveRelayCursor = {
message_id: await latest("bot_messages"),
update_id: await latest("bots_queue_updates"),
bots_at: Date.now(),
};
let running = false;
setInterval(async () => {
if (running) return;
running = true;
try {
const users = grabConnectedUserIds();
const now = Date.now();
cursor.message_id = await relayNewBotMessages({ after_id: cursor.message_id, users });
cursor.update_id = await relayNewJobUpdates({ after_id: cursor.update_id, users });
/**
* Overlap one interval, so a change written in the same
* millisecond as the last look isn't missed.
*/
await relayChangedBots({ since: cursor.bots_at - AppData.LiveRelayPollMs, users });
cursor.bots_at = now;
} catch (error) {
await logError({ title: `Live relay failed`, error }).catch(() => {});
} finally {
running = false;
}
}, AppData.LiveRelayPollMs);
}
+47
View File
@@ -0,0 +1,47 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import type { BotUpdatedData } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
/**
* Unix ms: bots updated since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* Pushes `server:bot-updated` (face, status line, active task) for every
* bot of a connected user that changed since `since`. Pushes are
* idempotent, so overlapping windows only repeat them.
*/
export default async function relayChangedBots({ since, users }: Params) {
if (!users.size) return;
const ids = [...users];
const bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, user_id, face, status_line, computer_id FROM bots
WHERE updated_at > ? AND user_id IN (${ids.map(() => "?").join(", ")})`,
values: [since, ...ids],
})
).payload || [];
for (const bot of bots) {
if (!bot.id || !bot.user_id) continue;
const data: BotUpdatedData = {
id: bot.id,
face: bot.face,
status_line: bot.status_line || "",
task: await grabActiveBotTask({ bot_id: bot.id }),
computer_id: bot.computer_id || null,
};
sendToUser({ user_id: bot.user_id, data: { event: "server:bot-updated", data } });
}
}
+31
View File
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
after_id: number;
users: Set<string>;
};
/**
* Pushes `server:bot-message` for every message newer than `after_id` to
* its owner's sockets on this process. Returns the newest id read.
*/
export default async function relayNewBotMessages({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES & { user_id: number }>({
sql: `SELECT m.*, b.user_id FROM bot_messages m JOIN bots b ON b.id = m.bot_id
WHERE m.id > ? ORDER BY m.id ASC LIMIT 500`,
values: [after_id],
})
).payload || [];
for (const { user_id, ...message } of rows) {
if (users.has(String(user_id))) {
sendToUser({ user_id, data: { event: "server:bot-message", data: message } });
}
}
return Number(rows[rows.length - 1]?.id) || after_id;
}
+33
View File
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotJobUpdateData } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
after_id: number;
users: Set<string>;
};
/**
* Pushes `server:bot-job-update` for every job step newer than `after_id`
* to its owner's sockets on this process. Returns the newest id read.
*/
export default async function relayNewJobUpdates({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BotJobUpdateData & { user_id: number }>({
sql: `SELECT u.*, q.task_id, b.user_id FROM bots_queue_updates u
JOIN bots_queue q ON q.id = u.bots_queue_id
JOIN bots b ON b.id = u.bot_id
WHERE u.id > ? ORDER BY u.id ASC LIMIT 500`,
values: [after_id],
})
).payload || [];
for (const { user_id, ...update } of rows) {
if (users.has(String(user_id))) {
sendToUser({ user_id, data: { event: "server:bot-job-update", data: update } });
}
}
return Number(rows[rows.length - 1]?.id) || after_id;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* How far the relay has read: the newest message and job step it pushed,
* and when it last looked for changed bots.
*/
export type LiveRelayCursor = {
message_id: number;
update_id: number;
bots_at: number;
};
@@ -0,0 +1,11 @@
import React from "react";
import type { BotComputers } from "../(hooks)/use-bot-computers";
/**
* The open bot's computers and the switch, shared by the header's
* computer select and the computer buttons in its messages.
*/
export const BotComputersContext = React.createContext<BotComputers>({
computers: [],
switchComputer: async () => {},
});
@@ -0,0 +1,70 @@
import type { Components } from "react-markdown";
import Divider from "@moduletrace/twui/layout/Divider";
import H3 from "@moduletrace/twui/layout/H3";
import H4 from "@moduletrace/twui/layout/H4";
import H5 from "@moduletrace/twui/layout/H5";
import P from "@moduletrace/twui/layout/P";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import MarkdownCodeBlock from "../(partials)/markdown-code-block";
import MarkdownLink from "../(partials)/markdown-link";
import MarkdownList from "../(partials)/markdown-list";
const HeadingClassName = "my-0! text-[15px]! leading-6! tracking-normal! font-bold text-ink";
/**
* How a bot message's markdown renders: chat-sized twui elements (headings
* no bigger than the bot's name), lists through twui's `List`, code on the
* sunken surface, and links that open in a new tab or switch computers.
* Tables (GFM) have no children-based twui equivalent, so they're styled
* elements.
*/
export const MessageMarkdownComponents: Components = {
p: ({ children }) => (
<P noMargin className="text-sm leading-5 text-ink">
{children}
</P>
),
h1: ({ children }) => <H3 className={HeadingClassName}>{children}</H3>,
h2: ({ children }) => <H3 className={HeadingClassName}>{children}</H3>,
h3: ({ children }) => <H4 className={HeadingClassName}>{children}</H4>,
h4: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
h5: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
h6: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
strong: ({ children }) => <Span className="font-bold">{children}</Span>,
em: ({ children }) => <Span className="italic">{children}</Span>,
del: ({ children }) => <Span className="line-through">{children}</Span>,
a: MarkdownLink,
ul: ({ children }) => <MarkdownList>{children}</MarkdownList>,
ol: ({ children, start }) => (
<MarkdownList ordered start={start}>
{children}
</MarkdownList>
),
code: ({ children }) => (
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink break-words">
{children}
</Span>
),
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
blockquote: ({ children }) => (
<Stack className="gap-2! items-stretch pl-3 border-0 border-l-2 border-solid border-hairline-strong text-ink-secondary">
{children}
</Stack>
),
hr: () => <Divider className="my-1 border-hairline dark:border-hairline" />,
img: ({ src, alt }) => (
<MarkdownLink href={typeof src == "string" ? src : undefined}>{alt || "Image"}</MarkdownLink>
),
table: ({ children }) => (
<Stack className="w-full max-w-full overflow-x-auto items-start">
<table className="text-[13px] leading-5 text-ink border-collapse">{children}</table>
</Stack>
),
th: ({ children }) => (
<th className="px-3 py-1.5 text-left font-bold border border-solid border-hairline">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 align-top border border-solid border-hairline">{children}</td>
),
};
@@ -0,0 +1,18 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { SetBotComputerRequest } from "@/src/schemas/bot-schema";
/**
* Switches the computer a bot works on. Returns its computers, the new
* one first.
*/
export default async function setBotComputer({ bot_id, computer_id }: SetBotComputerRequest) {
return await fetchApi<
SetBotComputerRequest,
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
>(`/api/admin/bots/set-computer`, {
method: "POST",
body: { bot_id, computer_id },
});
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotUpdatedData } from "@/src/types";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import setBotComputer from "../(functions)/set-bot-computer";
export type BotComputers = {
/**
* The bot's computers, the one it's on first.
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The computer being switched to, while the switch runs.
*/
switching_id?: number;
error?: string;
switchComputer: (params: { computer_id: number }) => Promise<void>;
};
type Params = {
bot_id: number;
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
/**
* Keeps the open bot's computers and switches the one it's on, in place:
* no reload, the list comes back reordered. A switch made in another tab
* arrives as `server:bot-updated` with the bot's `computer_id`.
*/
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
const [computers, setComputers] = useState(initial);
const [switching_id, setSwitchingId] = useState<number>();
const [error, setError] = useState<string>();
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) => {
const computer_id = data.computer_id;
if (data.id != bot_id || !computer_id) return;
setComputers((prev) => {
const picked = prev.find((c) => c.id == computer_id);
return !picked || prev[0]?.id == computer_id
? prev
: [picked, ...prev.filter((c) => c.id != computer_id)];
});
},
});
const switchComputer = async ({ computer_id }: { computer_id: number }) => {
if (switching_id || computers[0]?.id == computer_id) return;
setSwitchingId(computer_id);
setError(undefined);
const res = await setBotComputer({ bot_id, computer_id }).catch(() => null);
if (res?.success && res.payload?.length) {
setComputers(res.payload);
} else {
setError(res?.msg || "Couldn't switch computers");
}
setSwitchingId(undefined);
};
return { computers, switching_id, error, switchComputer };
}
@@ -0,0 +1,50 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check } from "lucide-react";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
type Props = {
computer: BUN_SQLITE_COBBLE_COMPUTERS;
/**
* The computer the bot is on.
*/
current: boolean;
onPick: () => void;
};
/**
* One computer in the header's computer select, checked when it's the
* one the bot is on.
*/
export default function BotComputerOption({ computer, current, onPick }: Props) {
return (
<Row
role="menuitemradio"
aria-checked={current}
tabIndex={0}
onClick={current ? undefined : onPick}
onKeyDown={(e) => {
if (!current && (e.key == "Enter" || e.key == " ")) {
e.preventDefault();
onPick();
}
}}
className={`w-full min-h-9 gap-2.5! flex-nowrap items-center px-4 py-1.5 text-ink transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink ${
current ? "cursor-default" : "cursor-pointer hover:bg-sunken"
}`}
>
<Check size={16} aria-hidden className={`shrink-0 ${current ? "" : "invisible"}`} />
<Stack className="min-w-0 gap-0!">
<Span className="text-sm leading-5 text-ink truncate w-full">
{computer.name || `Computer ${computer.id}`}
</Span>
{computer.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
{computer.description}
</Span>
) : null}
</Stack>
</Row>
);
}
@@ -0,0 +1,90 @@
import { useContext, useState } from "react";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import Paper from "@moduletrace/twui/elements/Paper";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronDown, Monitor } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
type Props = {
bot: BotWithLastMessage;
};
/**
* The header's computer button, beside the model button: shows the
* computer the bot is on and, with more than one, switches it.
*/
export default function BotComputerSelect({ bot }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
if (!current?.id) return null;
const name = bot.name || "This bot";
const can_switch = computers.length > 1;
const target = (
<Button
title={
can_switch
? `${name} is on ${current.name}. Switch computers`
: `${name} is on ${current.name}, its only computer`
}
type="button"
variant="outlined"
color="gray"
size="small"
disabled={!can_switch}
loading={Boolean(switching_id)}
buttonContentProps={{ className: "min-w-0" }}
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
>
<Monitor size={16} aria-hidden className="shrink-0 -mt-0.5" />
<Span className="truncate">{current.name || "Computer"}</Span>
{can_switch ? <ChevronDown size={14} aria-hidden className="shrink-0" /> : null}
</Button>
);
if (!can_switch) {
return <Stack className="max-w-[200px] min-w-0 shrink">{target}</Stack>;
}
return (
<Dropdown
target={target}
position="bottom-right"
externalOpen={open}
externalSetOpen={setOpen}
targetWrapperProps={{ className: "max-w-full min-w-0" }}
className="max-w-[200px] min-w-0 shrink items-stretch"
>
<Paper
role="menu"
aria-label={`${name}'s computers`}
className="w-[260px] p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
>
{computers.map((computer, index) => (
<BotComputerOption
key={computer.id}
computer={computer}
current={index == 0}
onPick={async () => {
setOpen(false);
await switchComputer({ computer_id: computer.id || 0 });
}}
/>
))}
{error ? (
<Span role="alert" className="px-4 py-2 text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Paper>
</Dropdown>
);
}
@@ -1,5 +1,8 @@
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P"; import linkComputerRefs from "@/src/utils/link-computer-refs";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
import NeedsYouChip from "./needs-you-chip"; import NeedsYouChip from "./needs-you-chip";
type Props = { type Props = {
@@ -7,12 +10,19 @@ type Props = {
needs_you?: boolean; needs_you?: boolean;
}; };
/**
* One bot message, rendered from markdown (GFM; raw HTML is never
* rendered). Computers it names (`computerRef`) show as buttons that
* switch the bot to them.
*/
export default function BotMessageLine({ text, needs_you }: Props) { export default function BotMessageLine({ text, needs_you }: Props) {
return ( return (
<Stack className="gap-1.5! items-start max-w-full"> <Stack className="gap-1.5! items-start max-w-full">
<P noMargin className="text-sm leading-5 text-ink whitespace-pre-wrap break-words"> <Stack className="w-full gap-2.5! items-start break-words">
{text} <Markdown remarkPlugins={[remarkGfm]} components={MessageMarkdownComponents}>
</P> {linkComputerRefs({ text })}
</Markdown>
</Stack>
{needs_you ? <NeedsYouChip /> : null} {needs_you ? <NeedsYouChip /> : null}
</Stack> </Stack>
); );
@@ -49,6 +49,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
color="gray" color="gray"
size="small" size="small"
disabled={!can_edit} disabled={!can_edit}
buttonContentProps={{ className: "min-w-0" }}
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink" className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
> >
<CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" /> <CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" />
@@ -61,7 +62,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
if (!can_edit) { if (!can_edit) {
return ( return (
<Stack className="ml-auto max-w-[40%] min-w-0 shrink"> <Stack className="max-w-[280px] min-w-0 shrink">
{target} {target}
</Stack> </Stack>
); );
@@ -71,7 +72,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
<Modal <Modal
target={target} target={target}
targetWrapperProps={{ targetWrapperProps={{
className: "ml-auto max-w-[40%] min-w-0 shrink", className: "max-w-[280px] min-w-0 shrink",
}} }}
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
@@ -0,0 +1,56 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import { Check, Monitor } from "lucide-react";
import { BotComputersContext } from "../(data)/bot-computers-context";
type Props = {
computer_id: number;
};
/**
* A computer the bot named in a message (`computerRef`): a button that
* switches the bot to it at once, or a checked chip when it's already on
* it. A computer the bot can't use (any more) is shown by name only.
*/
export default function ComputerRefChip({ computer_id }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const index = computers.findIndex((c) => c.id == computer_id);
const computer = computers[index];
const name = computer?.name || `computer ${computer_id}`;
if (!computer) {
return <Span className="font-bold">{name}</Span>;
}
if (index == 0) {
return (
<Span
title="The computer this bot is on"
className="inline-flex h-6 gap-1.5 items-center px-2 align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap"
>
<Monitor size={14} aria-hidden />
{name}
<Check size={14} aria-hidden />
</Span>
);
}
return (
<Button
type="button"
variant="outlined"
color="gray"
size="smaller"
title={error && switching_id == undefined ? error : `Switch to ${name}`}
loading={switching_id == computer_id}
disabled={Boolean(switching_id)}
onClick={() => switchComputer({ computer_id })}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
>
<Monitor size={14} aria-hidden className="shrink-0" />
{name}
</Button>
);
}
@@ -6,6 +6,7 @@ import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict"; import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import { crewStatusSentence } from "@/src/utils/crew-status"; import { crewStatusSentence } from "@/src/utils/crew-status";
import BotComputerSelect from "./bot-computer-select";
import BotModelsModal from "./bot-models-modal"; import BotModelsModal from "./bot-models-modal";
type Props = { type Props = {
@@ -19,8 +20,9 @@ type Props = {
/** /**
* 64px header. The coordinator's status is the crew sentence. On the * 64px header. The coordinator's status is the crew sentence. On the
* right, the model button that opens the bot's models (the defaults on * right, the computer the bot is on (`BotComputerSelect`), then the model
* the coordinator, for super admins). * button that opens the bot's models (the defaults on the coordinator,
* for super admins).
*/ */
export default function ConversationHeader({ bot, bots, harnesses }: Props) { export default function ConversationHeader({ bot, bots, harnesses }: Props) {
const status = const status =
@@ -43,7 +45,10 @@ export default function ConversationHeader({ bot, bots, harnesses }: Props) {
{status} {status}
</Span> </Span>
</Stack> </Stack>
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} /> <Row className="ml-auto min-w-0 shrink gap-2! flex-nowrap justify-end">
<BotComputerSelect bot={bot} />
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
</Row>
</Row> </Row>
); );
} }
@@ -0,0 +1,26 @@
import { isValidElement, type ReactNode } from "react";
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
/**
* A fenced code block in a bot message, on the sunken surface with
* twui's copy button (its icon is drawn white; recoloured to the theme).
*/
export default function MarkdownCodeBlock({ children }: { children?: ReactNode }) {
const code = isValidElement(children)
? (children.props as { children?: ReactNode }).children
: children;
return (
<CodeBlock
singleBlock
backgroundColor="var(--color-sunken)"
wrapperProps={{
className:
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
}}
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10"
>
{code}
</CodeBlock>
);
}
@@ -0,0 +1,27 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerRefHrefPrefix } from "@/src/utils/link-computer-refs";
import ComputerRefChip from "./computer-ref-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
const computer_id = Number(href.slice(ComputerRefHrefPrefix.length));
if (computer_id) return <ComputerRefChip computer_id={computer_id} />;
}
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline underline-offset-2 hover:opacity-80 break-words"
>
{children}
</Link>
);
}
@@ -0,0 +1,29 @@
import { Children, isValidElement, type ReactNode } from "react";
import List from "@moduletrace/twui/layout/List";
type Props = {
children?: ReactNode;
ordered?: boolean;
start?: number;
};
/**
* A list in a bot message, through twui's `List` (which takes items, not
* `li` children): each item's content is lifted out of its `li`.
*/
export default function MarkdownList({ children, ordered, start }: Props) {
const items = Children.toArray(children)
.filter(isValidElement)
.map((item) => ({
content: (item.props as { children?: ReactNode }).children,
}));
return (
<List
ordered={ordered}
start={start}
items={items}
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
/>
);
}
@@ -5,12 +5,16 @@ import { AppContext } from "@/src/pages/__root";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots"; import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event"; import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context";
import useBotComputers from "../(hooks)/use-bot-computers";
import ConversationHeader from "../(partials)/conversation-header"; import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread"; import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer"; import Composer from "../(partials)/composer";
/** /**
* The 900px conversation column: header, bottom-anchored thread, composer. * The 900px conversation column: header, bottom-anchored thread, composer.
* The bot's computers are shared with the header's select and the computer
* buttons in the thread, so a switch from either shows in both.
*/ */
export default function Conversation() { export default function Conversation() {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
@@ -22,6 +26,10 @@ export default function Conversation() {
const bots = useLiveBots({ bots: pageProps?.bots || [], open_bot_id: bot?.id || undefined }); const bots = useLiveBots({ bots: pageProps?.bots || [], open_bot_id: bot?.id || undefined });
const live_bot = bots.find((b) => b.id == bot?.id) || bot; const live_bot = bots.find((b) => b.id == bot?.id) || bot;
const bot_computers = useBotComputers({
bot_id: bot?.id || 0,
computers: pageProps?.bot_computers || [],
});
useServerEvent({ useServerEvent({
event: "server:bot-message", event: "server:bot-message",
@@ -44,21 +52,23 @@ export default function Conversation() {
if (!bot?.id || !live_bot) return null; if (!bot?.id || !live_bot) return null;
return ( return (
<Stack className="grow min-w-0 h-full gap-0! items-stretch"> <BotComputersContext.Provider value={bot_computers}>
<ConversationHeader <Stack className="grow min-w-0 h-full gap-0! items-stretch">
bot={live_bot} <ConversationHeader
bots={bots} bot={live_bot}
harnesses={pageProps?.bot_harness_overview?.harnesses} bots={bots}
/> harnesses={pageProps?.bot_harness_overview?.harnesses}
<MessageThread bot={live_bot} messages={messages} /> />
<Composer <MessageThread bot={live_bot} messages={messages} />
bot={bot} <Composer
onSent={({ message }) => bot={bot}
setMessages((prev) => onSent={({ message }) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message], setMessages((prev) =>
) prev.some((m) => m.id == message.id) ? prev : [...prev, message],
} )
/> }
</Stack> />
</Stack>
</BotComputersContext.Provider>
); );
} }
@@ -4,13 +4,17 @@ import Span from "@moduletrace/twui/layout/Span";
import H2 from "@moduletrace/twui/layout/H2"; import H2 from "@moduletrace/twui/layout/H2";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import useServerEvent from "@/src/hooks/use-server-event"; import useServerEvent from "@/src/hooks/use-server-event";
import type { BotUpdatedData } from "@/src/types"; import { AppData } from "@/src/data/app-data";
import type { BotJobUpdateData, BotUpdatedData } from "@/src/types";
import TaskStepRow from "../(partials)/task-step-row"; import TaskStepRow from "../(partials)/task-step-row";
import OpenComputerButton from "../(partials)/open-computer-button"; import OpenComputerButton from "../(partials)/open-computer-button";
/** /**
* 280px panel listing the steps the bot is taking, kept current by the * 280px panel listing the steps the bot is taking, kept current by the
* active task each `server:bot-updated` push carries. Hidden below 1280px. * active task each `server:bot-updated` push carries. Without planned
* steps it shows what the task's job has done (`bots_queue_updates`), the
* newest as in progress, appended live from `server:bot-job-update`.
* Hidden below 1280px.
*/ */
export default function WorkingOnPanel() { export default function WorkingOnPanel() {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
@@ -24,6 +28,17 @@ export default function WorkingOnPanel() {
}, },
}); });
useServerEvent({
event: "server:bot-job-update",
onData: (data: BotJobUpdateData) => {
setTask((prev) =>
prev?.id && prev.id == data.task_id && !prev.updates.some((u) => u.id == data.id)
? { ...prev, updates: [...prev.updates, data].slice(-AppData.BotJobUpdatesShown) }
: prev,
);
},
});
return ( return (
<Stack <Stack
className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline" className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline"
@@ -38,13 +53,21 @@ export default function WorkingOnPanel() {
{task.title} {task.title}
</H2> </H2>
<Stack className="mt-4 gap-0! items-stretch"> <Stack className="mt-4 gap-0! items-stretch">
{task.steps.map((step) => ( {task.steps.length
<TaskStepRow ? task.steps.map((step) => (
key={step.id} <TaskStepRow
label={step.label || ""} key={step.id}
state={step.state || "pending"} label={step.label || ""}
/> state={step.state || "pending"}
))} />
))
: task.updates.map((update, index) => (
<TaskStepRow
key={update.id}
label={update.text || ""}
state={index == task.updates.length - 1 ? "active" : "done"}
/>
))}
</Stack> </Stack>
</> </>
) : ( ) : (
@@ -3,6 +3,7 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task"; import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read"; import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview"; import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview"; import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
import type { PagePropsType, PageQueryObject } from "@/src/types"; import type { PagePropsType, PageQueryObject } from "@/src/types";
@@ -40,13 +41,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
* The header's models modal: a worker's own settings, or for the * The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers). * coordinator the defaults it runs on (without the providers).
*/ */
const [bot_messages, bot_task, bot_harness_overview] = await Promise.all([ const [bot_messages, bot_task, bot_harness_overview, bot_computers] = await Promise.all([
grabBotMessages({ bot_id: bot.id }), grabBotMessages({ bot_id: bot.id }),
grabActiveBotTask({ bot_id: bot.id }), grabActiveBotTask({ bot_id: bot.id }),
(bot.kind == "coordinator" (bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses })) ? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id }) : grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null), ).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id: user.id }),
]); ]);
return { return {
@@ -56,6 +58,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_messages, bot_messages,
bot_task, bot_task,
bot_harness_overview, bot_harness_overview,
bot_computers,
}, },
}; };
}; };
+80
View File
@@ -0,0 +1,80 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import userAuth from "@/src/functions/backend/auth/user-auth";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import setBotComputer from "@/src/functions/backend/db/computers/set-bot-computer";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetBotComputerRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Switches the computer one of the session user's bots works on, and
* tells the user's other tabs. Returns the bot's computers, the new one
* first.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, computer_id } = parseWithSchema({
schema: SetBotComputerRequestSchema,
data: body,
});
const { bot, computer, computers } = await setBotComputer({
bot_id,
computer_id,
user_id: user.id,
});
await pushBotNotice({
bot_id: bot.id || 0,
bot: {
id: bot.id,
face: bot.face,
status_line: bot.status_line,
computer_id: computer.id,
},
});
await logEvent({
title: "Bot computer switched",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} switched ${bot.name} to ${computer.name || `computer #${computer.id}`}.`,
metadata: { bot_id: bot.id, computer_id: computer.id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `${bot.name} is on ${computer.name || "that computer"} now.`,
payload: computers,
};
} catch (error: unknown) {
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
+7
View File
@@ -18,6 +18,13 @@ export const BotIdRequestSchema = z.object({
export type BotIdRequest = z.infer<typeof BotIdRequestSchema>; export type BotIdRequest = z.infer<typeof BotIdRequestSchema>;
export const SetBotComputerRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
computer_id: z.coerce.number().int().positive("Pick a computer"),
});
export type SetBotComputerRequest = z.infer<typeof SetBotComputerRequestSchema>;
export const UploadBotAttachmentRequestSchema = z.object({ export const UploadBotAttachmentRequestSchema = z.object({
/** /**
* The file as a base64 data URL. * The file as a base64 data URL.
+3 -1
View File
@@ -6,6 +6,7 @@ import userAuth from "./functions/backend/auth/user-auth";
import type { WebSocketSession } from "./websocket/types"; import type { WebSocketSession } from "./websocket/types";
import { logError, grabRequestIP } from "./functions/backend/logger"; import { logError, grabRequestIP } from "./functions/backend/logger";
import botsQueue from "./bots-queue"; import botsQueue from "./bots-queue";
import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
declare global {} declare global {}
@@ -78,4 +79,5 @@ bunext.bunextLog.info(
); );
cron(); cron();
botsQueue(); botsQueue();
liveRelay();
+27
View File
@@ -4,6 +4,8 @@ import type {
BUN_SQLITE_COBBLE_BOT_TASK_STEPS, BUN_SQLITE_COBBLE_BOT_TASK_STEPS,
BUN_SQLITE_COBBLE_BOT_TASKS, BUN_SQLITE_COBBLE_BOT_TASKS,
BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES,
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_USER_TYPES, BUN_SQLITE_COBBLE_USER_TYPES,
BUN_SQLITE_COBBLE_USERS, BUN_SQLITE_COBBLE_USERS,
@@ -49,6 +51,18 @@ export type BotWithLastMessage = BUN_SQLITE_COBBLE_BOTS & {
*/ */
export type BotTaskWithSteps = BUN_SQLITE_COBBLE_BOT_TASKS & { export type BotTaskWithSteps = BUN_SQLITE_COBBLE_BOT_TASKS & {
steps: BUN_SQLITE_COBBLE_BOT_TASK_STEPS[]; steps: BUN_SQLITE_COBBLE_BOT_TASK_STEPS[];
/**
* What its newest job has done so far, oldest first (`grabBotJobUpdates`).
*/
updates: BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES[];
};
/**
* `server:bot-job-update` data: a step a running job just took, with the
* task it's for (null for a reply).
*/
export type BotJobUpdateData = BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & {
task_id: number | null;
}; };
/** /**
@@ -60,6 +74,11 @@ export type BotUpdatedData = {
face: BUN_SQLITE_COBBLE_BOTS["face"]; face: BUN_SQLITE_COBBLE_BOTS["face"];
status_line: string; status_line: string;
task: BotTaskWithSteps | null; task: BotTaskWithSteps | null;
/**
* The computer the bot is on, when the push knows it (a switch, or the
* live relay). Null means its main one.
*/
computer_id?: number | null;
}; };
/** /**
@@ -255,6 +274,10 @@ export type PagePropsType = {
bot?: BotWithLastMessage | null; bot?: BotWithLastMessage | null;
bot_messages?: BotMessageWithAttachments[] | null; bot_messages?: BotMessageWithAttachments[] | null;
bot_task?: BotTaskWithSteps | null; bot_task?: BotTaskWithSteps | null;
/**
* The open bot's computers, the one it's on first.
*/
bot_computers?: BUN_SQLITE_COBBLE_COMPUTERS[] | null;
environment?: string; environment?: string;
has_password?: boolean; has_password?: boolean;
backups?: DbBackup[] | null; backups?: DbBackup[] | null;
@@ -420,6 +443,10 @@ export const WebSocketEvents = [
* A bot's face or status line changed (`data`: `BotUpdatedData`) * A bot's face or status line changed (`data`: `BotUpdatedData`)
*/ */
"server:bot-updated", "server:bot-updated",
/**
* A running job took a step (`data`: `BotJobUpdateData`)
*/
"server:bot-job-update",
] as const; ] as const;
export type WebSocketDataType = { export type WebSocketDataType = {
+7
View File
@@ -0,0 +1,7 @@
/**
* How a bot names one of its computers in a message: `[computer:3]`. The
* chat shows it as a button that switches the bot to that computer.
*/
export default function computerRef({ id }: { id: number | string }) {
return `[computer:${id}]`;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
describe("linkComputerRefs", () => {
test("links every computer a message names", () => {
expect(
linkComputerRefs({
text: `Your GitHub session is on ${computerRef({ id: 3 })}, not ${computerRef({ id: 12 })}.`,
}),
).toBe("Your GitHub session is on [computer 3](#computer-3), not [computer 12](#computer-12).");
});
test("leaves look-alikes and existing links alone", () => {
const text = "See [computer:x], [link](url) and [computer:4](https://example.com)";
expect(linkComputerRefs({ text })).toBe(text);
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* The link target a computer ref (`computerRef`) becomes, e.g.
* `#computer-3`. The chat's markdown renders these links as buttons that
* switch the bot to that computer.
*/
export const ComputerRefHrefPrefix = "#computer-";
/**
* Turns every computer ref in a message into a markdown link to
* `#computer-<id>`, so it survives markdown rendering as a link the chat
* can swap for a switch button.
*/
export default function linkComputerRefs({ text }: { text: string }) {
return text.replace(
/\[computer:(\d+)\](?!\()/g,
(_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`,
);
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import messagePreview from "./message-preview";
describe("messagePreview", () => {
test("strips markdown and names computers plainly", () => {
expect(
messagePreview({
text: "## Found it\n\nYour **GitHub** session is on [computer:3]. See [the docs](https://x.y) and `search_computers`.\n\n- *one*\n- two",
}),
).toBe("Found it Your GitHub session is on a computer. See the docs and search_computers. one two");
});
test("keeps code text without its fences", () => {
expect(messagePreview({ text: "Run:\n```bash\nls ~\n```" })).toBe("Run: ls ~");
});
test("leaves plain text alone", () => {
expect(messagePreview({ text: "All done, 3 files_moved." })).toBe("All done, 3 files_moved.");
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* A bot message as one line of plain text, for the rail's preview: no
* markdown syntax, links as their text, computer refs (`computerRef`) as
* "a computer", whitespace collapsed.
*/
export default function messagePreview({ text }: { text: string }) {
return text
.replace(/```[^\n]*\n?/g, "")
.replace(/\[computer:\d+\](?!\()/g, "a computer")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, "")
.replace(/(\*\*|__|~~|`)/g, "")
.replace(/(^|\s)[*_](\S[^*_]*\S|\S)[*_](?=\s|$|[.,;:!?])/g, "$1$2")
.replace(/\|/g, " ")
.replace(/\s+/g, " ")
.trim();
}
@@ -0,0 +1,17 @@
import { getAllRooms } from "../(data)/rooms";
/**
* The users with an open socket on this process (every socket joins
* `public` on open).
*/
export default function grabConnectedUserIds() {
const ids = new Set<string>();
for (const ws of getAllRooms().get("public") || []) {
if (ws.data.user?.id && ws.readyState === WebSocket.OPEN) {
ids.add(String(ws.data.user.id));
}
}
return ids;
}