Give every user their own bot face

A bot's face and status line live in bot_user_faces, one row per bot and
user, set by jobs for the user whose conversation they're in. Needs-you
and working only show to that user; every bot read overlays the viewer's
face (botFaceSql) and pushes go to the user a face change is for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 13:58:26 +01:00
co-authored by Claude Opus 5.5
parent 47c58bc76b
commit ab25158d87
32 changed files with 448 additions and 181 deletions
+4 -4
View File
@@ -85,8 +85,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
### Bots (the chat shell)
- **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`.
- **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. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and 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`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `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 dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) 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.
@@ -97,9 +97,9 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **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 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:** 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 for the job's user), `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 bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates`, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, 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`).
+60 -12
View File
@@ -233,18 +233,6 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldDescription: `The bot's job in one line. Null for the coordinator`,
dataType: "TEXT",
},
{
fieldName: "face",
fieldDescription: `The bot's status, shown as its face`,
dataType: "TEXT",
options: BotFaces.map((bf) => bf.value),
defaultValue: "idle",
},
{
fieldName: "status_line",
fieldDescription: `Short status sentence, e.g. "Waiting on you"`,
dataType: "TEXT",
},
{
fieldName: "system_prompt",
fieldDescription: `The bot's core instructions, in markdown. The bot can rewrite it`,
@@ -1064,6 +1052,66 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
],
},
{
tableName: "bot_user_faces",
tableDescription: `A bot's face (its status) and status line as one user sees it, set by the bot's jobs in that user's conversations. No row means idle. Other users never see it, so "needs you" only shows to the user it's for`,
fields: [
{
fieldName: "bot_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_user_face_bot_fk",
},
},
{
fieldName: "user_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "users",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_user_face_user_fk",
},
},
{
fieldName: "face",
fieldDescription: `The bot's status for this user, shown as its face`,
dataType: "TEXT",
options: BotFaces.map((bf) => bf.value),
defaultValue: "idle",
},
{
fieldName: "status_line",
fieldDescription: `Short status sentence, e.g. "Waiting on you"`,
dataType: "TEXT",
},
],
uniqueConstraints: [
{
alias: "one_face_per_bot_user",
constraintName: "A bot has one face per user",
constraintTableFields: [
{ value: "bot_id" },
{ value: "user_id" },
],
},
],
indexes: [
{
indexName: "idx_bot_user_faces_user_id",
indexTableFields: ["user_id"],
},
{
indexName: "idx_bot_user_faces_updated_at",
indexTableFields: ["updated_at"],
},
],
},
{
tableName: "bot_message_media",
tableDescription: `Files attached to a chat message (\`media\` rows, private to the message's owner)`,
+27 -9
View File
@@ -18,6 +18,7 @@ export const BunSQLiteTables = [
"workspace_users",
"bot_users",
"bot_user_memories",
"bot_user_faces",
"bot_message_media",
"containers",
"computers",
@@ -179,14 +180,6 @@ export type BUN_SQLITE_COBBLE_BOTS = {
* The bot's job in one line. Null for the coordinator
*/
role?: string;
/**
* The bot's status, shown as its face
*/
face?: "idle" | "working" | "done" | "asleep" | "needs-you" | "";
/**
* Short status sentence, e.g. "Waiting on you"
*/
status_line?: string;
/**
* The bot's core instructions, in markdown. The bot can rewrite it
*/
@@ -650,6 +643,31 @@ export type BUN_SQLITE_COBBLE_BOT_USER_MEMORIES = {
memory?: string;
}
export type BUN_SQLITE_COBBLE_BOT_USER_FACES = {
/**
* 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 | "";
/**
* The bot's status for this user, shown as its face
*/
face?: "idle" | "working" | "done" | "asleep" | "needs-you" | "";
/**
* Short status sentence, e.g. "Waiting on you"
*/
status_line?: string;
}
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA = {
/**
* The unique identifier of the record.
@@ -1102,4 +1120,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_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
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_USER_FACES & 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
+16 -15
View File
@@ -14,6 +14,7 @@ import BotJobFatalError from "./bot-job-fatal-error";
import describeJobStep from "./describe-job-step";
import pushToSessionUser from "./push-to-session-user";
import pushBotNotice from "./push-bot-notice";
import grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
import runBotJob from "./run-bot-job";
import parseJobJson from "./parse-job-json";
import type { BotJobUpdateData } from "@/src/types";
@@ -58,6 +59,17 @@ export default async function processBotJob({
const job_id = job.id;
const bot_id = job.bot_id;
/**
* Its face changes only for the user it works for.
*/
const user_id = await grabJobUserId({ job }).catch(() => null);
const setFace = async (face: Omit<Parameters<typeof setBotFace>[0], "bot_id" | "user_id">) => {
if (!user_id) return;
await pushBotNotice({ bot_id, bot: await setBotFace({ bot_id, user_id, ...face }) });
};
const controller = new AbortController();
const update: BotJobContext["update"] = async (params) => {
@@ -106,13 +118,9 @@ export default async function processBotJob({
* its computer.
*/
const run = async () => {
await pushBotNotice({
bot_id,
bot: await setBotFace({
bot_id,
face: "working",
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
}),
await setFace({
face: "working",
status_line: job.kind == "reply" ? BotReplyJobTitle : BotTaskStatusLine,
});
return await runner({
@@ -159,14 +167,7 @@ export default async function processBotJob({
refund: true,
});
await pushBotNotice({
bot_id,
bot: await setBotFace({
bot_id,
face: "asleep",
status_line: BotWaitingStatusLine,
}),
});
await setFace({ face: "asleep", status_line: BotWaitingStatusLine });
return;
}
+9 -8
View File
@@ -1,14 +1,15 @@
import type {
BUN_SQLITE_COBBLE_BOT_MESSAGES,
BUN_SQLITE_COBBLE_BOTS,
} from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import type { BotFaceChange } from "@/src/functions/backend/db/bots/set-bot-face";
import pushBotUpdated from "./push-bot-updated";
import pushToSessionUser from "./push-to-session-user";
type Params = {
bot_id: number | string;
message?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null;
bot?: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id"> | null;
/**
* A face change (`setBotFace`), pushed to the user it's for.
*/
bot?: BotFaceChange | null;
/**
* A job's result reported to the bot that handed it over
* (`relayJobToRequester`): a message in that bot's conversation.
@@ -18,8 +19,8 @@ type Params = {
/**
* 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
* conversation it's in, and a face change to the user it's for
* (`pushBotUpdated`). A relayed report
* goes to the conversation it was posted in.
*/
export default async function pushBotNotice({ bot_id, message, bot, relay }: Params) {
@@ -38,6 +39,6 @@ export default async function pushBotNotice({ bot_id, message, bot, relay }: Par
}
if (bot) {
await pushBotUpdated({ bot: { ...bot, id: bot.id || Number(bot_id) } });
await pushBotUpdated({ bot_id: bot.id || bot_id, user_id: bot.user_id });
}
}
+27 -17
View File
@@ -1,38 +1,48 @@
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 grabBotFace from "@/src/functions/backend/db/bots/grab-bot-face";
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
type Params = {
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "face" | "status_line" | "computer_id">;
bot_id: number | string;
/**
* Only these users (the connected ones, for the live relay); else
* everyone who sees the bot.
* Only this user: a face change, which is theirs alone. Else everyone
* who sees the bot.
*/
user_id?: number | null;
/**
* Only these users (the connected ones, for the live relay).
*/
users?: Set<string>;
/**
* The computer the bot is on, when the push knows it (a switch, or the
* live relay). Null means its main one.
*/
computer_id?: number | null;
};
/**
* 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.
* Pushes `server:bot-updated` to one user, or everyone who sees the bot
* (`grabBotAudience`), each with their own face, status line and active
* task with it, never another user's.
*/
export default async function pushBotUpdated({ bot, users }: Params) {
if (!bot.id) return;
export default async function pushBotUpdated({ bot_id, user_id, users, computer_id }: Params) {
if (!bot_id) return;
for (const user_id of await grabBotAudience({ bot_id: bot.id })) {
if (users && !users.has(String(user_id))) continue;
const audience = user_id ? [user_id] : await grabBotAudience({ bot_id });
for (const viewer_id of audience) {
if (users && !users.has(String(viewer_id))) continue;
sendToUser({
user_id,
user_id: viewer_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 }),
id: Number(bot_id),
...(await grabBotFace({ bot_id, user_id: viewer_id })),
...(computer_id !== undefined ? { computer_id: computer_id || null } : {}),
task: await grabActiveBotTask({ bot_id, user_id: viewer_id }),
},
},
});
+3 -11
View File
@@ -1,7 +1,5 @@
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 grabJobUserId from "@/src/functions/backend/db/bots-queue/grab-job-user-id";
import HarnessError from "@/src/harness/harness-error";
import BotJobFatalError from "./bot-job-fatal-error";
import runBotReply from "./run-bot-reply";
@@ -26,19 +24,13 @@ const runBotJob: BotJobRunner = async (context) => {
throw new BotJobFatalError(`The bot for this job no longer exists.`);
}
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];
const user_id = await grabJobUserId({ job });
if (!session?.user_id) {
if (!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"
+5
View File
@@ -45,6 +45,11 @@ export const BotReplyJobTitle = "Thinking";
*/
export const BotTaskStatusLine = "Working on a task";
/**
* The status line while a bot waits on the user its job is for.
*/
export const BotNeedsYouStatusLine = "Waiting on you";
/**
* The bot's status line while its job waits for a rate-limited model.
*/
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabChatBot from "../bots/grab-chat-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
@@ -13,7 +14,10 @@ type Params = {
};
export type UserConversation = {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
/**
* With the user's own face.
*/
bot: BotWithFace & { id: number };
workspace: BUN_SQLITE_COBBLE_WORKSPACES | null;
workspace_id: number | null;
};
@@ -1,11 +1,13 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
type Params = {
job_id: number | string;
@@ -27,9 +29,9 @@ type Params = {
/**
* Marks a job done, closes its task and sends the completion message back
* to the user in the bot's conversation. Unless it has other jobs open,
* the bot shows needs-you if asked, else done after a task and idle after
* a reply. A handed-over job's result also goes to the bot that handed
* to the user in the bot's conversation. Unless it has other jobs open
* for that user, the bot shows that user needs-you if asked, else done
* after a task and idle after a reply; nobody else's face changes. A handed-over job's result also goes to the bot that handed
* it over (`relay`). Throws if this handler no longer holds the job.
*/
export default async function completeBotJob({
@@ -78,18 +80,24 @@ export default async function completeBotJob({
needs_you,
});
const bot = needs_you
? await setBotFace({
bot_id: job.bot_id,
face: "needs-you",
status_line: "Waiting on you",
})
: (await countOpenBotJobs({ bot_id: job.bot_id }))
? null
: await setBotFace({
const user_id = await grabJobUserId({ job });
const bot = !user_id
? null
: needs_you
? await setBotFace({
bot_id: job.bot_id,
face: job.kind == "reply" ? "idle" : "done",
});
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: (await countOpenBotJobs({ bot_id: job.bot_id, user_id }))
? null
: await setBotFace({
bot_id: job.bot_id,
user_id,
face: job.kind == "reply" ? "idle" : "done",
});
const relay = silent ? null : await relayJobToRequester({ job, text: result });
@@ -2,15 +2,20 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
/**
* Only jobs in this user's conversations.
*/
user_id: number | string;
};
/**
* How many of a bot's jobs are still pending or processing.
* How many of a bot's jobs for one user are still pending or processing.
*/
export default async function countOpenBotJobs({ bot_id }: Params) {
export default async function countOpenBotJobs({ bot_id, user_id }: Params) {
const res = await BunSQLite.sql<{ open: number }>({
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')`,
values: [bot_id],
sql: `SELECT COUNT(*) AS open FROM bots_queue WHERE bot_id = ? AND status IN ('pending', 'processing')
AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)`,
values: [bot_id, user_id],
});
return Number(res.payload?.[0]?.open || 0);
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import jobSessionId from "./job-session-id";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "session_id">;
};
/**
* The user a job works for: the one whose conversation it's in. Null once
* that conversation is gone.
*/
export default async function grabJobUserId({ job }: Params) {
const user_id = (
await BunSQLite.sql<{ user_id: number }>({
sql: `SELECT user_id FROM bot_sessions WHERE id = ? LIMIT 1`,
values: [jobSessionId({ job })],
})
).payload?.[0]?.user_id;
return user_id ? Number(user_id) : null;
}
@@ -4,6 +4,8 @@ import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
import grabJobUserId from "./grab-job-user-id";
import { BotNeedsYouStatusLine } from "@/src/dict/bot-queue-dict";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -13,7 +15,7 @@ type Params = {
/**
* Wraps up a job that is out of attempts: stamps `finished_at` (so it's
* reported once), tells the user in the bot's conversation and sets the
* bot to needs-you. Its task stays active, since the work isn't done. A
* bot to needs-you for that user. Its task stays active, since the work isn't done. A
* handed-over job's failure also goes to the bot that handed it over.
*/
export default async function notifyBotJobFailed({
@@ -46,11 +48,16 @@ export default async function notifyBotJobFailed({
needs_you: true,
});
const bot = await setBotFace({
bot_id: job.bot_id,
face: "needs-you",
status_line: "Waiting on you",
});
const user_id = await grabJobUserId({ job });
const bot = user_id
? await setBotFace({
bot_id: job.bot_id,
user_id,
face: "needs-you",
status_line: BotNeedsYouStatusLine,
})
: null;
const relay = await relayJobToRequester({
job,
@@ -42,8 +42,6 @@ export default async function createBot({ user_id, name, role, hue, system_promp
kind: "worker",
hue,
role: role || undefined,
face: "idle",
status_line: "",
system_prompt,
},
],
@@ -55,8 +55,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
user_id: Number(user_id),
name: CoordinatorBotName,
kind: "coordinator",
face: "idle",
status_line: "",
system_prompt: CoordinatorSystemPrompt,
},
],
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot's face and status line as one user sees it: idle when it has
* none for them.
*/
export default async function grabBotFace({ bot_id, user_id }: Params) {
const row = (
await BunSQLite.sql<{ face: BotFace; status_line: string }>({
sql: `SELECT face, status_line FROM bot_user_faces WHERE bot_id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
})
).payload?.[0];
return { face: (row?.face || "idle") as BotFace, status_line: row?.status_line || "" };
}
+7 -3
View File
@@ -2,6 +2,7 @@ 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 botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -18,8 +19,8 @@ type Params = {
* 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.
* carries the user's own face (`botFaceSql`) and conversation there: its
* unread flag, latest activity and latest message.
*/
export default async function grabBots({
user_id,
@@ -29,10 +30,13 @@ export default async function grabBots({
const role = await grabCrewRole({ user_id });
const access = scope ? workspaceBotsSql({ role, workspace_id: scope }) : chatBotsSql({ role, user_id });
const face = botFaceSql({ user_id });
let sql = ``;
sql += `SELECT\n`;
sql += ` b.*,\n`;
sql += ` ${face.sql},\n`;
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
sql += ` t.last_activity_at AS last_activity_at,\n`;
sql += ` lm.text AS last_message_text,\n`;
@@ -55,7 +59,7 @@ export default async function grabBots({
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [scope, user_id, scope, user_id, ...access.values],
values: [...face.values, scope, user_id, scope, user_id, ...access.values],
});
return res.payload || [];
+10 -6
View File
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -9,17 +10,20 @@ type Params = {
};
/**
* A bot only if the user chats with it in its own chat (`chatBotsSql`).
* A bot, with the user's own face (`botFaceSql`), 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> {
}: Params): Promise<BotWithFace | 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],
const face = botFaceSql({ user_id });
const res = await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [...face.values, bot_id, ...access.values],
});
return res.payload?.[0] || null;
+12 -8
View File
@@ -1,6 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -9,23 +10,26 @@ type Params = {
};
/**
* 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.
* A bot, with the user's own face (`botFaceSql`), 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> {
}: Params): Promise<BotWithFace | 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} OR (b.kind != 'coordinator' AND b.id IN (
const face = botFaceSql({ user_id });
const res = await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} 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],
values: [...face.values, bot_id, ...access.values, user_id],
});
return res.payload?.[0] || null;
+22 -5
View File
@@ -3,23 +3,40 @@ import type { BotFace } from "@/src/dict/bot-dict";
type Params = {
bot_id: number | string;
/**
* The user who sees it: the one whose conversation the job is in.
*/
user_id: number | string;
face: BotFace;
status_line?: string;
};
export type BotFaceChange = {
id: number;
user_id: number;
face: BotFace;
status_line: string;
};
/**
* Sets a bot's face (its status) and status line. Callers must check the
* Sets a bot's face (its status) and status line as one user sees it
* (`bot_user_faces`). Other users keep their own. Callers must check the
* bot first.
*/
export default async function setBotFace({
bot_id,
user_id,
face,
status_line = "",
}: Params) {
}: Params): Promise<BotFaceChange> {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bots SET face = ?, status_line = ?, updated_at = ? WHERE id = ?`,
values: [face, status_line, Date.now(), bot_id],
sql: `INSERT INTO bot_user_faces (created_at, updated_at, bot_id, user_id, face, status_line)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET face = excluded.face, status_line = excluded.status_line, updated_at = excluded.updated_at`,
values: [now, now, bot_id, user_id, face, status_line],
});
return { id: Number(bot_id), face, status_line };
return { id: Number(bot_id), user_id: Number(user_id), face, status_line };
}
@@ -1,15 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotWithFace } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
type Params = {
computer_id: number | string;
/**
* Whose faces the bots show.
*/
user_id: number | string;
};
export type ComputerAccess = {
bots: BUN_SQLITE_COBBLE_BOTS[];
bots: BotWithFace[];
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
};
@@ -20,14 +25,17 @@ export type ComputerAccess = {
*/
export default async function grabComputerAccess({
computer_id,
user_id,
}: Params): Promise<ComputerAccess> {
const face = botFaceSql({ user_id });
const [bots, workspaces] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b
BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b
JOIN bot_computers bc ON bc.bot_id = b.id
WHERE bc.computer_id = ?
ORDER BY b.kind = 'coordinator' DESC, b.name COLLATE NOCASE ASC, b.id ASC`,
values: [computer_id],
values: [...face.values, computer_id],
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT w.* FROM workspaces w
@@ -1,5 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { CronJobWithNames } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
type Params = {
user_id: number | string;
@@ -14,10 +15,12 @@ type Params = {
};
/**
* The user's schedules with their bot and workspace names, the next to
* The user's schedules with their bot (its face as the user sees it) and
* workspace names, the next to
* run first and paused ones last.
*/
export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Params): Promise<CronJobWithNames[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const where = [`c.user_id = ?`];
const values: (number | string)[] = [user_id];
@@ -32,14 +35,14 @@ export default async function grabCronJobs({ user_id, bot_id, cron_job_id }: Par
}
const res = await BunSQLite.sql<CronJobWithNames>({
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.face AS bot_face,
sql: `SELECT c.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, ${face.sql},
b.image_media_id AS bot_image_media_id, w.name AS workspace_name
FROM cron_jobs c
JOIN bots b ON b.id = c.bot_id
LEFT JOIN workspaces w ON w.id = c.workspace_id
WHERE ${where.join(" AND ")}
ORDER BY c.enabled DESC, c.next_run_at IS NULL, c.next_run_at ASC, c.id ASC`,
values,
values: [...face.values, ...values],
});
return res.payload || [];
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { WorkspaceActivityItem } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
type Params = {
user_id: number | string;
@@ -9,19 +10,21 @@ type Params = {
/**
* The user's latest conversations in a workspace, most recent first, each
* with the bot it's with, its message count and latest message. Empty
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty
* sessions are left out unless active.
*/
export default async function grabWorkspaceActivity({
user_id,
workspace_id,
}: Params): Promise<WorkspaceActivityItem[]> {
const face = botFaceSql({ user_id, prefix: "bot_" });
const res = await BunSQLite.sql<WorkspaceActivityItem>({
sql: `SELECT s.*,
b.name AS bot_name,
b.image_media_id AS bot_image_media_id,
b.hue AS bot_hue,
b.face AS bot_face,
${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(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
FROM bot_sessions s
@@ -33,7 +36,7 @@ export default async function grabWorkspaceActivity({
))
ORDER BY COALESCE(s.last_activity_at, s.created_at) DESC, s.id DESC
LIMIT ?`,
values: [workspace_id, user_id, AppData.WorkspaceActivityItems],
values: [...face.values, workspace_id, user_id, AppData.WorkspaceActivityItems],
});
return res.payload || [];
@@ -1,11 +1,12 @@
import type { BotWithFace } from "@/src/types";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACE_BOTS,
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 botFaceSql from "@/src/utils/bot-face-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
@@ -16,7 +17,10 @@ type Params = {
export type WorkspaceBotMembership = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
bot: BUN_SQLITE_COBBLE_BOTS;
/**
* With the user's own face (`botFaceSql`).
*/
bot: BotWithFace;
/**
* Its `workspace_bots` row. Null for the coordinator, which works in
* every workspace without one until it's given instructions there.
@@ -47,11 +51,12 @@ export default async function grabWorkspaceBot({
if (!workspace?.id) return null;
const bot_access = workspaceBotsSql({ role, workspace_id: workspace.id });
const face = botFaceSql({ user_id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [bot_id, ...bot_access.values],
await BunSQLite.sql<BotWithFace>({
sql: `SELECT b.*, ${face.sql} FROM bots b WHERE b.id = ? AND ${bot_access.sql} LIMIT 1`,
values: [...face.values, bot_id, ...bot_access.values],
})
).payload?.[0];
+27 -15
View File
@@ -1,33 +1,45 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOT_USER_FACES, BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
type Params = {
/**
* Unix ms: bots updated since then are pushed.
* Unix ms: bots and faces updated since then are pushed.
*/
since: number;
users: Set<string>;
};
/**
* 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.
* Pushes `server:bot-updated` (the user's own face, status line and
* active task) for every face that changed since `since` to the connected
* user it's for, and for every bot that changed (e.g. its computer) 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 bots =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, face, status_line, computer_id FROM bots WHERE updated_at > ?`,
values: [since],
})
).payload || [];
const [faces, bots] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_USER_FACES>({
sql: `SELECT bot_id, user_id FROM bot_user_faces WHERE updated_at > ?`,
values: [since],
}),
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT id, computer_id FROM bots WHERE updated_at > ?`,
values: [since],
}),
]);
for (const bot of bots) {
await pushBotUpdated({ bot, users });
for (const face of faces.payload || []) {
if (!face.bot_id || !face.user_id || !users.has(String(face.user_id))) continue;
await pushBotUpdated({ bot_id: face.bot_id, user_id: face.user_id });
}
for (const bot of bots.payload || []) {
if (!bot.id) continue;
await pushBotUpdated({ bot_id: bot.id, users, computer_id: bot.computer_id || null });
}
}
@@ -1,11 +1,11 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithFace } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import SettingsListRow from "../../../(partials)/settings-list-row";
import SettingsRowAction from "../../../(partials)/settings-row-action";
type Props = {
bot: BUN_SQLITE_COBBLE_BOTS;
bot: BotWithFace;
/**
* The bot's settings page, when the actor may open it.
*/
@@ -45,7 +45,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
),
grabBots({ user_id }),
grabUserWorkspaces({ user_id }),
grabComputerAccess({ computer_id: allowed.id }),
grabComputerAccess({ computer_id: allowed.id, user_id }),
]);
return {
+2 -10
View File
@@ -1,6 +1,6 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import userAuth from "@/src/functions/backend/auth/user-auth";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
import setBotComputer from "@/src/functions/backend/db/computers/set-bot-computer";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
@@ -54,15 +54,7 @@ export const handler: BunextAPIRouteHandler<
user_id: user.id,
});
await pushBotNotice({
bot_id: bot.id || 0,
bot: {
id: bot.id,
face: bot.face,
status_line: bot.status_line,
computer_id: computer.id,
},
});
await pushBotUpdated({ bot_id: bot.id || 0, computer_id: computer.id || null });
await logEvent({
title: "Bot computer switched",
+4 -6
View File
@@ -13,6 +13,7 @@ import type {
BotMessageAuthor,
BotTaskStepState,
} from "@/src/dict/bot-dict";
import setBotFace from "@/src/functions/backend/db/bots/set-bot-face";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
@@ -205,10 +206,7 @@ async function insertBot({
if (!bot.hue) {
const coordinator_id = await ensureCoordinatorBot({ user_id });
await BunSQLite.sql({
sql: `UPDATE bots SET face = ?, status_line = ? WHERE id = ?`,
values: [bot.face, bot.status_line, coordinator_id],
});
await setBotFace({ bot_id: coordinator_id, user_id, face: bot.face, status_line: bot.status_line });
await insertMessages({ user_id, bot_id: coordinator_id, bot, now, last_ago });
@@ -225,8 +223,6 @@ async function insertBot({
kind: bot.hue ? "worker" : "coordinator",
hue: bot.hue || undefined,
role: bot.role || undefined,
face: bot.face,
status_line: bot.status_line,
},
],
});
@@ -246,6 +242,8 @@ async function insertBot({
values: [now, now, bot_id, user_id],
});
await setBotFace({ bot_id, user_id, face: bot.face, status_line: bot.status_line });
const session_id = await insertMessages({ user_id, bot_id, bot, now, last_ago });
if (bot.task) {
+14 -3
View File
@@ -26,6 +26,7 @@ import type { HarnessKind } from "../dict/harness-dict";
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
import type { ContainerStatus } from "../dict/computer-dict";
import type { BotFace } from "../dict/bot-dict";
export type User = {
id: number;
@@ -42,11 +43,21 @@ export type User = {
roles?: string;
};
/**
* A bot row with its face and status line as the user it's for sees them
* (`botFaceSql`). Faces are per user, so a bot row read without one has
* neither.
*/
export type BotWithFace = BUN_SQLITE_COBBLE_BOTS & {
face?: BotFace;
status_line?: string;
};
/**
* A bot row plus its conversation (in its own chat or one workspace):
* unread, latest activity and latest message, for the rail.
*/
export type BotWithLastMessage = BUN_SQLITE_COBBLE_BOTS & {
export type BotWithLastMessage = BotWithFace & {
unread?: number | null;
last_activity_at?: number | null;
last_message_text?: string | null;
@@ -169,7 +180,7 @@ export type BotRemovedData = {
*/
export type BotUpdatedData = {
id: number;
face: BUN_SQLITE_COBBLE_BOTS["face"];
face: BotFace;
status_line: string;
task: BotTaskWithSteps | null;
/**
@@ -413,7 +424,7 @@ export type PagePropsType = {
* The computer a settings page manages, with its access.
*/
computer?: ComputerWithStatus | null;
computer_bots?: BUN_SQLITE_COBBLE_BOTS[] | null;
computer_bots?: BotWithFace[] | null;
computer_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null;
workspace_harness_overview?: BotHarnessOverviewPayload | null;
/**
+38
View File
@@ -0,0 +1,38 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import botFaceSql from "./bot-face-sql";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY)`);
db.run(`CREATE TABLE bot_user_faces (bot_id INTEGER, user_id INTEGER, face TEXT, status_line TEXT)`);
db.run(`INSERT INTO bots VALUES (1), (2)`);
db.run(`INSERT INTO bot_user_faces VALUES (1, 7, 'needs-you', 'Waiting on you'), (2, 8, 'working', 'Working on a task')`);
});
function faces({ user_id, prefix }: { user_id: number; prefix?: string }) {
const columns = botFaceSql({ user_id, prefix });
return db.query(`SELECT b.id, ${columns.sql} FROM bots b ORDER BY b.id`).all(...columns.values);
}
describe("botFaceSql", () => {
test("each user sees only their own face of a bot", () => {
expect(faces({ user_id: 7 })).toEqual([
{ id: 1, face: "needs-you", status_line: "Waiting on you" },
{ id: 2, face: "idle", status_line: "" },
]);
expect(faces({ user_id: 8 })).toEqual([
{ id: 1, face: "idle", status_line: "" },
{ id: 2, face: "working", status_line: "Working on a task" },
]);
});
test("a user with no faces sees every bot idle, under the prefix", () => {
expect(faces({ user_id: 9, prefix: "bot_" })).toEqual([
{ id: 1, bot_face: "idle", bot_status_line: "" },
{ id: 2, bot_face: "idle", bot_status_line: "" },
]);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { SqlCondition } from "./crew-access/types";
type Params = {
user_id: number | string;
/**
* The bots table alias.
*/
alias?: string;
/**
* Put before the column names, e.g. `bot_` for `bot_face`.
*/
prefix?: string;
};
/**
* Select columns for a bot's face and status line as one user sees them
* (`bot_user_faces`): idle and no status line when the bot has none for
* them. Faces are per user, so a job one user's waiting on never shows
* on anyone else's screen.
*/
export default function botFaceSql({ user_id, alias = "b", prefix = "" }: Params): SqlCondition {
const column = (name: string) =>
`(SELECT f.${name} FROM bot_user_faces f WHERE f.bot_id = ${alias}.id AND f.user_id = ?)`;
return {
sql: `COALESCE(${column("face")}, 'idle') AS ${prefix}face, COALESCE(${column("status_line")}, '') AS ${prefix}status_line`,
values: [Number(user_id), Number(user_id)],
};
}