Share one crew across the install, keep user memory per user

Bots, workspaces and computers now belong to the install. Users reach
workers only through bot_users (assigned by a super admin or admin) and
workspace_users; the coordinator and main computer are one per install,
and only the super admin sees the coordinator. Conversations are per
user (bot_sessions.user_id), jobs run for the session's user, and live
updates go only to who should see them.

A bot's core memory is now general knowledge shared by every user it
works for; what it learns about a user lives in bot_user_memories.

One-off scripts move existing installs over: move-bot-memory-to-owners
(run first), then share-crew.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 11:37:14 +01:00
co-authored by Claude Opus 5.5
parent 4dd34ed251
commit e45d54d30b
133 changed files with 1854 additions and 436 deletions
+13 -12
View File
@@ -35,7 +35,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w
| DB admin UI | `bun run db:admin` |
| DB backup | `bun run db:backup` |
| Seed super admin user | `bun run seed` (runs `src/scripts/seed-super-admin.tsx`) |
| Seed sample bot crew | `bun run seed:bots [email]` (runs `src/scripts/seed-bots.ts`, idempotent: adds sample messages to the existing coordinator and the sample workers, skips users who already have workers) |
| Seed sample bot crew | `bun run seed:bots [email]` (runs `src/scripts/seed-bots.ts`, idempotent: adds sample messages to the existing coordinator and the sample workers, skips users who already created workers; assigns the sample workers to them) |
| Query DB (CLI) | `bun llm-code-assistant/query-db.ts "<sql>"` |
| Install | `bun install` |
@@ -85,23 +85,24 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
### Bots (the chat shell)
- **Tables:** `bots` (per user; `kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown)), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Threads and sessions:** a bot has one conversation of its own and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (ownership and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **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 the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's page (`(sections)/workspace-overview.tsx`: context, bots with their per-workspace instructions and memory, computers, delete, models) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All 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`, `create-bot`, `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 a session's unread when a message arrives in the open conversation; `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the user form's Bots and Workspaces pickers, `user-form-access.tsx`, saved by `save-user` through `setUserAccess`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. Live updates go to the session's user (`pushToSessionUser`), and a bot's face to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`, and on a workspace's page is sent to its first bot. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this.
- **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown)), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/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 the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's page (`(sections)/workspace-overview.tsx`: context, bots with their per-workspace instructions and memory, computers, delete, models) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All 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`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (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 a session's unread when a message arrives in the open conversation; `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **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; while a task runs it reads "<name> is working…" with the task's latest step. A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its hue still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less).
- **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 (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, then `# Memory in <name>` (`workspace_bots.memory`). In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it.
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`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 for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in <name>` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's.
- **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 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 active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`).
- **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. 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 header's computer in step across tabs. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace page, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`), all ownership-checked against the session user; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies).
- **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). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. Each user has one main computer, created 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 grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are 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-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Opening a computer:** the app serves each computer to its owner under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, ownership via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace page, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies).
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
- **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`).
- **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. `/admin/routines` lists them (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat.
@@ -145,9 +146,9 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
- Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), those three for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
## Page Directory Rules
+158 -26
View File
@@ -208,15 +208,8 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fields: [
{
fieldName: "user_id",
fieldDescription: `Bot owner`,
fieldDescription: `Who created the bot. Not a foreign key, and not access: bots belong to the install, reached through \`bot_users\` and \`workspace_users\``,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_user_fk",
},
},
{
fieldName: "name",
@@ -259,7 +252,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
{
fieldName: "memory",
fieldDescription: `What the bot must always remember, in markdown. The bot keeps it up to date`,
fieldDescription: `What the bot must always remember, in markdown, the same for every user it works with: never about one user (that's \`bot_user_memories\`). The bot keeps it up to date`,
dataType: "TEXT",
},
{
@@ -282,8 +275,20 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
{
tableName: "bot_sessions",
tableDescription: `A stretch of one conversation: a bot in its own chat (no workspace) or in one workspace. Only the active session's messages go to the model; closed ones can be searched and reopened`,
tableDescription: `A stretch of one user's conversation with a bot: in its own chat (no workspace) or in one workspace. Every user has their own. Only the active session's messages go to the model; closed ones can be searched and reopened`,
fields: [
{
fieldName: "user_id",
fieldDescription: `The user the conversation is with: only they see it, and the bot's runs in it work for them`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_session_user_fk",
},
},
{
fieldName: "bot_id",
fieldDescription: `The bot`,
@@ -351,6 +356,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
indexName: "idx_bot_sessions_workspace_id",
indexTableFields: ["workspace_id"],
},
{
indexName: "idx_bot_sessions_user_id",
indexTableFields: ["user_id", "bot_id", "workspace_id", "status"],
},
],
},
{
@@ -825,15 +834,8 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fields: [
{
fieldName: "user_id",
fieldDescription: `Workspace owner`,
fieldDescription: `Who created the workspace. Not a foreign key, and not access: workspaces belong to the install, reached through \`workspace_users\``,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "workspace_user_fk",
},
},
{
fieldName: "name",
@@ -925,6 +927,143 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
],
},
{
tableName: "workspace_users",
tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`,
fields: [
{
fieldName: "workspace_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "workspaces",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "workspace_users_workspace_fk",
},
},
{
fieldName: "user_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "workspace_users_user_fk",
},
},
],
uniqueConstraints: [
{
alias: "one_user_per_workspace",
constraintName: "A user joins a workspace once",
constraintTableFields: [
{ value: "workspace_id" },
{ value: "user_id" },
],
},
],
indexes: [
{
indexName: "idx_workspace_users_user_id",
indexTableFields: ["user_id"],
},
],
},
{
tableName: "bot_users",
tableDescription: `Worker bots assigned to a user (by a super admin or admin): the user chats with the bot in its own chat. Nobody but the super admin reaches the coordinator`,
fields: [
{
fieldName: "bot_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_users_bot_fk",
},
},
{
fieldName: "user_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_users_user_fk",
},
},
],
uniqueConstraints: [
{
alias: "one_user_per_bot",
constraintName: "A bot is assigned to a user once",
constraintTableFields: [
{ value: "bot_id" },
{ value: "user_id" },
],
},
],
indexes: [
{
indexName: "idx_bot_users_user_id",
indexTableFields: ["user_id"],
},
],
},
{
tableName: "bot_user_memories",
tableDescription: `What a bot remembers about one user (markdown). Only that user's runs see it, the way a workspace's memory stays in the workspace`,
fields: [
{
fieldName: "bot_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_user_memory_bot_fk",
},
},
{
fieldName: "user_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_user_memory_user_fk",
},
},
{
fieldName: "memory",
fieldDescription: `What the bot must remember about this user (markdown), after its core memory. The bot keeps it up to date`,
dataType: "TEXT",
},
],
uniqueConstraints: [
{
alias: "one_memory_per_bot_user",
constraintName: "A bot has one memory per user",
constraintTableFields: [
{ value: "bot_id" },
{ value: "user_id" },
],
},
],
indexes: [
{
indexName: "idx_bot_user_memories_user_id",
indexTableFields: ["user_id"],
},
],
},
{
tableName: "bot_message_media",
tableDescription: `Files attached to a chat message (\`media\` rows, private to the message's owner)`,
@@ -1039,15 +1178,8 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fields: [
{
fieldName: "user_id",
fieldDescription: `Computer owner`,
fieldDescription: `Who created the computer. Not a foreign key, and not access: computers belong to the install, reached through the bots that use them`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "computer_user_fk",
},
},
{
fieldName: "container_id",
+67 -5
View File
@@ -15,6 +15,9 @@ export const BunSQLiteTables = [
"cron_jobs",
"workspaces",
"workspace_bots",
"workspace_users",
"bot_users",
"bot_user_memories",
"bot_message_media",
"containers",
"computers",
@@ -160,7 +163,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
*/
updated_at?: number | "";
/**
* Bot owner
* Who created the bot. Not a foreign key, and not access: bots belong to the install, reached through `bot_users` and `workspace_users`
*/
user_id?: number | "";
name?: string;
@@ -189,7 +192,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
*/
system_prompt?: string;
/**
* What the bot must always remember, in markdown. The bot keeps it up to date
* What the bot must always remember, in markdown, the same for every user it works with: never about one user (that's `bot_user_memories`). The bot keeps it up to date
*/
memory?: string;
/**
@@ -215,6 +218,10 @@ export type BUN_SQLITE_COBBLE_BOT_SESSIONS = {
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* The user the conversation is with: only they see it, and the bot's runs in it work for them
*/
user_id?: number | "";
/**
* The bot
*/
@@ -543,7 +550,7 @@ export type BUN_SQLITE_COBBLE_WORKSPACES = {
*/
updated_at?: number | "";
/**
* Workspace owner
* Who created the workspace. Not a foreign key, and not access: workspaces belong to the install, reached through `workspace_users`
*/
user_id?: number | "";
name?: string;
@@ -588,6 +595,61 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
memory?: string;
}
export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = {
/**
* 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 | "";
workspace_id?: number | "";
user_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_USERS = {
/**
* 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 | "";
bot_id?: number | "";
user_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_USER_MEMORIES = {
/**
* 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 | "";
bot_id?: number | "";
user_id?: number | "";
/**
* What the bot must remember about this user (markdown), after its core memory. The bot keeps it up to date
*/
memory?: string;
}
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA = {
/**
* The unique identifier of the record.
@@ -675,7 +737,7 @@ export type BUN_SQLITE_COBBLE_COMPUTERS = {
*/
updated_at?: number | "";
/**
* Computer owner
* Who created the computer. Not a foreign key, and not access: computers belong to the install, reached through the bots that use them
*/
user_id?: number | "";
/**
@@ -1040,4 +1102,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_SESSIONS & 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_CRON_JOBS & 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_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & 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_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & 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_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
+5 -5
View File
@@ -9,10 +9,10 @@ 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 { BotReplyJobTitle, BotTaskStatusLine, 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 pushToSessionUser from "./push-to-session-user";
import pushBotNotice from "./push-bot-notice";
import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json";
@@ -80,8 +80,8 @@ export default async function processBotJob({
const row = await insertBotJobUpdate({ job_id, bot_id, ...described });
await pushToBotOwner({
bot_id,
await pushToSessionUser({
session_id: job.session_id,
data: {
event: "server:bot-job-update",
data: {
@@ -111,7 +111,7 @@ export default async function processBotJob({
bot: await setBotFace({
bot_id,
face: "working",
status_line: job.title || "",
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
}),
});
+11 -16
View File
@@ -2,8 +2,8 @@ import type {
BUN_SQLITE_COBBLE_BOT_MESSAGES,
BUN_SQLITE_COBBLE_BOTS,
} from "@/db/types/db";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import pushToBotOwner from "./push-to-bot-owner";
import pushBotUpdated from "./push-bot-updated";
import pushToSessionUser from "./push-to-session-user";
type Params = {
bot_id: number | string;
@@ -17,32 +17,27 @@ type Params = {
};
/**
* 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.
* Pushes a new bot message to the open tabs of the user whose
* conversation it's in, and a face change to everyone who sees the bot
* (`pushBotUpdated`, each with their own active task). A relayed report
* goes to the conversation it was posted in.
*/
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
if (message) {
await pushToBotOwner({
bot_id,
await pushToSessionUser({
session_id: message.session_id,
data: { event: "server:bot-message", data: message },
});
}
if (relay) {
await pushToBotOwner({
bot_id: relay.bot_id,
await pushToSessionUser({
session_id: relay.message.session_id,
data: { event: "server:bot-message", data: relay.message },
});
}
if (bot) {
await pushToBotOwner({
bot_id,
data: {
event: "server:bot-updated",
data: { ...bot, task: await grabActiveBotTask({ bot_id }) },
},
});
await pushBotUpdated({ bot: { ...bot, id: bot.id || Number(bot_id) } });
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id">;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the bot.
*/
users?: Set<string>;
};
/**
* Pushes `server:bot-updated` (face, status line, computer) to everyone
* who sees the bot (`grabBotAudience`), each with their own active task
* with it, never another user's.
*/
export default async function pushBotUpdated({ bot, users }: Params) {
if (!bot.id) return;
for (const user_id of await grabBotAudience({ bot_id: bot.id })) {
if (users && !users.has(String(user_id))) continue;
sendToUser({
user_id,
data: {
event: "server:bot-updated",
data: {
id: bot.id,
face: bot.face,
status_line: bot.status_line || "",
...(bot.computer_id !== undefined ? { computer_id: bot.computer_id || null } : {}),
task: await grabActiveBotTask({ bot_id: bot.id, user_id }),
},
},
});
}
}
-26
View File
@@ -1,26 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { WebSocketDataType } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot_id: number | string;
data: WebSocketDataType;
};
/**
* Live update to the bot owner's open tabs. Best effort: nothing is queued
* for offline users, who see the change on their next page load.
*/
export default async function pushToBotOwner({ bot_id, data }: Params) {
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT user_id FROM bots WHERE id = ? LIMIT 1`,
values: [bot_id],
})
).payload?.[0];
if (!bot?.user_id) return;
sendToUser({ user_id: bot.user_id, data });
}
+29
View File
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import type { WebSocketDataType } from "@/src/types";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
session_id?: number | string | null;
data: WebSocketDataType;
};
/**
* Live update to the open tabs of the user a session belongs to: only
* they see that conversation. Best effort: nothing is queued for offline
* users, who see the change on their next page load.
*/
export default async function pushToSessionUser({ session_id, data }: Params) {
if (!session_id) return;
const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [session_id],
})
).payload?.[0];
if (!session?.user_id) return;
sendToUser({ user_id: session.user_id, data });
}
+22 -4
View File
@@ -1,4 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import HarnessError from "@/src/harness/harness-error";
import BotJobFatalError from "./bot-job-fatal-error";
import runBotReply from "./run-bot-reply";
@@ -9,6 +12,9 @@ import type { BotJobRunner } from "./types";
* Does a job's actual work on the harness: a `reply` answers the user in
* chat (`runBotReply`), a `task` works through its brief (`runBotTask`).
*
* It works for the user whose conversation the job is in: their memory,
* and only what they can reach.
*
* Harness failures that waiting can fix (rate limits, outages) are thrown
* as they are, so the queue retries with backoff; the rest are fatal.
*/
@@ -16,16 +22,28 @@ const runBotJob: BotJobRunner = async (context) => {
const { job } = context;
const bot = job.bot_id ? await grabBotById({ bot_id: job.bot_id }) : null;
if (!bot?.id || !bot.user_id) {
if (!bot?.id) {
throw new BotJobFatalError(`The bot for this job no longer exists.`);
}
const owned = { ...bot, id: bot.id, user_id: bot.user_id };
const session = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [jobSessionId({ job })],
})
).payload?.[0];
if (!session?.user_id) {
throw new BotJobFatalError(`The conversation for this job no longer exists.`);
}
const owned = { ...bot, id: bot.id };
const user_id = session.user_id;
try {
return job.kind == "reply"
? await runBotReply({ ...context, bot: owned })
: await runBotTask({ ...context, bot: owned });
? await runBotReply({ ...context, bot: owned, user_id })
: await runBotTask({ ...context, bot: owned, user_id });
} catch (error: unknown) {
if (
error instanceof HarnessError &&
+9 -4
View File
@@ -20,7 +20,11 @@ import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number; user_id: number };
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
/**
* The user whose conversation the job is in.
*/
user_id: number;
};
/**
@@ -35,6 +39,7 @@ type Params = BotJobContext & {
*/
export default async function runBotReply({
bot,
user_id,
job,
signal,
report,
@@ -51,8 +56,8 @@ export default async function runBotReply({
return { result: "", silent: true };
}
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
/**
* A summary that can't be written now (no model free) is left out
@@ -69,7 +74,7 @@ export default async function runBotReply({
const tools = await grabBotChatTools({
bot,
user_id: bot.user_id,
user_id,
computers,
session_id,
workspace_id,
+9 -4
View File
@@ -14,7 +14,11 @@ import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
bot: { id: number; user_id: number };
bot: { id: number };
/**
* The user whose conversation the job is in.
*/
user_id: number;
};
/**
@@ -28,6 +32,7 @@ type Params = BotJobContext & {
*/
export default async function runBotTask({
bot,
user_id,
job,
params,
signal,
@@ -46,8 +51,8 @@ export default async function runBotTask({
try {
const session_id = await jobSessionId({ job });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
const carry_over = await grabSessionCarryOver({
session_id,
bot_id: bot.id,
@@ -97,7 +102,7 @@ export default async function runBotTask({
},
],
tools: [
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id, workspace_id, computers }),
...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers }),
...(computers.length
? [
searchComputersTool({ computers, workspace_id }),
+2 -2
View File
@@ -27,7 +27,7 @@ You are chatting with the user. Answer their latest messages directly and briefl
- Work that takes more than a moment, or anything on a computer (running commands, editing files, browsing): call start_task with a clear brief, then tell the user in one sentence, in the first person, what you're doing, e.g. "I'm going through your inbox now. I'll report back when I'm done." A task you start is your own work: never say you handed it to anyone else. The result is posted here when it's done.
- When a message mixes both, answer the quick parts in this reply and start a task for the rest.
- When only the user can do something on a computer (log in, solve a captcha, enter a code), ask them to take over with ask_user_to_take_over and say exactly what to do. They'll tell you when they hand it back; then carry on.
- When the user tells you something worth keeping, save it with update_memory. When they correct how you work, update your instructions with update_instructions.
- When the user tells you something worth keeping, save it with update_memory: about them in your memory of this user, never in your core memory, which every user you work for shares. When they correct how you work for everyone, update your instructions with update_instructions; a correction that's only their preference goes in your memory of them.
- Never claim you did something you didn't do.`;
/**
@@ -54,7 +54,7 @@ You are working through a task on your own; the user isn't watching. Use your to
*/
export const WorkspaceLearningNote = `# Learning in this workspace
update_memory and update_instructions save to this workspace by default: what you learn here applies here. Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. When something learned here looks useful everywhere, suggest saving it to your core and do it once the user agrees.`;
update_memory and update_instructions save to this workspace by default: what you learn here applies here. What you learn about the user that holds outside this workspace goes in your memory of them (update_memory, scope "user"). Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. Your core memory is shared with every user you work for, so nothing about this user goes there. When something learned here looks useful everywhere, suggest saving it and do it once the user agrees.`;
/**
* Asks the text model for a closed session's summary, carried into the
+7
View File
@@ -38,6 +38,13 @@ export type BotJobUpdateKind = (typeof BotJobUpdateKinds)[number]["value"];
*/
export const BotReplyJobTitle = "Thinking";
/**
* The bot's status line while a task runs. Everyone who sees the bot sees
* it, so it never names the task: each user sees their own task's title
* and steps in their conversation.
*/
export const BotTaskStatusLine = "Working on a task";
/**
* The bot's status line while its job waits for a rate-limited model.
*/
+4 -3
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import canListUsers from "@/src/utils/rbac/can-list-users";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
export type SettingsPage = {
title: string;
@@ -45,9 +46,9 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/change-password",
Icon: KeyRound,
},
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor },
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot, can_view: canManageCrew },
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, can_view: canManageCrew },
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
{
title: "Users",
url: "/admin/settings/users",
+4 -4
View File
@@ -7,17 +7,17 @@ export const UserTypes = [
{
value: "super_admin",
title: "Super admin",
description: "Runs the install: models, environment, computers and users. The first account.",
description: "Runs the install: the coordinator, models, environment, computers and users. The first account.",
},
{
value: "admin",
title: "Admin",
description: "Adds and manages members.",
description: "Manages members, bots and workspaces, and picks which bots and workspaces each member reaches.",
},
{
value: "member",
title: "Member",
description: "Has their own crew, workspaces and main computer.",
description: "Chats with the bots assigned to them and in the workspaces they were added to.",
},
{
value: "read_only",
@@ -27,7 +27,7 @@ export const UserTypes = [
{
value: "revoked",
title: "Revoked",
description: "Can't sign in. Their crew and files are kept.",
description: "Can't sign in. Their conversations and files are kept.",
},
] as const;
@@ -0,0 +1,20 @@
import AppError from "@/src/utils/app-error";
import grabCrewRole from "./grab-crew-role";
type Params = {
user_id: number | string;
};
/**
* Throws unless the user manages the crew (a super admin or an admin).
* Returns their role.
*/
export default async function assertCrewManager({ user_id }: Params) {
const role = await grabCrewRole({ user_id });
if (role == "member") {
throw new AppError(`Only an admin can do that.`);
}
return role;
}
@@ -0,0 +1,30 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
};
/**
* Every user who sees a bot (`grabUserBot`), for live updates about it:
* the super admin, and for a worker the admins, the users it's assigned
* to and the members of its workspaces.
*/
export default async function grabBotAudience({ bot_id }: Params): Promise<number[]> {
const res = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM user_types WHERE user_type = 'super_admin'
UNION
SELECT ut.user_id FROM user_types ut JOIN bots b ON b.id = ? AND b.kind != 'coordinator'
WHERE ut.user_type = 'admin'
UNION
SELECT bu.user_id FROM bot_users bu JOIN bots b ON b.id = bu.bot_id AND b.kind != 'coordinator'
WHERE bu.bot_id = ?
UNION
SELECT wu.user_id FROM workspace_users wu
JOIN workspace_bots wb ON wb.workspace_id = wu.workspace_id AND wb.bot_id = ?
JOIN workspaces w ON w.id = wu.workspace_id AND w.archived = 0
JOIN bots b ON b.id = wb.bot_id AND b.kind != 'coordinator'`,
values: [bot_id, bot_id, bot_id],
});
return (res.payload || []).map((row) => Number(row.user_id)).filter(Boolean);
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
computer_id: number | string;
};
/**
* Every user who sees a computer (`computersSql`), for live updates about
* it: super admins and admins, and the users whose workspaces or assigned
* workers use it.
*/
export default async function grabComputerAudience({ computer_id }: Params): Promise<number[]> {
const res = await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM user_types WHERE user_type IN ('super_admin', 'admin')
UNION
SELECT wu.user_id FROM workspace_users wu
JOIN workspace_computers wc ON wc.workspace_id = wu.workspace_id AND wc.computer_id = ?
UNION
SELECT bu.user_id FROM bot_users bu
JOIN bot_computers bc ON bc.bot_id = bu.bot_id AND bc.computer_id = ?
JOIN bots b ON b.id = bu.bot_id AND b.kind != 'coordinator'`,
values: [computer_id, computer_id],
});
return (res.payload || []).map((row) => Number(row.user_id)).filter(Boolean);
}
@@ -0,0 +1,19 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
/**
* Every worker and every workspace (not archived), for picking what a
* user may reach. Never the coordinator: it's the super admin's alone.
*/
export default async function grabCrewOptions() {
const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, name, role FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`,
}),
]);
return { crew_bots: bots.payload || [], crew_workspaces: workspaces.payload || [] };
}
@@ -0,0 +1,13 @@
import crewRole from "@/src/utils/rbac/crew-role";
import grabUserTypes from "../users/grab-user-types";
type Params = {
user_id: number | string;
};
/**
* A user's `crewRole`, read from their user types.
*/
export default async function grabCrewRole({ user_id }: Params) {
return crewRole({ user_types: await grabUserTypes({ user_id }) });
}
@@ -0,0 +1,41 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import grabUserBot from "../bots/grab-user-bot";
import grabUserWorkspace from "../workspaces/grab-user-workspace";
type Params = {
media_id: number | string;
user_id: number | string;
};
/**
* A bot's or workspace's picture (private media, uploaded by whoever set
* it) for a user who reaches that bot or workspace, or null. Bots and
* workspaces belong to the install, so their pictures show to everyone
* who sees them.
*/
export default async function grabSharedPictureMedia({ media_id, user_id }: Params) {
const [bot, workspace] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id FROM bots WHERE image_media_id = ? LIMIT 1`,
values: [media_id],
}).then((res) => res.payload?.[0]),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id FROM workspaces WHERE image_media_id = ? LIMIT 1`,
values: [media_id],
}).then((res) => res.payload?.[0]),
]);
const allowed =
(bot?.id && (await grabUserBot({ bot_id: bot.id, user_id }))?.id) ||
(workspace?.id && (await grabUserWorkspace({ workspace_id: workspace.id, user_id }))?.id);
if (!allowed) return null;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [media_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,49 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
/**
* Every worker assigned to them, replacing the old list. The
* coordinator is never assigned. Left out: unchanged.
*/
bot_ids?: number[];
/**
* Every workspace they're in, replacing the old list. Left out:
* unchanged.
*/
workspace_ids?: number[];
};
/**
* Sets which workers a user chats with in their own chat (`bot_users`)
* and which workspaces they work in (`workspace_users`). The caller checks
* the actor manages the crew. Ids that aren't a worker or a workspace are
* skipped.
*/
export default async function setUserAccess({ user_id, bot_ids, workspace_ids }: Params) {
const now = Date.now();
if (bot_ids) {
await BunSQLite.sql({ sql: `DELETE FROM bot_users WHERE user_id = ?`, values: [user_id] });
for (const bot_id of new Set(bot_ids)) {
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_users (created_at, updated_at, bot_id, user_id)
SELECT ?, ?, id, ? FROM bots WHERE id = ? AND kind = 'worker'`,
values: [now, now, user_id, bot_id],
});
}
}
if (workspace_ids) {
await BunSQLite.sql({ sql: `DELETE FROM workspace_users WHERE user_id = ?`, values: [user_id] });
for (const workspace_id of new Set(workspace_ids)) {
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO workspace_users (created_at, updated_at, workspace_id, user_id)
SELECT ?, ?, id, ? FROM workspaces WHERE id = ?`,
values: [now, now, user_id, workspace_id],
});
}
}
}
@@ -5,6 +5,10 @@ import type {
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -14,10 +18,11 @@ type Params = {
};
/**
* Starts an active session in a conversation. Callers close the old one
* Starts an active session in a user's conversation. Callers close the old one
* first (`startBotSession`).
*/
export default async function createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id,
@@ -25,6 +30,7 @@ export default async function createBotSession({
const now = Date.now();
const row: BUN_SQLITE_COBBLE_BOT_SESSIONS = {
user_id: Number(user_id),
bot_id: Number(bot_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
previous_session_id: previous_session_id || undefined,
@@ -6,6 +6,10 @@ import grabActiveBotSession from "./grab-active-bot-session";
import startBotSession from "./start-bot-session";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -17,16 +21,17 @@ type Params = {
};
/**
* The conversation's active session, started first when there is none
* The user's conversation's active session, started first when there is none
* (carrying on from its latest closed one) or, with `rollover`, when the
* active one is full.
*/
export default async function ensureBotSession({
user_id,
bot_id,
workspace_id,
rollover,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (active?.id) {
if (!rollover) return active;
@@ -43,18 +48,19 @@ export default async function ensureBotSession({
return count < AppData.BotSessionMaxMessages
? active
: await startBotSession({ bot_id, workspace_id });
: await startBotSession({ user_id, bot_id, workspace_id });
}
const latest = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ?
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ?
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
})
).payload?.[0];
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: latest?.id || null,
@@ -2,6 +2,10 @@ import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
/**
* Null or undefined: the bot's own chat.
@@ -10,18 +14,19 @@ type Params = {
};
/**
* The active session of one conversation (a bot in its own chat or in one
* workspace), or null when it has none yet.
* The active session of one user's conversation (with a bot in its own
* chat or in one workspace), or null when it has none yet.
*/
export default async function grabActiveBotSession({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT * FROM bot_sessions
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'
ORDER BY id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
});
return res.payload?.[0] || null;
@@ -3,6 +3,10 @@ import { AppData } from "@/src/data/app-data";
import type { BotSessionListItem } from "@/src/types";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -13,11 +17,12 @@ type Params = {
};
/**
* A conversation's sessions for the sessions modal, latest activity first,
* A user's conversation's sessions for the sessions modal, latest activity first,
* each with its message count and latest message. Empty sessions are left
* out unless active.
*/
export default async function grabBotSessions({
user_id,
bot_id,
workspace_id,
query,
@@ -33,7 +38,7 @@ export default async function grabBotSessions({
sql += ` (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,\n`;
sql += ` (SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text\n`;
sql += `FROM bot_sessions s\n`;
sql += `WHERE s.bot_id = ? AND s.workspace_id IS ?\n`;
sql += `WHERE s.user_id = ? AND s.bot_id = ? AND s.workspace_id IS ?\n`;
sql += `AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))\n`;
if (like) {
@@ -47,6 +52,7 @@ export default async function grabBotSessions({
const res = await BunSQLite.sql<BotSessionListItem>({
sql,
values: [
user_id,
bot_id,
workspace_id ? Number(workspace_id) : null,
...(like ? [like, like, like] : []),
@@ -24,7 +24,7 @@ type Params = {
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, its models, computers and active task there.
* Null when the bot isn't the user's or isn't in the workspace.
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationProps({
user_id,
@@ -38,7 +38,7 @@ export default async function grabConversationProps({
if (!conversation) return null;
const scope = conversation.workspace_id;
const session = await ensureBotSession({ bot_id: conversation.bot.id, workspace_id: scope });
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
@@ -71,7 +71,7 @@ export default async function grabConversationProps({
workspace_id: scope || undefined,
})
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id, workspace_id: scope }),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
]);
@@ -7,15 +7,14 @@ type Params = {
};
/**
* A session only if its bot belongs to the given owner.
* A session only if it's the given user's own.
*/
export default async function grabUserBotSession({
session_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT s.* FROM bot_sessions s JOIN bots b ON b.id = s.bot_id
WHERE s.id = ? AND b.user_id = ? LIMIT 1`,
sql: `SELECT s.* FROM bot_sessions s WHERE s.id = ? AND s.user_id = ? LIMIT 1`,
values: [session_id, user_id],
});
@@ -1,6 +1,6 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabChatBot from "../bots/grab-chat-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
type Params = {
@@ -19,9 +19,9 @@ export type UserConversation = {
};
/**
* One of the user's conversations: their bot, in its own chat or in one of
* their workspaces it works in. Throws when the bot isn't theirs or isn't
* in that workspace.
* One of the user's conversations: a bot they chat with in its own chat
* (`grabChatBot`) or in a workspace they're in (`grabWorkspaceBot`).
* Throws when they can't.
*/
export default async function grabUserConversation({
user_id,
@@ -42,7 +42,7 @@ export default async function grabUserConversation({
};
}
const bot = await grabUserBot({ bot_id, user_id });
const bot = await grabChatBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found`);
@@ -6,12 +6,12 @@ type Params = {
};
/**
* Clears a session's unread flag when its owner has the conversation open.
* Clears a session's unread flag when its user has the conversation open.
*/
export default async function markBotSessionRead({ session_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET unread = 0
WHERE id = ? AND unread = 1 AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)`,
WHERE id = ? AND unread = 1 AND user_id = ?`,
values: [session_id, user_id],
});
}
@@ -29,15 +29,15 @@ export default async function reopenBotSession({
const workspace_id = session.workspace_id || null;
await BunSQLite.sql({
sql: `DELETE FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
sql: `DELETE FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'
AND NOT EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = bot_sessions.id)`,
values: [session.bot_id!, workspace_id],
values: [session.user_id!, session.bot_id!, workspace_id],
});
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ?
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'`,
values: [now, session.bot_id!, workspace_id],
WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'`,
values: [now, session.user_id!, session.bot_id!, workspace_id],
});
await BunSQLite.sql({
@@ -4,6 +4,10 @@ import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
};
@@ -14,10 +18,11 @@ type Params = {
* An active session with no messages yet is kept as it is.
*/
export default async function startBotSession({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (active?.id) {
const has_messages = (
@@ -36,6 +41,7 @@ export default async function startBotSession({
}
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: active?.id || null,
@@ -9,7 +9,7 @@ type Params = {
};
/**
* Cancels one of the user's pending or running jobs. It's marked failed
* Cancels a pending or running job in one of the user's conversations. It's marked failed
* with `BotJobCancelledError`; a running handler finds out on its next
* `updateBotJob` (which returns false) and stops. Its task is closed.
*/
@@ -19,7 +19,7 @@ export default async function cancelBotJob({ user_id, job_id }: Params) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
sql: `UPDATE bots_queue SET status = 'failed', last_error = ?, locked_at = NULL, locked_by = NULL, finished_at = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'processing')
AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)
AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)
RETURNING *`,
values: [BotJobCancelledError, now, now, job_id, user_id],
});
@@ -115,7 +115,7 @@ export default async function enqueueBotJob({
}
if (computer_id) {
const computers = await grabBotComputers({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
if (!computers.some((c) => String(c.id) == String(computer_id))) {
throw new AppError(`This bot has no access to that computer.`);
@@ -1,21 +1,18 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
import AppError from "@/src/utils/app-error";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "bot_id" | "session_id" | "workspace_id">;
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "session_id">;
};
/**
* The session a job's result goes in: its own, or (for a job queued
* without one) the active session of its bot's conversation there.
* The session a job's result goes in. Every job is queued in one
* (`enqueueBotJob`), which says whose conversation it is.
*/
export default async function jobSessionId({ job }: Params) {
if (job.session_id) return Number(job.session_id);
export default function jobSessionId({ job }: Params) {
if (!job.session_id) {
throw new AppError(`This job has no conversation.`);
}
const session = await ensureBotSession({
bot_id: job.bot_id!,
workspace_id: job.workspace_id || null,
});
return Number(session.id);
return Number(job.session_id);
}
@@ -19,8 +19,8 @@ type Params = {
/**
* Reports a handed-over job back to the bot that handed it over
* (`requested_by_bot_id`, the coordinator): a message in the conversation
* it was handed over from (the active session there, which may have moved
* on since), with the worker's report, flagged needs-you when the job
* it was handed over from (that user's active session there, which may
* have moved on since), 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.
*/
@@ -48,14 +48,16 @@ export default async function relayJobToRequester({ job, text, failed }: Params)
const requested_from = job.requested_by_session_id
? (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT workspace_id FROM bot_sessions WHERE id = ? AND bot_id = ? LIMIT 1`,
sql: `SELECT user_id, workspace_id FROM bot_sessions WHERE id = ? AND bot_id = ? LIMIT 1`,
values: [job.requested_by_session_id, requester.id],
})
).payload?.[0]
: null;
const workspace_id = requested_from ? requested_from.workspace_id || null : job.workspace_id || null;
const session = await ensureBotSession({ bot_id: requester.id, workspace_id });
if (!requested_from?.user_id) return null;
const workspace_id = requested_from.workspace_id || null;
const session = await ensureBotSession({ user_id: requested_from.user_id, bot_id: requester.id, workspace_id });
const name = worker?.name || "A crew member";
const title = job.title || "the job";
+9 -1
View File
@@ -2,8 +2,12 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import type { BotHue } from "@/src/dict/bot-dict";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
/**
* Who creates it (a super admin or admin). Bots belong to the install.
*/
user_id: number | string;
name: string;
role?: string;
@@ -15,11 +19,15 @@ type Params = {
};
/**
* Adds a worker bot to the user's crew and returns its id. It has no
* Adds a worker bot to the install and returns its id. Nobody but super
* admins and admins reaches it until it's assigned to a user or added to
* a workspace. It has no
* computer of its own until it's given one; in a workspace it uses the
* workspace's.
*/
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
const res = await BunSQLite.insert<
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -14,6 +15,8 @@ type Params = {
* its row. The coordinator can't be deleted.
*/
export default async function deleteBot({ user_id, bot_id }: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
@@ -4,12 +4,16 @@ import { CoordinatorBotName } from "@/src/dict/bot-dict";
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
type Params = {
/**
* Who it's created by when it's missing (the super admin).
*/
user_id: number | string;
};
/**
* Returns the id of the user's coordinator, creating it first (with the
* default `CoordinatorSystemPrompt`) if the user has none. A
* Returns the id of the install's one coordinator (the super admin's
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
* if there is none. A
* coordinator without a prompt gets the default; an edited prompt is never
* overwritten. Safe to call on every visit.
*/
@@ -22,7 +26,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
table: "bots",
query: {
query: {
user_id: { value: user_id },
kind: { value: "coordinator" },
},
order: { field: "id", strategy: "ASC" },
@@ -12,21 +12,28 @@ type Params = {
* Only a task started from this session.
*/
session_id?: number | string | null;
/**
* Only a task in this user's conversations.
*/
user_id?: number | string | null;
};
/**
* The bot's most recent active task (in one session, when given), its
* The bot's most recent active task (in one session or one user's
* conversations, when given), its
* steps and what its job has done so far, for the working-on panel.
*/
export default async function grabActiveBotTask({
bot_id,
session_id,
user_id,
}: Params): Promise<BotTaskWithSteps | null> {
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active'
${session_id ? `AND session_id = ?` : ""}
${user_id ? `AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)` : ""}
ORDER BY created_at DESC, id DESC LIMIT 1`,
values: session_id ? [bot_id, session_id] : [bot_id],
values: [bot_id, ...(session_id ? [session_id] : []), ...(user_id ? [user_id] : [])],
});
const task = task_res.singleRes || task_res.payload?.[0];
@@ -1,10 +1,14 @@
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
import grabBotUserMemory from "./grab-bot-user-memory";
import grabUserBot from "./grab-user-bot";
type Params = {
bot_id: number | string;
/**
* The user the bot works with: only their memory joins the prompt.
*/
user_id: number | string;
/**
* The workspace the bot is working in. Its context and the bot's
@@ -15,8 +19,8 @@ type Params = {
/**
* The full prompt for one of the user's bots (`composeBotPrompt`): its
* core instructions and memory, and in a workspace that workspace's
* context and what the bot has for it.
* core instructions and memory, what it remembers about this user, and in
* a workspace that workspace's context and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -36,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
return composeBotPrompt({
system_prompt: bot.system_prompt,
memory: bot.memory,
user_memory: await grabBotUserMemory({ bot_id: bot.id, user_id }),
workspace: found?.workspace,
workspace_bot: found?.membership,
});
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_USER_MEMORIES } from "@/db/types/db";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* What a bot remembers about one user, or null when it has nothing yet.
* The caller checks the user may talk to the bot.
*/
export default async function grabBotUserMemory({ bot_id, user_id }: Params) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_USER_MEMORIES>({
sql: `SELECT memory FROM bot_user_memories WHERE bot_id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
});
return res.payload?.[0]?.memory || null;
}
+19 -17
View File
@@ -1,26 +1,33 @@
import type { BotWithLastMessage } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
/**
* A workspace's crew instead: the coordinator and the bots in it, with
* their conversations in that workspace.
* A workspace's crew instead: the bots the user chats with there
* (check they're in the workspace first), with their conversations
* there.
*/
workspace_id?: number | string | null;
};
/**
* A user's crew for the rail: the coordinator pinned first, then
* workers by latest activity. Each row carries its conversation's unread
* flag, latest activity and latest message: in its own chat, or in the
* workspace when one is given.
* The bots a user chats with, for the rail: in their own chat
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
* coordinator is pinned first, then workers by latest activity. Each row
* carries the user's own conversation there: its unread flag, latest
* activity and latest message.
*/
export default async function grabBots({
user_id,
workspace_id,
}: Params): Promise<BotWithLastMessage[]> {
const scope = workspace_id ? Number(workspace_id) : null;
const role = await grabCrewRole({ user_id });
const access = scope ? workspaceBotsSql({ role, workspace_id: scope }) : chatBotsSql({ role, user_id });
let sql = ``;
@@ -33,27 +40,22 @@ export default async function grabBots({
sql += `FROM bots b\n`;
sql += `LEFT JOIN (\n`;
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
sql += ` FROM bot_sessions WHERE workspace_id IS ? GROUP BY bot_id\n`;
sql += ` FROM bot_sessions WHERE workspace_id IS ? AND user_id = ? GROUP BY bot_id\n`;
sql += `) t ON t.bot_id = b.id\n`;
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
sql += ` SELECT id FROM bot_messages\n`;
sql += ` WHERE bot_id = b.id AND workspace_id IS ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` SELECT m.id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id\n`;
sql += ` WHERE m.bot_id = b.id AND m.workspace_id IS ? AND s.user_id = ?\n`;
sql += ` ORDER BY m.created_at DESC, m.id DESC\n`;
sql += ` LIMIT 1\n`;
sql += `)\n`;
sql += `WHERE b.user_id = ?\n`;
if (scope) {
sql += `AND (b.kind = 'coordinator' OR b.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?))\n`;
}
sql += `WHERE ${access.sql}\n`;
sql += `ORDER BY\n`;
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
sql += ` COALESCE(lm.created_at, t.last_activity_at, b.created_at) DESC\n`;
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [scope, scope, user_id, ...(scope ? [scope] : [])],
values: [scope, user_id, scope, user_id, ...access.values],
});
return res.payload || [];
@@ -0,0 +1,26 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot only if the user chats with it in its own chat (`chatBotsSql`).
*/
export default async function grabChatBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [bot_id, ...access.values],
});
return res.payload?.[0] || null;
}
+13 -3
View File
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
@@ -7,15 +9,23 @@ type Params = {
};
/**
* A bot only if it belongs to the given owner.
* A bot only if the user reaches it somewhere: in its own chat
* (`chatBotsSql`) or in a workspace they're in. Super admins and admins
* reach every worker; only the super admin reaches the coordinator.
*/
export default async function grabUserBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND (${access.sql} OR (b.kind != 'coordinator' AND b.id IN (
SELECT wb.bot_id FROM workspace_bots wb
JOIN workspace_users wu ON wu.workspace_id = wb.workspace_id AND wu.user_id = ?
JOIN workspaces w ON w.id = wb.workspace_id AND w.archived = 0
))) LIMIT 1`,
values: [bot_id, ...access.values, user_id],
});
return res.payload?.[0] || null;
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The whole new memory about this user (markdown), replacing the old one.
*/
memory: string;
};
/**
* Replaces what a bot remembers about one user. Only that user's runs see
* it; the bot's core memory stays the same for everyone.
*/
export default async function updateBotUserMemory({
user_id,
bot_id,
memory,
}: Params) {
assertPromptLength({ label: "Memory", text: memory });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO bot_user_memories (created_at, updated_at, bot_id, user_id, memory)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET updated_at = excluded.updated_at, memory = excluded.memory`,
values: [now, now, bot.id, user_id, memory],
});
if (!res.success) {
throw new Error(`Couldn't save the bot's memory: ${res.error}`);
}
}
@@ -3,6 +3,7 @@ import type { BotHue } from "@/src/dict/bot-dict";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -19,6 +20,8 @@ type Params = {
* change. Returns the saved bot.
*/
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
const bot = await grabUserBot({ bot_id, user_id });
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabUserComputer from "./grab-user-computer";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -18,6 +19,8 @@ export default async function addBotToComputer({
bot_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
const computer = await grabUserComputer({ computer_id, user_id });
@@ -10,11 +10,14 @@ import grabEnv from "../envs/grab-env";
import provisionComputer from "./provision-computer";
type Params = {
/**
* Who creates it. Computers belong to the install.
*/
user_id: number | string;
name: string;
description?: string;
/**
* `main` only for the owner's one main computer (`ensureMainComputer`).
* `main` only for the install's one main computer (`ensureMainComputer`).
*/
kind?: ComputerKind;
/**
@@ -29,7 +32,7 @@ type Params = {
};
/**
* Records a new computer for the user and its container
* Records a new computer and its container
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
* in the background (`provisionComputer`). Returns the computer at once,
* still `creating`, and the setup to await when the caller must (a CLI
@@ -10,9 +10,12 @@ import createComputer from "./create-computer";
import provisionComputer from "./provision-computer";
type Params = {
/**
* Who it's created by when it's missing (the super admin).
*/
user_id: number | string;
/**
* The owner's coordinator (from `ensureCoordinatorBot`). It always has
* The coordinator (from `ensureCoordinatorBot`). It always has
* access to the main computer.
*/
coordinator_id: number | string;
@@ -24,7 +27,7 @@ type Params = {
};
/**
* Returns the id of the user's main computer, creating it first if
* Returns the id of the install's one main computer, creating it first if
* missing (`createComputer`, which sets its container up in the
* background), and makes sure the coordinator has access to it. A main
* computer recorded before the app managed containers (no docker id, no
@@ -44,7 +47,6 @@ export default async function ensureMainComputer({
table: "computers",
query: {
query: {
user_id: { value: user_id },
kind: { value: "main" },
},
order: { field: "id", strategy: "ASC" },
@@ -3,7 +3,6 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
/**
* The workspace the bot is working in: then its computers are the
* workspace's, the default first, and nothing else.
@@ -15,27 +14,26 @@ type Params = {
* Every computer a bot can use where it's working. In a workspace, the
* workspace's computers (none linked: none at all), its default first. In
* its own chat, the ones it was given directly (`bot_computers`); the
* coordinator can use all of the owner's. There 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.
* coordinator can use all of them. There the one it works on now
* (`bots.computer_id`) comes first, else the main one, so `computers[0]`
* is where its tasks run by default. The same for every user it works
* for: callers check the user reaches the bot.
*/
export default async function grabBotComputers({
bot_id,
user_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
if (workspace_id) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id AND w.archived = 0
WHERE c.user_id = ?
AND (
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
JOIN workspaces w ON w.id = wc.workspace_id AND w.archived = 0
WHERE (
(SELECT kind FROM bots WHERE id = ?) = 'coordinator'
OR ? IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = w.id)
)
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id, user_id, bot_id, Number(bot_id)],
values: [workspace_id, bot_id, Number(bot_id)],
});
return res.payload || [];
@@ -43,14 +41,13 @@ export default async function grabBotComputers({
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
WHERE c.user_id = ?
AND (
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
WHERE (
(SELECT kind FROM bots WHERE id = ?) = 'coordinator'
OR c.id IN (SELECT computer_id FROM bot_computers WHERE 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],
values: [bot_id, bot_id, bot_id],
});
return res.payload || [];
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import computersSql from "@/src/utils/crew-access/computers-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
computer_id: number | string;
@@ -7,15 +9,17 @@ type Params = {
};
/**
* A computer only if it belongs to the given owner.
* A computer only if the user sees it (`computersSql`).
*/
export default async function grabUserComputer({
computer_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS | null> {
const access = computersSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT * FROM computers WHERE id = ? AND user_id = ? LIMIT 1`,
values: [computer_id, user_id],
sql: `SELECT c.* FROM computers c WHERE c.id = ? AND ${access.sql} LIMIT 1`,
values: [computer_id, ...access.values],
});
return res.payload?.[0] || null;
@@ -1,10 +1,12 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import type { ComputerWithStatus } from "@/src/types";
import computersSql from "@/src/utils/crew-access/computers-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
/**
* Every computer of this owner, main first.
* Every computer this user sees (`computersSql`), main first.
*/
user_id?: number | string;
/**
@@ -22,14 +24,18 @@ export default async function grabUserComputers({
}: Params): Promise<ComputerWithStatus[]> {
if (!user_id && !computer_id) return [];
const access = computer_id
? { sql: `c.id = ?`, values: [Number(computer_id)] }
: computersSql({ role: await grabCrewRole({ user_id: user_id! }), user_id: user_id! });
const rows =
(
await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
LEFT JOIN containers co ON co.id = c.container_id
WHERE ${computer_id ? `c.id = ?` : `c.user_id = ?`}
WHERE ${access.sql}
ORDER BY c.kind = 'main' DESC, c.id ASC`,
values: [computer_id || user_id!],
values: access.values,
})
).payload || [];
@@ -1,4 +1,5 @@
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
import grabComputerAudience from "../access/grab-computer-audience";
import grabUserComputers from "./grab-user-computers";
type Params = {
@@ -6,16 +7,15 @@ type Params = {
};
/**
* Pushes a computer's current state (`server:computer-updated`) to its
* owner's open tabs. Best effort.
* Pushes a computer's current state (`server:computer-updated`) to the
* open tabs of everyone who sees it (`grabComputerAudience`). Best effort.
*/
export default async function pushComputerUpdate({ computer_id }: Params) {
const computer = (await grabUserComputers({ computer_id }))[0];
if (!computer?.user_id) return;
if (!computer?.id) return;
sendToUser({
user_id: computer.user_id,
data: { event: "server:computer-updated", data: computer },
});
for (const user_id of await grabComputerAudience({ computer_id: computer.id })) {
sendToUser({ user_id, data: { event: "server:computer-updated", data: computer } });
}
}
@@ -3,6 +3,7 @@ import AppError from "@/src/utils/app-error";
import isLockedComputerAccess from "@/src/utils/is-locked-computer-access";
import grabUserBot from "../bots/grab-user-bot";
import grabUserComputer from "./grab-user-computer";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -19,6 +20,8 @@ export default async function removeBotFromComputer({
bot_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
const computer = await grabUserComputer({ computer_id, user_id });
@@ -22,7 +22,7 @@ export default async function setBotComputer({ bot_id, user_id, computer_id }: P
throw new AppError(`Bot not found.`);
}
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
const computers = await grabBotComputers({ bot_id: bot.id });
const computer = computers.find((c) => String(c.id) == String(computer_id));
if (!computer?.id) {
@@ -49,8 +49,8 @@ export default async function createCronJob({
const count = Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM cron_jobs WHERE bot_id = ?`,
values: [bot_id_num],
sql: `SELECT COUNT(*) AS n FROM cron_jobs WHERE bot_id = ? AND user_id = ?`,
values: [bot_id_num, user_id],
})
).payload?.[0]?.n || 0,
);
@@ -64,7 +64,6 @@ export default async function createCronJob({
if (computer_id) {
const computers = await grabBotComputers({
bot_id: bot_id_num,
user_id,
workspace_id: conversation.workspace_id,
});
@@ -48,6 +48,7 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
}
const session = await ensureBotSession({
user_id: cron_job.user_id!,
bot_id: cron_job.bot_id!,
workspace_id: cron_job.workspace_id || null,
});
@@ -57,7 +58,6 @@ export default async function fireCronJob({ cron_job, now = Date.now() }: Params
(
await grabBotComputers({
bot_id: cron_job.bot_id!,
user_id: cron_job.user_id!,
workspace_id: cron_job.workspace_id || null,
})
)[0]?.id;
@@ -8,25 +8,25 @@ import type { EnvsOverviewPayload } from "@/src/types";
import grabAllEnvs from "./grab-all-envs";
type Params = {
/**
* The super admin looking: workspaces and computers belong to the
* install, so they see every one.
*/
user_id: number;
};
/**
* The Environment settings page: where envs can be set (general, the
* user's workspaces and computers), every env (secret values left out),
* The Environment settings page: where envs can be set (general, every
* workspace and computer), every env (secret values left out),
* and the app settings with where each comes from.
*/
export default async function grabEnvsOverview({
user_id,
}: Params): Promise<EnvsOverviewPayload> {
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> {
const [workspaces, computers, envs] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name, archived FROM workspaces WHERE user_id = ? ORDER BY archived ASC, id ASC`,
values: [user_id],
sql: `SELECT id, name, archived FROM workspaces ORDER BY archived ASC, id ASC`,
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT id, name, kind FROM computers WHERE user_id = ? ORDER BY kind = 'main' DESC, id ASC`,
values: [user_id],
sql: `SELECT id, name, kind FROM computers ORDER BY kind = 'main' DESC, id ASC`,
}),
grabAllEnvs(),
]);
+3 -3
View File
@@ -156,14 +156,14 @@ async function resolveScope({
const owned = id
? (
await BunSQLite.sql({
sql: `SELECT id FROM ${table} WHERE id = ? AND user_id = ? LIMIT 1`,
values: [id, user_id],
sql: `SELECT id FROM ${table} WHERE id = ? LIMIT 1`,
values: [id],
})
).payload?.[0]
: undefined;
if (!owned) {
throw new AppError(`Pick one of your ${table}.`);
throw new AppError(`Pick one of the ${table}.`);
}
return paradigm == "workspace"
@@ -7,6 +7,7 @@ import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-w
import type { ProfileImageTarget } from "@/src/schemas/profile-image-schema";
import AppError from "@/src/utils/app-error";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -28,6 +29,8 @@ const Targets = {
* a private image, the old one deleted. Returns the new `image_media_id`.
*/
export default async function setProfileImage({ user_id, target, id, data_url }: Params) {
await assertCrewManager({ user_id });
const record =
target == "bot"
? await grabUserBot({ bot_id: id, user_id })
+9 -29
View File
@@ -1,7 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
import AppError from "@/src/utils/app-error";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import parseUserTypes from "@/src/utils/parse-user-types";
@@ -21,11 +19,12 @@ type Params = {
};
/**
* Deletes a user with everything that's theirs (bots, workspaces,
* conversations, computers, user types, password, media and its files):
* their computers' containers are removed first, their home directories
* stay on disk. Their audit log entries stay too. The super admin can't be
* deleted, and nobody deletes themselves.
* Deletes a user with everything that's theirs (their conversations,
* routines, access, what bots remember about them, user types, password,
* media and its files). Bots, workspaces and computers belong to the
* install and stay, pictures they uploaded for them too. Their audit log
* entries stay as well. The super admin can't be deleted, and nobody
* deletes themselves.
*/
export default async function deleteUser({ actor_user_id, actor_user_types, user_id }: Params) {
if (String(actor_user_id) == String(user_id)) {
@@ -48,24 +47,12 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
throw new AppError(`You can't delete ${user.first_name || "this user"}.`);
}
const containers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.user_id = ?`,
values: [user.id],
})
).payload || [];
for (const container of containers) {
if (container.docker_id || container.name) {
await removeDockerContainer({ container: container.docker_id || container.name! });
}
}
const media_ids =
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM media WHERE user_id = ?`,
sql: `SELECT id FROM media WHERE user_id = ?
AND id NOT IN (SELECT image_media_id FROM bots WHERE image_media_id IS NOT NULL)
AND id NOT IN (SELECT image_media_id FROM workspaces WHERE image_media_id IS NOT NULL)`,
values: [user.id],
})
).payload?.map((media) => media.id) || [];
@@ -87,12 +74,5 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
}
if (containers.length) {
await BunSQLite.sql({
sql: `DELETE FROM containers WHERE id IN (${containers.map(() => "?").join(", ")})`,
values: containers.map((container) => container.id!),
});
}
return user;
}
@@ -29,6 +29,8 @@ export default async function grabUsers(
sql += `SELECT\n`;
sql += ` u.*,\n`;
sql += ` ut_agg.user_types,\n`;
sql += ` (SELECT GROUP_CONCAT(bot_id) FROM bot_users WHERE user_id = u.id) AS bot_ids,\n`;
sql += ` (SELECT GROUP_CONCAT(workspace_id) FROM workspace_users WHERE user_id = u.id) AS workspace_ids,\n`;
/**
* Their profile photo only: other primary media (a bot's picture)
* must not stand in for it.
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -18,6 +19,8 @@ export default async function addBotToWorkspace({
workspace_id,
bot_id,
}: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserComputer from "../computers/grab-user-computer";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -19,6 +20,8 @@ export default async function addComputerToWorkspace({
workspace_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const computer = await grabUserComputer({ computer_id, user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
@@ -4,8 +4,13 @@ import type {
BunSQLiteTables,
} from "@/db/types/db";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
/**
* Who creates it (a super admin or admin). Workspaces belong to the
* install.
*/
user_id: number | string;
name: string;
description?: string;
@@ -13,7 +18,8 @@ type Params = {
};
/**
* Creates a workspace owned by the user and returns its id.
* Creates a workspace and returns its id. Members reach it once they're
* added to it (`workspace_users`).
*/
export default async function createWorkspace({
user_id,
@@ -21,6 +27,8 @@ export default async function createWorkspace({
description,
system_prompt,
}: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "System prompt", text: system_prompt });
const res = await BunSQLite.insert<
@@ -2,6 +2,7 @@ import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -13,6 +14,8 @@ type Params = {
* links and its models. Its bots and computers stay.
*/
export default async function deleteWorkspace({ user_id, workspace_id }: Params) {
await assertCrewManager({ user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
workspace_id: number | string;
@@ -7,15 +9,17 @@ type Params = {
};
/**
* A workspace only if it belongs to the given owner.
* A workspace only if the user works in it (`workspacesSql`).
*/
export default async function grabUserWorkspace({
workspace_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_WORKSPACES | null> {
const access = workspacesSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? LIMIT 1`,
values: [workspace_id, user_id],
sql: `SELECT w.* FROM workspaces w WHERE w.id = ? AND ${access.sql} LIMIT 1`,
values: [workspace_id, ...access.values],
});
return res.payload?.[0] || null;
@@ -1,25 +1,30 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { WorkspaceWithActivity } from "@/src/types";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
};
/**
* The user's workspaces for the rail, not archived, oldest first, each
* unread while any conversation in it is.
* The workspaces the user works in (`workspacesSql`), for the rail: not
* archived, oldest first, each unread while any of the user's
* conversations in it is.
*/
export default async function grabUserWorkspaces({
user_id,
}: Params): Promise<WorkspaceWithActivity[]> {
const access = workspacesSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<WorkspaceWithActivity>({
sql: `SELECT w.*,
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id), 0) AS unread,
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id) AS last_activity_at
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id AND s.user_id = ?), 0) AS unread,
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id AND s.user_id = ?) AS last_activity_at
FROM workspaces w
WHERE w.user_id = ? AND w.archived = 0
WHERE w.archived = 0 AND ${access.sql}
ORDER BY w.id ASC`,
values: [user_id],
values: [user_id, user_id, ...access.values],
});
return res.payload || [];
@@ -4,6 +4,9 @@ import type {
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
workspace_id: number | string;
@@ -22,29 +25,37 @@ export type WorkspaceBotMembership = {
};
/**
* One of the user's bots in one of their (non-archived) workspaces: a
* member, or the coordinator, which is in all of them. Null otherwise.
* A bot the user chats with in a workspace they're in (not archived): one
* of its workers, or for the super admin the coordinator, which is in all
* of them (`workspaceBotsSql`). Null otherwise.
*/
export default async function grabWorkspaceBot({
workspace_id,
bot_id,
user_id,
}: Params): Promise<WorkspaceBotMembership | null> {
const role = await grabCrewRole({ user_id });
const workspace_access = workspacesSql({ role, user_id });
const workspace = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? AND archived = 0 LIMIT 1`,
values: [workspace_id, user_id],
sql: `SELECT w.* FROM workspaces w WHERE w.id = ? AND w.archived = 0 AND ${workspace_access.sql} LIMIT 1`,
values: [workspace_id, ...workspace_access.values],
})
).payload?.[0];
if (!workspace?.id) return null;
const bot_access = workspaceBotsSql({ role, workspace_id: workspace.id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [bot_id, ...bot_access.values],
})
).payload?.[0];
if (!workspace?.id || !bot?.id) return null;
if (!bot?.id) return null;
const membership =
(
@@ -54,7 +65,5 @@ export default async function grabWorkspaceBot({
})
).payload?.[0] || null;
if (!membership && bot.kind != "coordinator") return null;
return { workspace, bot, membership };
}
@@ -16,7 +16,7 @@ export default async function grabWorkspaceComputers({
const res = await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id
JOIN workspaces w ON w.id = wc.workspace_id
LEFT JOIN containers co ON co.id = c.container_id
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id],
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -16,6 +17,8 @@ export default async function removeBotFromWorkspace({
workspace_id,
bot_id,
}: Params) {
await assertCrewManager({ user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -18,6 +19,8 @@ export default async function removeComputerFromWorkspace({
workspace_id,
computer_id,
}: Params) {
await assertCrewManager({ user_id });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
@@ -7,6 +7,7 @@ import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserWorkspace from "./grab-user-workspace";
import _ from "lodash";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -29,6 +30,8 @@ export default async function updateWorkspace({
workspace_id,
data,
}: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "System prompt", text: data.system_prompt });
const workspace = await grabUserWorkspace({ workspace_id, user_id });
@@ -4,6 +4,7 @@ import grabDirNames from "@/src/utils/grab-dir-names";
import { existsSync } from "node:fs";
import path from "node:path";
import userAuth from "../../backend/auth/user-auth";
import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media";
const { DATA_DIR } = grabDirNames();
@@ -48,7 +49,10 @@ export default async function handleMediaServer({
targetId: media_id,
});
media = media_res.singleRes || undefined;
media =
media_res.singleRes ||
(media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) ||
undefined;
} else {
const media_res = await BunSQLite.select<
BUN_SQLITE_COBBLE_MEDIA,
@@ -62,7 +66,8 @@ export default async function handleMediaServer({
/**
* A private file is only served through `/media/private`, to its
* owner.
* owner (or, for a bot's or workspace's picture, to whoever sees
* it).
*/
if (media?.is_private) {
media = undefined;
+2 -2
View File
@@ -71,8 +71,8 @@ export default function delegateTaskTool({
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
}
const computers = await grabBotComputers({ bot_id: worker.id, user_id, workspace_id });
const worker_session = await ensureBotSession({ bot_id: worker.id, workspace_id });
const computers = await grabBotComputers({ bot_id: worker.id, workspace_id });
const worker_session = await ensureBotSession({ user_id, bot_id: worker.id, workspace_id });
const job_id = await enqueueBotJob({
user_id,
+6 -6
View File
@@ -43,8 +43,8 @@ type Params = {
* in a workspace switching its default computer), start_task for longer
* work, search_computers to find what's on its computers,
* ask_user_to_take_over when only the user can do something on one, and
* for the coordinator delegate_task (to the workspace's crew in a
* workspace) plus, in its own chat, assign_computer (which computers each
* for the coordinator (the super admin's alone) delegate_task to every
* worker (the workspace's in a workspace) plus, in its own chat, assign_computer (which computers each
* crew member can use) or, in a workspace, set_bot_workspace_instructions.
* Anything else on a computer goes through a task, never mid-reply.
*/
@@ -88,9 +88,9 @@ export default async function grabBotChatTools({
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: workspace_id
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
WHERE b.user_id = ? AND b.kind = 'worker' ORDER BY b.id ASC`
: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
values: workspace_id ? [workspace_id, user_id] : [user_id],
WHERE b.kind = 'worker' ORDER BY b.id ASC`
: `SELECT * FROM bots WHERE kind = 'worker' ORDER BY id ASC`,
values: workspace_id ? [workspace_id] : [],
})
).payload || [];
@@ -117,7 +117,7 @@ export default async function grabBotChatTools({
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
await grabBotComputers({ bot_id: worker.id || 0 }),
]),
),
);
@@ -34,8 +34,8 @@ export default function updateInstructionsTool({
return {
name: "update_instructions",
description: workspace_id
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Both are shared: the workspace's with everyone working in it, your core ones with every user you work for. Change them only for how you should work for all of them, such as a standing rule for your job; a preference of this one user goes in your memory of them (update_memory, scope "user"). Keep everything that still applies.`
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory, shared with every user you work for. Change them only for how you should work for every user you work for, such as a standing rule for your job; a preference of this one user goes in your memory of them (update_memory, scope "user"). Keep everything that still applies.`,
schema: workspace_id
? z.object({ instructions, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ instructions: instructions.min(1) }),
+27 -11
View File
@@ -1,27 +1,38 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import updateBotMemory from "@/src/functions/backend/db/bots/update-bot-memory";
import updateBotUserMemory from "@/src/functions/backend/db/bots/update-bot-user-memory";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
/**
* The user the run is for: `user` memory is theirs alone.
*/
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: then the bot rewrites its memory for
* the workspace unless it picks `core`.
* the workspace unless it picks another scope.
*/
workspace_id?: number | string | null;
};
type Args = {
memory: string;
scope?: "workspace" | "core";
scope?: "user" | "workspace" | "core";
};
const UserScopeNote = `what you remember about this user, under "# Memory about the user": their preferences, facts about them and their work, decisions they made. Only runs for this user see it.`;
const CoreScopeNote = `scope "core" is your core memory, under "# Memory", shared with every user you work for: only general knowledge about your job that holds for anyone, never anything about a particular user.`;
const ReplaceNote = `Anything you leave out is forgotten, so include the parts that still matter.`;
/**
* Lets a bot rewrite its own memory: its core memory (under `# Memory`),
* or in a workspace (by default) its memory for that workspace.
* Lets a bot rewrite one of its memories: about the user it works for
* (the default outside a workspace), for the workspace (the default in
* one), or its core memory, shared by every user.
*/
export default function updateMemoryTool({
user_id,
@@ -33,19 +44,24 @@ export default function updateMemoryTool({
return {
name: "update_memory",
description: workspace_id
? `Replace a memory with this full text (markdown). scope "workspace" (the default) is your memory for this workspace, under "# Memory in ..."; "core" is your core memory, under "# Memory", in every workspace and your own chat. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`
: `Replace your memory with this full text (markdown). It's in your prompt under "# Memory" on every run. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`,
? `Replace a memory with this full text (markdown). scope "workspace" (the default) is your memory for this workspace, under "# Memory in ...": what holds for the work here. scope "user" is ${UserScopeNote} ${CoreScopeNote} ${ReplaceNote}`
: `Replace a memory with this full text (markdown). scope "user" (the default) is ${UserScopeNote} ${CoreScopeNote} ${ReplaceNote}`,
schema: workspace_id
? z.object({ memory, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ memory }),
? z.object({ memory, scope: z.enum(["workspace", "user", "core"]).default("workspace") })
: z.object({ memory, scope: z.enum(["user", "core"]).default("user") }),
run: async ({ args }) => {
if (workspace_id && args.scope != "core") {
if (args.scope == "core") {
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Core memory saved.`;
}
if (workspace_id && args.scope == "workspace") {
await updateWorkspaceBot({ user_id, workspace_id, bot_id, memory: args.memory });
return `Memory for this workspace saved.`;
}
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Core memory saved.`;
await updateBotUserMemory({ user_id, bot_id, memory: args.memory });
return `Memory about the user saved.`;
},
};
}
@@ -1,14 +1,19 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import { Plus } from "lucide-react";
import { AddFlowContext } from "../(data)/add-flow-context";
/**
* The rail header's Add icon: opens the Add modal (a bot, a workspace or a
* computer).
* computer), for super admins and admins.
*/
export default function RailAddButton() {
const { openAdd } = useContext(AddFlowContext);
const { pageProps } = useContext(AppContext);
if (!canManageCrew({ user_types: pageProps?.user_types })) return null;
return (
<Button
@@ -1,5 +1,7 @@
import { useContext } from "react";
import { AddFlowContext } from "../(data)/add-flow-context";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import CoordinatorRow from "./coordinator-row";
import RailBotsSection from "./rail-bots-section";
@@ -18,6 +20,7 @@ type Props = {
*/
export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
const { pageProps } = useContext(AppContext);
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
@@ -36,7 +39,11 @@ export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
workers={workers}
selected_id={selected_id}
hrefFor={(bot) => `/admin/bots/${bot.id}`}
empty="No bots yet. Add one for a job you hand out often."
empty={
canManageCrew({ user_types: pageProps?.user_types })
? "No bots yet. Add one for a job you hand out often."
: "No bots for you yet. An admin assigns them."
}
/>
<RailWorkspacesSection workspaces={workspaces} />
</>
@@ -1,4 +1,7 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import { Plus } from "lucide-react";
type Props = {
@@ -7,9 +10,14 @@ type Props = {
};
/**
* The "+" beside a rail section's label.
* The "+" beside a rail section's label, for super admins and admins (who
* add bots and workspaces).
*/
export default function RailSectionAddButton({ title, onClick }: Props) {
const { pageProps } = useContext(AppContext);
if (!canManageCrew({ user_types: pageProps?.user_types })) return null;
return (
<Button
title={title}
@@ -51,7 +51,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
workers={workers}
selected_id={on_workspace_page ? undefined : selected_id}
hrefFor={(bot) => `${base}/bots/${bot.id}`}
empty="No bots here yet. Add one, or put an existing bot in from the workspace's page."
empty="No bots here yet."
/>
</>
);
+2 -2
View File
@@ -10,8 +10,8 @@ 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
* changed bots from the database and pushes them to the sockets here
* of the users they're for. 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.
+8 -22
View File
@@ -1,8 +1,6 @@
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";
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
type Params = {
/**
@@ -13,35 +11,23 @@ type Params = {
};
/**
* 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.
* Pushes `server:bot-updated` (face, status line, the user's own active
* task) for every bot that changed since `since` to the connected users
* who see it (`pushBotUpdated`). Pushes are idempotent, so overlapping
* windows only repeat them.
*/
export default async function relayChangedBots({ since, users }: Params) {
if (!users.size) return;
const ids = [...users];
const bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, user_id, face, status_line, computer_id FROM bots
WHERE updated_at > ? AND user_id IN (${ids.map(() => "?").join(", ")})`,
values: [since, ...ids],
sql: `SELECT id, face, status_line, computer_id FROM bots WHERE updated_at > ?`,
values: [since],
})
).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 } });
await pushBotUpdated({ bot, users });
}
}
+2 -2
View File
@@ -9,13 +9,13 @@ type Params = {
/**
* Pushes `server:bot-message` for every message newer than `after_id` to
* its owner's sockets on this process. Returns the newest id read.
* the sockets on this process of the user whose conversation it's in. Returns the newest id read.
*/
export default async function relayNewBotMessages({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES & { user_id: number }>({
sql: `SELECT m.*, b.user_id FROM bot_messages m JOIN bots b ON b.id = m.bot_id
sql: `SELECT m.*, s.user_id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE m.id > ? ORDER BY m.id ASC LIMIT 500`,
values: [after_id],
})
+4 -3
View File
@@ -9,15 +9,16 @@ type Params = {
/**
* 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.
* to the sockets on this process of the user whose conversation the job
* is in. Returns the newest id read.
*/
export default async function relayNewJobUpdates({ after_id, users }: Params) {
const rows =
(
await BunSQLite.sql<BotJobUpdateData & { user_id: number }>({
sql: `SELECT u.*, q.task_id, q.session_id, b.user_id FROM bots_queue_updates u
sql: `SELECT u.*, q.task_id, q.session_id, s.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
JOIN bot_sessions s ON s.id = q.session_id
WHERE u.id > ? ORDER BY u.id ASC LIMIT 500`,
values: [after_id],
})
@@ -0,0 +1,14 @@
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
/**
* Shown to a user who reaches no bot yet: an admin assigns them bots or
* adds them to a workspace.
*/
export default function NoBotsSection() {
return (
<SettingsShell title="Bots" description="The bots you chat with.">
<EmptyContent title="No bots for you yet. Ask an admin to assign you a bot or add you to a workspace." />
</SettingsShell>
);
}
@@ -27,9 +27,10 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
}
/**
* The latest step of the open bot's active task (a `bots_queue_updates`
* row's text), kept current from `server:bot-job-update`. Starts over when
* `server:bot-updated` brings a different task, or none.
* The open session's active task and its latest step (a
* `bots_queue_updates` row's text), kept current from
* `server:bot-job-update`. Starts over when `server:bot-updated` brings a
* different task, or none.
*/
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
@@ -49,11 +50,15 @@ export default function useTaskProgress({ bot_id, session_id, task }: Params) {
onData: (data: BotUpdatedData) => {
if (data.id != bot_id) return;
setProgress((prev) =>
(data.task?.id || null) == prev.task_id ? prev : fromTask({ task: data.task }),
);
/**
* The user's active task with the bot may be in another of
* their conversations with it.
*/
const task = data.task && (!session_id || data.task.session_id == session_id) ? data.task : null;
setProgress((prev) => ((task?.id || null) == prev.task_id ? prev : fromTask({ task })));
},
});
return progress.text;
return progress;
}
@@ -7,6 +7,7 @@ import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form";
import HeaderIconButton from "./header-icon-button";
@@ -24,8 +25,9 @@ type Props = {
/**
* The header's model icon: opens every model the bot uses (text, media
* reading, transcription, speech, images) in the Models settings form. On
* the coordinator that form is the defaults, for super admins only;
* everyone else gets the icon disabled, its title saying why.
* the coordinator that form is the defaults, for super admins only; a
* worker's (or a workspace's) models are for super admins and admins.
* Everyone else gets the icon disabled, its title saying why.
*/
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
@@ -35,9 +37,9 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
const in_workspace = Boolean(workspace?.id);
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
const can_edit =
!is_coordinator ||
isPassThroughUser({ user_types: pageProps?.user_types });
const can_edit = is_coordinator
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
const name = bot.name || "This bot";
const target = (
@@ -50,7 +52,9 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
: is_coordinator
? "Change the default models"
: `Change ${name}'s models`
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
: is_coordinator
? `${name} uses the default models. A super admin can change them in Settings, under Models.`
: `An admin can change the models ${name} uses.`
}
disabled={!can_edit}
/>
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
@@ -8,6 +8,8 @@ import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
type Props = {
bot: BotWithLastMessage;
@@ -15,12 +17,27 @@ type Props = {
/**
* The header's avatar: opens the bot's profile (picture, name, job,
* colour, instructions) to edit.
* colour, instructions) to edit, for super admins and admins. Everyone
* else sees the avatar alone.
*/
export default function BotProfileModal({ bot }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const name = bot.name || "This bot";
const avatar = (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
);
if (!canManageCrew({ user_types: pageProps?.user_types })) {
return <Stack className="shrink-0">{avatar}</Stack>;
}
return (
<Modal
target={
@@ -32,12 +49,7 @@ export default function BotProfileModal({ bot }: Props) {
aria-label={`Edit ${name}`}
className="shrink-0 p-0! rounded-default hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
{avatar}
</Button>
}
targetWrapperProps={{ className: "shrink-0" }}
@@ -12,18 +12,29 @@ type Props = {
* The latest step of the bot's active task.
*/
progress?: string | null;
/**
* The thread's last message is the user's, so a reply is due here.
*/
awaiting_reply?: boolean;
/**
* This session has an active task.
*/
has_task?: boolean;
};
/**
* Under the last message while the bot is working on a reply or a task
* (or waiting for a model to come back), so slow work doesn't look like
* none. A task shows its latest step, else its title.
* none. A task shows its latest step. The bot's face is shared by
* everyone who sees it, so the row only shows for work in this
* conversation: a reply while the user has the last word, or its own
* task.
*/
export default function BotThinkingRow({ bot, progress }: Props) {
export default function BotThinkingRow({ bot, progress, awaiting_reply, has_task }: Props) {
const name = bot.name || "The bot";
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
const working = bot.face == "working" && !thinking;
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle && Boolean(awaiting_reply);
const working = bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task);
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
if (!thinking && !working && !waiting) return null;
@@ -32,7 +43,7 @@ export default function BotThinkingRow({ bot, progress }: Props) {
: working
? `${name} is working…`
: `${BotWaitingStatusLine}…`;
const detail = working ? progress || bot.status_line : null;
const detail = working ? progress : null;
return (
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
@@ -14,6 +14,10 @@ type Props = {
* The latest step of the bot's active task, shown while it works.
*/
progress?: string | null;
/**
* This session has an active task.
*/
has_task?: boolean;
};
/**
@@ -22,7 +26,7 @@ type Props = {
* While the bot works on a reply or a task, a status row sits under the
* last one.
*/
export default function MessageThread({ bot, messages, progress }: Props) {
export default function MessageThread({ bot, messages, progress, has_task }: Props) {
const groups = groupMessages({ messages });
return (
@@ -40,7 +44,12 @@ export default function MessageThread({ bot, messages, progress }: Props) {
group={group}
/>
))}
<BotThinkingRow bot={bot} progress={progress} />
<BotThinkingRow
bot={bot}
progress={progress}
awaiting_reply={messages[messages.length - 1]?.author == "user"}
has_task={has_task}
/>
</Stack>
</Stack>
);
@@ -106,7 +106,8 @@ export default function Conversation() {
<MessageThread
bot={live_bot}
messages={messages}
progress={progress}
progress={progress.text}
has_task={Boolean(progress.task_id)}
/>
<Composer
bot={bot}
@@ -3,15 +3,17 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* A worker bot's model settings. The coordinator always uses the defaults,
* so its page goes back to its conversation.
* A worker bot's model settings, for super admins and admins. The
* coordinator always uses the defaults, so its page (and anyone else's)
* goes back to the conversation.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req });
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.id) {
return { redirect: { destination: "/auth/login" } };
@@ -24,7 +26,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return { redirect: { destination: "/admin/bots" } };
}
if (bot.kind == "coordinator") {
if (bot.kind == "coordinator" || !canManageCrew({ user_types })) {
return { redirect: { destination: `/admin/bots/${bot.id}` } };
}
+28 -10
View File
@@ -1,15 +1,20 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType } from "@/src/types";
import crewRole from "@/src/utils/rbac/crew-role";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/**
* Opens the coordinator's conversation, creating the coordinator and the
* main computer first if this account doesn't have them yet.
* Opens a conversation. The super admin lands on the coordinator, which
* (with the main computer) is created first if the install has none.
* Anyone else lands on the latest bot they chat with, else their first
* workspace; with neither, the page says to ask an admin.
*/
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user } = await userAuth({ req });
const { user, user_types } = await userAuth({ req });
if (!user?.id) {
return {
@@ -19,12 +24,25 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
};
}
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
await ensureMainComputer({ user_id: user.id, coordinator_id });
if (crewRole({ user_types }) == "super_admin") {
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
await ensureMainComputer({ user_id: user.id, coordinator_id });
return {
redirect: {
destination: `/admin/bots/${coordinator_id}`,
},
};
return { redirect: { destination: `/admin/bots/${coordinator_id}` } };
}
const [bots, workspaces] = await Promise.all([
grabBots({ user_id: user.id }),
grabUserWorkspaces({ user_id: user.id }),
]);
if (bots[0]?.id) {
return { redirect: { destination: `/admin/bots/${bots[0].id}` } };
}
if (workspaces[0]?.id) {
return { redirect: { destination: `/admin/workspaces/${workspaces[0].id}` } };
}
return { props: { bots, workspaces } };
};
+4 -2
View File
@@ -1,11 +1,13 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import NoBotsSection from "./(sections)/no-bots-section";
/**
* Never rendered: `index.server.ts` always redirects to the coordinator.
* Only rendered for a user who reaches no bot yet: `index.server.ts`
* otherwise redirects to a conversation.
*/
export default function BotsPage() {
return null;
return <NoBotsSection />;
}
export const meta: BunextPageModuleMeta = {
@@ -20,6 +20,7 @@ export default async function submitUserForm({
setLoading,
setStatus,
onSaved,
app_context,
}: Params) {
if (loading) return;
@@ -44,6 +45,13 @@ export default async function submitUserForm({
confirm_password: form.confirm_password,
user_types: form.user_types,
image: image?.imageBase64Full,
/**
* Only where the form offers them (`UserFormAccess`), so a
* form without them never clears them.
*/
...(app_context.pageProps?.crew_bots
? { bot_ids: form.bot_ids || [], workspace_ids: form.workspace_ids || [] }
: {}),
},
});
@@ -4,6 +4,7 @@ import useFormInit from "@/src/hooks/use-form-init";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseIdList from "@/src/utils/parse-id-list";
import parseUserTypes from "@/src/utils/parse-user-types";
import submitUserForm from "../../(functions)/submit-user-form";
import UserFormName from "./user-form-name";
@@ -11,6 +12,7 @@ import UserFormAction from "./user-form-action";
import UserFormEmail from "./user-form-email";
import UserFormUsername from "./user-form-username";
import UserFormUserTypes from "./user-form-user-types";
import UserFormAccess from "./user-form-access";
import UserFormImage from "./user-form-image";
import UserFormPassword from "./user-form-password";
@@ -27,8 +29,9 @@ type Props = {
};
/**
* A user's photo, name, email, username, user types and password, saved
* in one go through `users/save-user`.
* A user's photo, name, email, username, user types, what they reach
* (bots and workspaces) and password, saved in one go through
* `users/save-user`.
*/
export default function UserForm({ person, onSaved, onCancel }: Props) {
const init = useFormInit<UserFormObject>({
@@ -41,6 +44,8 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
user_types: parseUserTypes({ user_types: person.user_types }).filter(
(type) => !UneditableUserTypes.includes(type),
),
bot_ids: parseIdList({ ids: person.bot_ids }),
workspace_ids: parseIdList({ ids: person.workspace_ids }),
}
: undefined,
existing_full: person ? { id: person.id } : undefined,
@@ -56,6 +61,7 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
<UserFormUsername {...init} />
</Row>
<UserFormUserTypes {...init} person={person} />
<UserFormAccess {...init} person={person} />
<UserFormPassword {...init} />
<UserFormAction {...init} onCancel={onCancel} />
</Form>
@@ -0,0 +1,68 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import Stack from "@moduletrace/twui/layout/Stack";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
};
/**
* Picks the workers a user chats with and the workspaces they work in.
* Shown where the page carries the crew (`crew_bots`, `crew_workspaces`),
* and only for a user who'd reach nothing without it: super admins and
* admins reach every worker and workspace anyway.
*/
export default function UserFormAccess({ setForm, form, person, app_context }: Props) {
const bots = app_context.pageProps?.crew_bots;
const workspaces = app_context.pageProps?.crew_workspaces;
if (!bots || !workspaces) return null;
const types = [...parseUserTypes({ user_types: person?.user_types }), ...(form.user_types || [])];
if (types.includes("super_admin") || types.includes("admin")) return null;
return (
<Stack className="w-full gap-4! items-stretch">
<SearchSelect
id="user-form-bots"
title="Bots"
label="Bots"
showLabel
multiple
placeholder="Pick the bots they chat with"
options={bots.map((bot) => ({
value: String(bot.id),
title: bot.role ? `${bot.name} (${bot.role})` : bot.name || `Bot ${bot.id}`,
}))}
defaultValues={(form.bot_ids || []).map(String)}
info="They chat with these in their own chat. They reach no bot until one is picked here or they're in a workspace."
changeHandler={(value) => {
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
setForm((prev) => ({ ...prev, bot_ids: ids }));
}}
/>
<SearchSelect
id="user-form-workspaces"
title="Workspaces"
label="Workspaces"
showLabel
multiple
placeholder="Pick the workspaces they work in"
options={workspaces.map((workspace) => ({
value: String(workspace.id),
title: workspace.name || `Workspace ${workspace.id}`,
}))}
defaultValues={(form.workspace_ids || []).map(String)}
info="They chat with every bot in these workspaces, there."
changeHandler={(value) => {
const ids = (Array.isArray(value) ? value : value ? [value] : []).map(Number);
setForm((prev) => ({ ...prev, workspace_ids: ids }));
}}
/>
</Stack>
);
}

Some files were not shown because too many files have changed in this diff Show More