From 7b580d782e12509c6931f72c5cfba44710bb7025 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 04:44:35 +0100 Subject: [PATCH] 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 --- AGENTS.md | 17 ++-- bun.lock | 4 +- db/schema.ts | 64 ++++++++++++- db/types/db.ts | 46 +++++++++- package.json | 2 +- src/bots-queue/compose-computers-note.test.ts | 30 +++++++ src/bots-queue/compose-computers-note.ts | 50 +++++++++++ src/bots-queue/describe-job-step.test.ts | 47 ++++++++++ src/bots-queue/describe-job-step.ts | 45 ++++++++++ src/bots-queue/process-bot-job.ts | 27 ++++++ src/bots-queue/push-bot-notice.ts | 17 +++- src/bots-queue/run-bot-reply.ts | 9 +- src/bots-queue/run-bot-task.ts | 25 +++--- src/bots-queue/types.ts | 7 ++ src/data/app-data.ts | 16 ++++ src/data/harness-prompts.ts | 2 +- src/dict/bot-queue-dict.ts | 11 +++ .../backend/db/bots-queue/complete-bot-job.ts | 9 +- .../backend/db/bots-queue/enqueue-bot-job.ts | 11 +++ .../db/bots-queue/grab-bot-job-updates.ts | 28 ++++++ .../db/bots-queue/insert-bot-job-update.ts | 47 ++++++++++ .../db/bots-queue/notify-bot-job-failed.ts | 12 ++- .../db/bots-queue/relay-job-to-requester.ts | 53 +++++++++++ .../backend/db/bots/grab-active-bot-task.ts | 10 ++- .../db/computers/grab-bot-computers.ts | 13 ++- .../backend/db/computers/set-bot-computer.ts | 40 +++++++++ src/harness/tools/bot/assign-computer-tool.ts | 89 ++++++++++++++++++ src/harness/tools/bot/delegate-task-tool.ts | 15 +++- src/harness/tools/bot/grab-bot-chat-tools.ts | 32 ++++++- src/harness/tools/bot/start-task-tool.ts | 4 +- .../tools/computer/run-computer-command.ts | 41 +++++++++ .../tools/computer/search-computers-tool.ts | 82 +++++++++++++++++ src/layouts/bots/(partials)/bot-row.tsx | 7 +- src/live-relay/index.ts | 61 +++++++++++++ src/live-relay/relay-changed-bots.ts | 47 ++++++++++ src/live-relay/relay-new-bot-messages.ts | 31 +++++++ src/live-relay/relay-new-job-updates.ts | 33 +++++++ src/live-relay/types.ts | 9 ++ .../[bot_id]/(data)/bot-computers-context.ts | 11 +++ .../(data)/message-markdown-components.tsx | 70 +++++++++++++++ .../[bot_id]/(functions)/set-bot-computer.ts | 18 ++++ .../[bot_id]/(hooks)/use-bot-computers.ts | 70 +++++++++++++++ .../(partials)/bot-computer-option.tsx | 50 +++++++++++ .../(partials)/bot-computer-select.tsx | 90 +++++++++++++++++++ .../[bot_id]/(partials)/bot-message-line.tsx | 18 +++- .../[bot_id]/(partials)/bot-models-modal.tsx | 5 +- .../[bot_id]/(partials)/computer-ref-chip.tsx | 56 ++++++++++++ .../(partials)/conversation-header.tsx | 11 ++- .../(partials)/markdown-code-block.tsx | 26 ++++++ .../[bot_id]/(partials)/markdown-link.tsx | 27 ++++++ .../[bot_id]/(partials)/markdown-list.tsx | 29 ++++++ .../bots/[bot_id]/(sections)/conversation.tsx | 42 +++++---- .../[bot_id]/(sections)/working-on-panel.tsx | 41 +++++++-- src/pages/admin/bots/[bot_id]/index.server.ts | 5 +- src/pages/api/admin/bots/set-computer.ts | 80 +++++++++++++++++ src/schemas/bot-schema.ts | 7 ++ src/server.ts | 4 +- src/types/index.ts | 27 ++++++ src/utils/computer-ref.ts | 7 ++ src/utils/link-computer-refs.test.ts | 19 ++++ src/utils/link-computer-refs.ts | 18 ++++ src/utils/message-preview.test.ts | 20 +++++ src/utils/message-preview.ts | 17 ++++ .../(functions)/grab-connected-user-ids.ts | 17 ++++ 64 files changed, 1792 insertions(+), 86 deletions(-) create mode 100644 src/bots-queue/compose-computers-note.test.ts create mode 100644 src/bots-queue/compose-computers-note.ts create mode 100644 src/bots-queue/describe-job-step.test.ts create mode 100644 src/bots-queue/describe-job-step.ts create mode 100644 src/functions/backend/db/bots-queue/grab-bot-job-updates.ts create mode 100644 src/functions/backend/db/bots-queue/insert-bot-job-update.ts create mode 100644 src/functions/backend/db/bots-queue/relay-job-to-requester.ts create mode 100644 src/functions/backend/db/computers/set-bot-computer.ts create mode 100644 src/harness/tools/bot/assign-computer-tool.ts create mode 100644 src/harness/tools/computer/run-computer-command.ts create mode 100644 src/harness/tools/computer/search-computers-tool.ts create mode 100644 src/live-relay/index.ts create mode 100644 src/live-relay/relay-changed-bots.ts create mode 100644 src/live-relay/relay-new-bot-messages.ts create mode 100644 src/live-relay/relay-new-job-updates.ts create mode 100644 src/live-relay/types.ts create mode 100644 src/pages/admin/bots/[bot_id]/(data)/bot-computers-context.ts create mode 100644 src/pages/admin/bots/[bot_id]/(data)/message-markdown-components.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(functions)/set-bot-computer.ts create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-bot-computers.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/bot-computer-option.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/bot-computer-select.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/computer-ref-chip.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/markdown-code-block.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/markdown-link.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/markdown-list.tsx create mode 100644 src/pages/api/admin/bots/set-computer.ts create mode 100644 src/utils/computer-ref.ts create mode 100644 src/utils/link-computer-refs.test.ts create mode 100644 src/utils/link-computer-refs.ts create mode 100644 src/utils/message-preview.test.ts create mode 100644 src/utils/message-preview.ts create mode 100644 src/websocket/(functions)/grab-connected-user-ids.ts diff --git a/AGENTS.md b/AGENTS.md index 8e50e81..7276e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 - `/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` -- `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 @@ -87,16 +87,17 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - 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. - **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. -- **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. - **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: `, 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`. -- **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. -- **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`. -- **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. +- **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). ### 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. - **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`). -- **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://:/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. ## Admin Route Hierarchy diff --git a/bun.lock b/bun.lock index acaf3f9..d409741 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bunext": "^1.3.3", - "@moduletrace/twui": "^0.1.1", + "@moduletrace/twui": "^0.1.2", "ace-builds": "^1.44.0", "dayjs": "^1.11.23", "gray-matter": "^4.0.3", @@ -234,7 +234,7 @@ "@moduletrace/bunext": ["@moduletrace/bunext@1.3.3", "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/twui@0.1.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/twui@0.1.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/mdx@16.3.0", "", { "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=="], diff --git a/db/schema.ts b/db/schema.ts index c5b494d..f4aaf6b 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -15,7 +15,7 @@ import { } from "@/src/dict/computer-dict"; import { Harnesses } from "@/src/dict/harness-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 type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types"; 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`, 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: [ { @@ -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`, 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", 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", fields: [ diff --git a/db/types/db.ts b/db/types/db.ts index c99233e..cd606a6 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -10,6 +10,7 @@ export const BunSQLiteTables = [ "bot_tasks", "bot_task_steps", "bots_queue", + "bots_queue_updates", "workspaces", "workspace_bots", "bot_message_media", @@ -193,6 +194,10 @@ export type BUN_SQLITE_COBBLE_BOTS = { * Unix timestamp of the latest message, for rail ordering */ 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 = { @@ -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 */ 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 */ @@ -349,6 +358,41 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = { 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 = { /** * The unique identifier of the record. @@ -792,4 +836,4 @@ export type BUN_SQLITE_COBBLE_ENVS = { 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 \ No newline at end of file +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 \ No newline at end of file diff --git a/package.json b/package.json index 911e977..54cddf9 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bunext": "^1.3.3", - "@moduletrace/twui": "^0.1.1", + "@moduletrace/twui": "^0.1.2", "ace-builds": "^1.44.0", "dayjs": "^1.11.23", "gray-matter": "^4.0.3", diff --git a/src/bots-queue/compose-computers-note.test.ts b/src/bots-queue/compose-computers-note.test.ts new file mode 100644 index 0000000..ca55a2a --- /dev/null +++ b/src/bots-queue/compose-computers-note.test.ts @@ -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"); + }); +}); diff --git a/src/bots-queue/compose-computers-note.ts b/src/bots-queue/compose-computers-note.ts new file mode 100644 index 0000000..d4434bc --- /dev/null +++ b/src/bots-queue/compose-computers-note.ts @@ -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.`; +} diff --git a/src/bots-queue/describe-job-step.test.ts b/src/bots-queue/describe-job-step.test.ts new file mode 100644 index 0000000..4419614 --- /dev/null +++ b/src/bots-queue/describe-job-step.test.ts @@ -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(); + }); +}); diff --git a/src/bots-queue/describe-job-step.ts b/src/bots-queue/describe-job-step.ts new file mode 100644 index 0000000..2b3ae42 --- /dev/null +++ b/src/bots-queue/describe-job-step.ts @@ -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; +} | 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, + }; +} diff --git a/src/bots-queue/process-bot-job.ts b/src/bots-queue/process-bot-job.ts index 9323810..0bbdc72 100644 --- a/src/bots-queue/process-bot-job.ts +++ b/src/bots-queue/process-bot-job.ts @@ -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 failBotJob from "@/src/functions/backend/db/bots-queue/fail-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 createKeyLock from "@/src/utils/create-key-lock"; import HarnessError from "@/src/harness/harness-error"; import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job"; import { BotWaitingStatusLine } from "@/src/dict/bot-queue-dict"; 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 runBotJob from "./run-bot-job"; import parseJobJson from "./parse-job-json"; +import type { BotJobUpdateData } from "@/src/types"; import type { BotJobContext, BotJobRunner } from "./types"; /** @@ -66,6 +70,28 @@ export default async function processBotJob({ 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(() => { update({}).catch(() => {}); }, HEARTBEAT_MS); @@ -91,6 +117,7 @@ export default async function processBotJob({ checkpoint: parseJobJson({ text: job.checkpoint }), signal: controller.signal, update, + report, }); }; diff --git a/src/bots-queue/push-bot-notice.ts b/src/bots-queue/push-bot-notice.ts index 90e8c42..18a4548 100644 --- a/src/bots-queue/push-bot-notice.ts +++ b/src/bots-queue/push-bot-notice.ts @@ -8,14 +8,20 @@ import pushToBotOwner from "./push-to-bot-owner"; type Params = { bot_id: number | string; message?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null; - bot?: Pick | null; + bot?: Pick | 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. * 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) { await pushToBotOwner({ 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) { await pushToBotOwner({ bot_id, diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index 0f23643..a18cfaf 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -3,9 +3,11 @@ import { AppData } from "@/src/data/app-data"; import { ChatReplyNote, CoordinatorReplyNote } from "@/src/data/harness-prompts"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; 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 grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools"; import botMessagesToHarness from "./bot-messages-to-harness"; +import composeComputersNote from "./compose-computers-note"; import loadAttachmentParts from "./load-attachment-parts"; 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 * 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 * is already the bot's, i.e. an earlier reply answered everything. */ @@ -24,6 +26,7 @@ export default async function runBotReply({ bot, job, signal, + report, }: Params): Promise { const history = await grabBotMessages({ 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 computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id }); const result = await runTextLoop({ system: [ prompt, ChatReplyNote, + composeComputersNote({ computers, is_coordinator: bot.kind == "coordinator" }), bot.kind == "coordinator" ? CoordinatorReplyNote : "", `Now: ${new Date().toUTCString()}.`, ] @@ -52,11 +57,13 @@ export default async function runBotReply({ tools: await grabBotChatTools({ bot, user_id: bot.user_id, + computers, message_id: job.message_id || undefined, }), max_steps: AppData.BotReplyMaxToolSteps, bot_id: bot.id, signal, + onStep: report, }); return { result: result.text }; diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index 26f856e..0045f85 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -1,8 +1,11 @@ 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 runTextLoop from "@/src/harness/text/run-text-loop"; import openComputerTools from "@/src/harness/tools/computer/open-computer-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 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, - * its own tools and, when the 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. + * its own tools, search_computers across all its computers and, when the + * job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user. + * Every model turn is reported as a step (`report`). */ export default async function runBotTask({ bot, job, params, signal, - update, + report, }: Params): Promise { const computer = job.computer_id ? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal }) @@ -29,6 +32,7 @@ export default async function runBotTask({ try { 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({ system: [ @@ -37,6 +41,9 @@ export default async function runBotTask({ 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 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 ? `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}` : ""}`) @@ -57,17 +64,13 @@ export default async function runBotTask({ ], tools: [ ...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }), + ...(computers.length ? [searchComputersTool({ bot_id: bot.id, computers })] : []), ...(computer?.tools || []), ], bot_id: bot.id, signal, - onStep: async ({ text, tool_calls }) => { - await update({ - progress: tool_calls.length - ? `Using ${tool_calls.map((call) => call.name).join(", ")}` - : text?.slice(0, 500) || undefined, - }); - }, + onStep: (step) => + report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }), }); return { result: computer ? computer.redact(result.text) : result.text }; diff --git a/src/bots-queue/types.ts b/src/bots-queue/types.ts index 00ab3d1..e6f92a8 100644 --- a/src/bots-queue/types.ts +++ b/src/bots-queue/types.ts @@ -1,4 +1,5 @@ import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db"; +import type { TextLoopStep } from "@/src/harness/text/run-text-loop"; export type BotJobContext = { job: BUN_SQLITE_COBBLE_BOTS_QUEUE; @@ -21,6 +22,12 @@ export type BotJobContext = { progress?: string; checkpoint?: Record; }) => Promise; + /** + * 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; }; export type BotJobOutcome = { diff --git a/src/data/app-data.ts b/src/data/app-data.ts index a777dc7..3d94738 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -108,6 +108,22 @@ export const AppData = { * One MCP tool call gives up after this long without progress. */ 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. */ diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index 1589796..4019a8c 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -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. */ -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. diff --git a/src/dict/bot-queue-dict.ts b/src/dict/bot-queue-dict.ts index 81fc32b..2892b12 100644 --- a/src/dict/bot-queue-dict.ts +++ b/src/dict/bot-queue-dict.ts @@ -21,6 +21,17 @@ export const BotJobKinds = [ 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 * shows a thinking row while a bot has it. diff --git a/src/functions/backend/db/bots-queue/complete-bot-job.ts b/src/functions/backend/db/bots-queue/complete-bot-job.ts index df66234..81f160e 100644 --- a/src/functions/backend/db/bots-queue/complete-bot-job.ts +++ b/src/functions/backend/db/bots-queue/complete-bot-job.ts @@ -4,6 +4,7 @@ import insertBotMessage from "../bots/insert-bot-message"; import setBotFace from "../bots/set-bot-face"; import countOpenBotJobs from "./count-open-bot-jobs"; import grabLockedBotJob from "./grab-locked-bot-job"; +import relayJobToRequester from "./relay-job-to-requester"; type Params = { job_id: number | string; @@ -27,8 +28,8 @@ type Params = { * 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, * the bot shows needs-you if asked, else done after a task and idle after - * a reply. Throws if this handler no longer holds - * the job. + * a reply. A handed-over job's result also goes to the bot that handed + * it over (`relay`). Throws if this handler no longer holds the job. */ export default async function completeBotJob({ job_id, @@ -87,5 +88,7 @@ export default async function completeBotJob({ face: job.kind == "reply" ? "idle" : "done", }); - return { message, bot }; + const relay = silent ? null : await relayJobToRequester({ job, text: result }); + + return { message, bot, relay }; } diff --git a/src/functions/backend/db/bots-queue/enqueue-bot-job.ts b/src/functions/backend/db/bots-queue/enqueue-bot-job.ts index 7da9538..eaf21f9 100644 --- a/src/functions/backend/db/bots-queue/enqueue-bot-job.ts +++ b/src/functions/backend/db/bots-queue/enqueue-bot-job.ts @@ -41,6 +41,11 @@ type Params = { * Milliseconds to hold the job back before it can be claimed. */ 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, computer_id, delay, + requested_by_bot_id, }: Params) { const bot = await grabUserBot({ bot_id, user_id }); @@ -67,6 +73,10 @@ export default async function enqueueBotJob({ 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 }))) { throw new AppError(`Message not found.`); } @@ -103,6 +113,7 @@ export default async function enqueueBotJob({ message_id: message_id ? Number(message_id) : undefined, task_id: final_task_id ? Number(final_task_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, diff --git a/src/functions/backend/db/bots-queue/grab-bot-job-updates.ts b/src/functions/backend/db/bots-queue/grab-bot-job-updates.ts new file mode 100644 index 0000000..83a55fa --- /dev/null +++ b/src/functions/backend/db/bots-queue/grab-bot-job-updates.ts @@ -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 { + const res = await BunSQLite.sql({ + 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 || []; +} diff --git a/src/functions/backend/db/bots-queue/insert-bot-job-update.ts b/src/functions/backend/db/bots-queue/insert-bot-job-update.ts new file mode 100644 index 0000000..ac43cab --- /dev/null +++ b/src/functions/backend/db/bots-queue/insert-bot-job-update.ts @@ -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; +}; + +/** + * 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 { + 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() }; +} diff --git a/src/functions/backend/db/bots-queue/notify-bot-job-failed.ts b/src/functions/backend/db/bots-queue/notify-bot-job-failed.ts index 547c402..cda0912 100644 --- a/src/functions/backend/db/bots-queue/notify-bot-job-failed.ts +++ b/src/functions/backend/db/bots-queue/notify-bot-job-failed.ts @@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db"; import insertBotMessage from "../bots/insert-bot-message"; import setBotFace from "../bots/set-bot-face"; +import relayJobToRequester from "./relay-job-to-requester"; type Params = { 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 * 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({ job, @@ -47,5 +49,11 @@ export default async function notifyBotJobFailed({ 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 }; } diff --git a/src/functions/backend/db/bots-queue/relay-job-to-requester.ts b/src/functions/backend/db/bots-queue/relay-job-to-requester.ts new file mode 100644 index 0000000..acbe91a --- /dev/null +++ b/src/functions/backend/db/bots-queue/relay-job-to-requester.ts @@ -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({ + sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`, + values: [job.bot_id], + }) + ).payload?.[0]; + + const requester = ( + await BunSQLite.sql({ + 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 }; +} diff --git a/src/functions/backend/db/bots/grab-active-bot-task.ts b/src/functions/backend/db/bots/grab-active-bot-task.ts index 7130df4..d61fe09 100644 --- a/src/functions/backend/db/bots/grab-active-bot-task.ts +++ b/src/functions/backend/db/bots/grab-active-bot-task.ts @@ -4,13 +4,15 @@ import type { } from "@/db/types/db"; import type { BotTaskWithSteps } from "@/src/types"; import BunSQLite from "@moduletrace/bun-sqlite"; +import grabBotJobUpdates from "../bots-queue/grab-bot-job-updates"; type Params = { 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({ bot_id, @@ -31,5 +33,9 @@ export default async function grabActiveBotTask({ values: [task.id], }); - return { ...task, steps: steps_res.payload || [] }; + return { + ...task, + steps: steps_res.payload || [], + updates: await grabBotJobUpdates({ task_id: task.id }), + }; } diff --git a/src/functions/backend/db/computers/grab-bot-computers.ts b/src/functions/backend/db/computers/grab-bot-computers.ts index aa87564..50a3b2f 100644 --- a/src/functions/backend/db/computers/grab-bot-computers.ts +++ b/src/functions/backend/db/computers/grab-bot-computers.ts @@ -9,7 +9,10 @@ type Params = { /** * Every computer a bot can use: the ones it was given directly * (`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({ bot_id, @@ -19,7 +22,8 @@ export default async function grabBotComputers({ sql: `SELECT c.* FROM computers c WHERE c.user_id = ? 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 ( SELECT wc.computer_id FROM workspace_computers wc 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 ) ) - ORDER BY c.kind = 'main' DESC, c.id ASC`, - values: [user_id, bot_id, bot_id], + ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC, + c.kind = 'main' DESC, c.id ASC`, + values: [user_id, bot_id, bot_id, bot_id, bot_id], }); return res.payload || []; diff --git a/src/functions/backend/db/computers/set-bot-computer.ts b/src/functions/backend/db/computers/set-bot-computer.ts new file mode 100644 index 0000000..74b6a11 --- /dev/null +++ b/src/functions/backend/db/computers/set-bot-computer.ts @@ -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)], + }; +} diff --git a/src/harness/tools/bot/assign-computer-tool.ts b/src/harness/tools/bot/assign-computer-tool.ts new file mode 100644 index 0000000..23ab885 --- /dev/null +++ b/src/harness/tools/bot/assign-computer-tool.ts @@ -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; +}; + +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 { + 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.`; + }, + }; +} diff --git a/src/harness/tools/bot/delegate-task-tool.ts b/src/harness/tools/bot/delegate-task-tool.ts index 4a66e39..be58b6e 100644 --- a/src/harness/tools/bot/delegate-task-tool.ts +++ b/src/harness/tools/bot/delegate-task-tool.ts @@ -8,6 +8,10 @@ import type { HarnessTool } from "../../types"; type Params = { 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. */ @@ -26,11 +30,13 @@ type Args = { /** * 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 - * conversation, where a note says who asked. + * the computer it's on (if it has one) and posts the result in its own + * conversation, where a note says who asked; the result (or failure) is + * also reported back in the coordinator's conversation. */ export default function delegateTaskTool({ user_id, + coordinator_id, coordinator_name, workers, }: Params): HarnessTool { @@ -40,7 +46,7 @@ export default function delegateTaskTool({ return { 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({ bot_id: z.number().int(), title: z.string().min(1).max(80), @@ -61,6 +67,7 @@ export default function delegateTaskTool({ title: args.title, instructions: args.instructions, computer_id: computers[0]?.id || undefined, + requested_by_bot_id: coordinator_id, }); 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.`; }, }; } diff --git a/src/harness/tools/bot/grab-bot-chat-tools.ts b/src/harness/tools/bot/grab-bot-chat-tools.ts index a5339e1..fd2021d 100644 --- a/src/harness/tools/bot/grab-bot-chat-tools.ts +++ b/src/harness/tools/bot/grab-bot-chat-tools.ts @@ -1,7 +1,9 @@ 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 type { HarnessTool } from "../../types"; +import searchComputersTool from "../computer/search-computers-tool"; +import assignComputerTool from "./assign-computer-tool"; import delegateTaskTool from "./delegate-task-tool"; import grabBotTools from "./grab-bot-tools"; import startTaskTool from "./start-task-tool"; @@ -9,6 +11,10 @@ import startTaskTool from "./start-task-tool"; type Params = { bot: BUN_SQLITE_COBBLE_BOTS; user_id: number | string; + /** + * The bot's computers, the current one first (`grabBotComputers`). + */ + computers: BUN_SQLITE_COBBLE_COMPUTERS[]; /** * The user message being answered. */ @@ -17,12 +23,15 @@ type Params = { /** * The tools a bot replies in chat with: its own (memory, instructions), - * start_task for longer work, and for the coordinator delegate_task to - * the crew. Computers are reached through tasks, never mid-reply. + * start_task for longer work, search_computers to find what's on its + * 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({ bot, user_id, + computers, message_id, }: Params): Promise { const bot_id = bot.id || 0; @@ -31,9 +40,10 @@ export default async function grabBotChatTools({ startTaskTool({ user_id, bot_id, - computers: await grabBotComputers({ bot_id, user_id }), + computers, message_id, }), + ...(computers.length ? [searchComputersTool({ bot_id, computers })] : []), ]; if (bot.kind != "coordinator") return tools; @@ -50,10 +60,24 @@ export default async function grabBotChatTools({ tools.push( delegateTaskTool({ user_id, + coordinator_id: bot_id, coordinator_name: bot.name || "The coordinator", 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; diff --git a/src/harness/tools/bot/start-task-tool.ts b/src/harness/tools/bot/start-task-tool.ts index 1cb8560..0e52f1f 100644 --- a/src/harness/tools/bot/start-task-tool.ts +++ b/src/harness/tools/bot/start-task-tool.ts @@ -7,7 +7,7 @@ type Params = { user_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[]; /** @@ -40,7 +40,7 @@ export default function startTaskTool({ 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. ${ 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.` }`, schema: z.object({ diff --git a/src/harness/tools/computer/run-computer-command.ts b/src/harness/tools/computer/run-computer-command.ts new file mode 100644 index 0000000..85b9738 --- /dev/null +++ b/src/harness/tools/computer/run-computer-command.ts @@ -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, + }; +} diff --git a/src/harness/tools/computer/search-computers-tool.ts b/src/harness/tools/computer/search-computers-tool.ts new file mode 100644 index 0000000..a743c00 --- /dev/null +++ b/src/harness/tools/computer/search-computers-tool.ts @@ -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 { + 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"); + }, + }; +} diff --git a/src/layouts/bots/(partials)/bot-row.tsx b/src/layouts/bots/(partials)/bot-row.tsx index fd6c82e..144da11 100644 --- a/src/layouts/bots/(partials)/bot-row.tsx +++ b/src/layouts/bots/(partials)/bot-row.tsx @@ -6,6 +6,7 @@ import { twMerge } from "tailwind-merge"; import BotStone from "@/src/components/general/bot-stone"; import type { BotWithLastMessage } from "@/src/types"; import type { BotFace, BotHue } from "@/src/dict/bot-dict"; +import messagePreview from "@/src/utils/message-preview"; import relativeTime from "@/src/utils/relative-time"; import UnreadDot from "./unread-dot"; @@ -24,7 +25,11 @@ type Props = { */ export default function BotRow({ bot, selected, subtitle }: Props) { 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; return ( diff --git a/src/live-relay/index.ts b/src/live-relay/index.ts new file mode 100644 index 0000000..a23199c --- /dev/null +++ b/src/live-relay/index.ts @@ -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); +} diff --git a/src/live-relay/relay-changed-bots.ts b/src/live-relay/relay-changed-bots.ts new file mode 100644 index 0000000..55a8b76 --- /dev/null +++ b/src/live-relay/relay-changed-bots.ts @@ -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; +}; + +/** + * 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({ + 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 } }); + } +} diff --git a/src/live-relay/relay-new-bot-messages.ts b/src/live-relay/relay-new-bot-messages.ts new file mode 100644 index 0000000..bb7e084 --- /dev/null +++ b/src/live-relay/relay-new-bot-messages.ts @@ -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; +}; + +/** + * 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({ + 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; +} diff --git a/src/live-relay/relay-new-job-updates.ts b/src/live-relay/relay-new-job-updates.ts new file mode 100644 index 0000000..8f3ea64 --- /dev/null +++ b/src/live-relay/relay-new-job-updates.ts @@ -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; +}; + +/** + * 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({ + 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; +} diff --git a/src/live-relay/types.ts b/src/live-relay/types.ts new file mode 100644 index 0000000..22eee1e --- /dev/null +++ b/src/live-relay/types.ts @@ -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; +}; diff --git a/src/pages/admin/bots/[bot_id]/(data)/bot-computers-context.ts b/src/pages/admin/bots/[bot_id]/(data)/bot-computers-context.ts new file mode 100644 index 0000000..73a5845 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(data)/bot-computers-context.ts @@ -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({ + computers: [], + switchComputer: async () => {}, +}); diff --git a/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components.tsx b/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components.tsx new file mode 100644 index 0000000..e94217d --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components.tsx @@ -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 }) => ( +

+ {children} +

+ ), + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>
{children}
, + h5: ({ children }) =>
{children}
, + h6: ({ children }) =>
{children}
, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + del: ({ children }) => {children}, + a: MarkdownLink, + ul: ({ children }) => {children}, + ol: ({ children, start }) => ( + + {children} + + ), + code: ({ children }) => ( + + {children} + + ), + pre: ({ children }) => {children}, + blockquote: ({ children }) => ( + + {children} + + ), + hr: () => , + img: ({ src, alt }) => ( + {alt || "Image"} + ), + table: ({ children }) => ( + + {children}
+
+ ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + {children} + ), +}; diff --git a/src/pages/admin/bots/[bot_id]/(functions)/set-bot-computer.ts b/src/pages/admin/bots/[bot_id]/(functions)/set-bot-computer.ts new file mode 100644 index 0000000..d83dad8 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(functions)/set-bot-computer.ts @@ -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 + >(`/api/admin/bots/set-computer`, { + method: "POST", + body: { bot_id, computer_id }, + }); +} diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-computers.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-computers.ts new file mode 100644 index 0000000..2112a74 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-bot-computers.ts @@ -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; +}; + +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(); + const [error, setError] = useState(); + + 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 }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-computer-option.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-computer-option.tsx new file mode 100644 index 0000000..8ca8073 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-computer-option.tsx @@ -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 ( + { + 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" + }`} + > + + + + {computer.name || `Computer ${computer.id}`} + + {computer.description ? ( + + {computer.description} + + ) : null} + + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-computer-select.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-computer-select.tsx new file mode 100644 index 0000000..8a9467f --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-computer-select.tsx @@ -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 = ( + + ); + + if (!can_switch) { + return {target}; + } + + return ( + + + {computers.map((computer, index) => ( + { + setOpen(false); + await switchComputer({ computer_id: computer.id || 0 }); + }} + /> + ))} + {error ? ( + + {error} + + ) : null} + + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx index c3301f2..ac2326b 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx @@ -1,5 +1,8 @@ +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; 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"; type Props = { @@ -7,12 +10,19 @@ type Props = { 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) { return ( -

- {text} -

+ + + {linkComputerRefs({ text })} + + {needs_you ? : null}
); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx index 38206b4..85ebb6d 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx @@ -49,6 +49,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) { color="gray" size="small" 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" > @@ -61,7 +62,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) { if (!can_edit) { return ( - + {target} ); @@ -71,7 +72,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) { c.id == computer_id); + const computer = computers[index]; + const name = computer?.name || `computer ${computer_id}`; + + if (!computer) { + return {name}; + } + + if (index == 0) { + return ( + + + {name} + + + ); + } + + return ( + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx index 35bf9ee..1740cfc 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx @@ -6,6 +6,7 @@ import BotStone from "@/src/components/general/bot-stone"; import type { BotFace, BotHue } from "@/src/dict/bot-dict"; import type { BotWithLastMessage, HarnessOverview } from "@/src/types"; import { crewStatusSentence } from "@/src/utils/crew-status"; +import BotComputerSelect from "./bot-computer-select"; import BotModelsModal from "./bot-models-modal"; type Props = { @@ -19,8 +20,9 @@ type Props = { /** * 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 - * the coordinator, for super admins). + * right, the computer the bot is on (`BotComputerSelect`), then the model + * button that opens the bot's models (the defaults on the coordinator, + * for super admins). */ export default function ConversationHeader({ bot, bots, harnesses }: Props) { const status = @@ -43,7 +45,10 @@ export default function ConversationHeader({ bot, bots, harnesses }: Props) { {status} - + + + + ); } diff --git a/src/pages/admin/bots/[bot_id]/(partials)/markdown-code-block.tsx b/src/pages/admin/bots/[bot_id]/(partials)/markdown-code-block.tsx new file mode 100644 index 0000000..3e816a6 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/markdown-code-block.tsx @@ -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 ( + + {code} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/markdown-link.tsx b/src/pages/admin/bots/[bot_id]/(partials)/markdown-link.tsx new file mode 100644 index 0000000..42059a1 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/markdown-link.tsx @@ -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 ; + } + + return ( + + {children} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/markdown-list.tsx b/src/pages/admin/bots/[bot_id]/(partials)/markdown-list.tsx new file mode 100644 index 0000000..4d2d132 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/markdown-list.tsx @@ -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 ( + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index 7b286e1..8a27e2c 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -5,12 +5,16 @@ import { AppContext } from "@/src/pages/__root"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import useLiveBots from "@/src/hooks/use-live-bots"; 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 MessageThread from "../(partials)/message-thread"; import Composer from "../(partials)/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() { 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 live_bot = bots.find((b) => b.id == bot?.id) || bot; + const bot_computers = useBotComputers({ + bot_id: bot?.id || 0, + computers: pageProps?.bot_computers || [], + }); useServerEvent({ event: "server:bot-message", @@ -44,21 +52,23 @@ export default function Conversation() { if (!bot?.id || !live_bot) return null; return ( - - - - - setMessages((prev) => - prev.some((m) => m.id == message.id) ? prev : [...prev, message], - ) - } - /> - + + + + + + setMessages((prev) => + prev.some((m) => m.id == message.id) ? prev : [...prev, message], + ) + } + /> + + ); } diff --git a/src/pages/admin/bots/[bot_id]/(sections)/working-on-panel.tsx b/src/pages/admin/bots/[bot_id]/(sections)/working-on-panel.tsx index 6f33432..e020468 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/working-on-panel.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/working-on-panel.tsx @@ -4,13 +4,17 @@ import Span from "@moduletrace/twui/layout/Span"; import H2 from "@moduletrace/twui/layout/H2"; import { AppContext } from "@/src/pages/__root"; 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 OpenComputerButton from "../(partials)/open-computer-button"; /** * 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() { 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 ( - {task.steps.map((step) => ( - - ))} + {task.steps.length + ? task.steps.map((step) => ( + + )) + : task.updates.map((update, index) => ( + + ))} ) : ( diff --git a/src/pages/admin/bots/[bot_id]/index.server.ts b/src/pages/admin/bots/[bot_id]/index.server.ts index 15f8891..70d7bd6 100644 --- a/src/pages/admin/bots/[bot_id]/index.server.ts +++ b/src/pages/admin/bots/[bot_id]/index.server.ts @@ -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 grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task"; 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 grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview"; import type { PagePropsType, PageQueryObject } from "@/src/types"; @@ -40,13 +41,14 @@ export const server: BunextPageServerFn = async (params) => { * The header's models modal: a worker's own settings, or for the * 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 }), grabActiveBotTask({ bot_id: bot.id }), (bot.kind == "coordinator" ? grabHarnessOverview().then(({ harnesses }) => ({ harnesses })) : grabBotHarnessOverview({ bot_id: bot.id }) ).catch(() => null), + grabBotComputers({ bot_id: bot.id, user_id: user.id }), ]); return { @@ -56,6 +58,7 @@ export const server: BunextPageServerFn = async (params) => { bot_messages, bot_task, bot_harness_overview, + bot_computers, }, }; }; diff --git a/src/pages/api/admin/bots/set-computer.ts b/src/pages/api/admin/bots/set-computer.ts new file mode 100644 index 0000000..29479ff --- /dev/null +++ b/src/pages/api/admin/bots/set-computer.ts @@ -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 +> = 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 }), + }; + } +}; diff --git a/src/schemas/bot-schema.ts b/src/schemas/bot-schema.ts index f358200..8ec36c5 100644 --- a/src/schemas/bot-schema.ts +++ b/src/schemas/bot-schema.ts @@ -18,6 +18,13 @@ export const BotIdRequestSchema = z.object({ export type BotIdRequest = z.infer; +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; + export const UploadBotAttachmentRequestSchema = z.object({ /** * The file as a base64 data URL. diff --git a/src/server.ts b/src/server.ts index deb9f81..72cbacf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import userAuth from "./functions/backend/auth/user-auth"; import type { WebSocketSession } from "./websocket/types"; import { logError, grabRequestIP } from "./functions/backend/logger"; import botsQueue from "./bots-queue"; +import liveRelay from "./live-relay"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; declare global {} @@ -78,4 +79,5 @@ bunext.bunextLog.info( ); cron(); -botsQueue(); \ No newline at end of file +botsQueue(); +liveRelay(); \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 68ee817..f65ee56 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,8 @@ import type { BUN_SQLITE_COBBLE_BOT_TASK_STEPS, BUN_SQLITE_COBBLE_BOT_TASKS, BUN_SQLITE_COBBLE_BOTS, + BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES, + BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_USER_TYPES, BUN_SQLITE_COBBLE_USERS, @@ -49,6 +51,18 @@ export type BotWithLastMessage = BUN_SQLITE_COBBLE_BOTS & { */ export type BotTaskWithSteps = BUN_SQLITE_COBBLE_BOT_TASKS & { 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"]; status_line: string; 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_messages?: BotMessageWithAttachments[] | 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; has_password?: boolean; backups?: DbBackup[] | null; @@ -420,6 +443,10 @@ export const WebSocketEvents = [ * A bot's face or status line changed (`data`: `BotUpdatedData`) */ "server:bot-updated", + /** + * A running job took a step (`data`: `BotJobUpdateData`) + */ + "server:bot-job-update", ] as const; export type WebSocketDataType = { diff --git a/src/utils/computer-ref.ts b/src/utils/computer-ref.ts new file mode 100644 index 0000000..135bce8 --- /dev/null +++ b/src/utils/computer-ref.ts @@ -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}]`; +} diff --git a/src/utils/link-computer-refs.test.ts b/src/utils/link-computer-refs.test.ts new file mode 100644 index 0000000..f5fb9be --- /dev/null +++ b/src/utils/link-computer-refs.test.ts @@ -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); + }); +}); diff --git a/src/utils/link-computer-refs.ts b/src/utils/link-computer-refs.ts new file mode 100644 index 0000000..c2f40c9 --- /dev/null +++ b/src/utils/link-computer-refs.ts @@ -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-`, 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})`, + ); +} diff --git a/src/utils/message-preview.test.ts b/src/utils/message-preview.test.ts new file mode 100644 index 0000000..ecab5bd --- /dev/null +++ b/src/utils/message-preview.test.ts @@ -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."); + }); +}); diff --git a/src/utils/message-preview.ts b/src/utils/message-preview.ts new file mode 100644 index 0000000..a548615 --- /dev/null +++ b/src/utils/message-preview.ts @@ -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(); +} diff --git a/src/websocket/(functions)/grab-connected-user-ids.ts b/src/websocket/(functions)/grab-connected-user-ids.ts new file mode 100644 index 0000000..3fddc81 --- /dev/null +++ b/src/websocket/(functions)/grab-connected-user-ids.ts @@ -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(); + + 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; +}