From e45d54d30b862a497278caab7d9209d92e53810e Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 11:37:14 +0100 Subject: [PATCH] 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 --- AGENTS.md | 25 +-- db/schema.ts | 184 +++++++++++++++--- db/types/db.ts | 72 ++++++- src/bots-queue/process-bot-job.ts | 10 +- src/bots-queue/push-bot-notice.ts | 27 ++- src/bots-queue/push-bot-updated.ts | 40 ++++ src/bots-queue/push-to-bot-owner.ts | 26 --- src/bots-queue/push-to-session-user.ts | 29 +++ src/bots-queue/run-bot-job.ts | 26 ++- src/bots-queue/run-bot-reply.ts | 13 +- src/bots-queue/run-bot-task.ts | 13 +- src/data/harness-prompts.ts | 4 +- src/dict/bot-queue-dict.ts | 7 + src/dict/settings-pages-dict.ts | 7 +- src/dict/user-types-dict.ts | 8 +- .../backend/db/access/assert-crew-manager.ts | 20 ++ .../backend/db/access/grab-bot-audience.ts | 30 +++ .../db/access/grab-computer-audience.ts | 26 +++ .../backend/db/access/grab-crew-options.ts | 19 ++ .../backend/db/access/grab-crew-role.ts | 13 ++ .../db/access/grab-shared-picture-media.ts | 41 ++++ .../backend/db/access/set-user-access.ts | 49 +++++ .../db/bot-sessions/create-bot-session.ts | 8 +- .../db/bot-sessions/ensure-bot-session.ts | 16 +- .../bot-sessions/grab-active-bot-session.ts | 13 +- .../db/bot-sessions/grab-bot-sessions.ts | 10 +- .../bot-sessions/grab-conversation-props.ts | 6 +- .../db/bot-sessions/grab-user-bot-session.ts | 5 +- .../db/bot-sessions/grab-user-conversation.ts | 10 +- .../db/bot-sessions/mark-bot-session-read.ts | 4 +- .../db/bot-sessions/reopen-bot-session.ts | 8 +- .../db/bot-sessions/start-bot-session.ts | 8 +- .../backend/db/bots-queue/cancel-bot-job.ts | 4 +- .../backend/db/bots-queue/enqueue-bot-job.ts | 2 +- .../backend/db/bots-queue/job-session-id.ts | 21 +- .../db/bots-queue/relay-job-to-requester.ts | 12 +- src/functions/backend/db/bots/create-bot.ts | 10 +- src/functions/backend/db/bots/delete-bot.ts | 3 + .../backend/db/bots/ensure-coordinator-bot.ts | 9 +- .../backend/db/bots/grab-active-bot-task.ts | 11 +- .../backend/db/bots/grab-bot-prompt.ts | 9 +- .../backend/db/bots/grab-bot-user-memory.ts | 20 ++ src/functions/backend/db/bots/grab-bots.ts | 36 ++-- .../backend/db/bots/grab-chat-bot.ts | 26 +++ .../backend/db/bots/grab-user-bot.ts | 16 +- .../backend/db/bots/update-bot-user-memory.ts | 44 +++++ src/functions/backend/db/bots/update-bot.ts | 3 + .../db/computers/add-bot-to-computer.ts | 3 + .../backend/db/computers/create-computer.ts | 7 +- .../db/computers/ensure-main-computer.ts | 8 +- .../db/computers/grab-bot-computers.ts | 25 ++- .../db/computers/grab-user-computer.ts | 10 +- .../db/computers/grab-user-computers.ts | 12 +- .../db/computers/push-computer-update.ts | 14 +- .../db/computers/remove-bot-from-computer.ts | 3 + .../backend/db/computers/set-bot-computer.ts | 2 +- .../backend/db/cron-jobs/create-cron-job.ts | 5 +- .../backend/db/cron-jobs/fire-cron-job.ts | 2 +- .../backend/db/envs/grab-envs-overview.ts | 18 +- src/functions/backend/db/envs/save-env.ts | 6 +- .../db/profile-images/set-profile-image.ts | 3 + src/functions/backend/db/users/delete-user.ts | 38 +--- src/functions/backend/db/users/grab-users.ts | 2 + .../db/workspaces/add-bot-to-workspace.ts | 3 + .../workspaces/add-computer-to-workspace.ts | 3 + .../backend/db/workspaces/create-workspace.ts | 10 +- .../backend/db/workspaces/delete-workspace.ts | 3 + .../db/workspaces/grab-user-workspace.ts | 10 +- .../db/workspaces/grab-user-workspaces.ts | 17 +- .../db/workspaces/grab-workspace-bot.ts | 27 ++- .../db/workspaces/grab-workspace-computers.ts | 2 +- .../workspaces/remove-bot-from-workspace.ts | 3 + .../remove-computer-from-workspace.ts | 3 + .../backend/db/workspaces/update-workspace.ts | 3 + .../server/media/handle-media-server.ts | 9 +- src/harness/tools/bot/delegate-task-tool.ts | 4 +- src/harness/tools/bot/grab-bot-chat-tools.ts | 12 +- .../tools/bot/update-instructions-tool.ts | 4 +- src/harness/tools/bot/update-memory-tool.ts | 38 ++-- .../bots/(partials)/rail-add-button.tsx | 7 +- .../bots/(partials)/rail-crew-list.tsx | 9 +- .../(partials)/rail-section-add-button.tsx | 10 +- .../bots/(partials)/rail-workspace-list.tsx | 2 +- src/live-relay/index.ts | 4 +- src/live-relay/relay-changed-bots.ts | 30 +-- src/live-relay/relay-new-bot-messages.ts | 4 +- src/live-relay/relay-new-job-updates.ts | 7 +- .../admin/bots/(sections)/no-bots-section.tsx | 14 ++ .../[bot_id]/(hooks)/use-task-progress.ts | 19 +- .../[bot_id]/(partials)/bot-models-modal.tsx | 16 +- .../[bot_id]/(partials)/bot-profile-modal.tsx | 28 ++- .../[bot_id]/(partials)/bot-thinking-row.tsx | 23 ++- .../[bot_id]/(partials)/message-thread.tsx | 13 +- .../bots/[bot_id]/(sections)/conversation.tsx | 3 +- .../bots/[bot_id]/models/index.server.ts | 10 +- src/pages/admin/bots/index.server.ts | 38 +++- src/pages/admin/bots/index.tsx | 6 +- .../create/(functions)/submit-user-form.ts | 8 + .../create/(partials)/user-form/index.tsx | 10 +- .../(partials)/user-form/user-form-access.tsx | 68 +++++++ .../grab-crew-settings-server-props.ts | 27 +++ src/pages/admin/settings/bots/index.server.ts | 4 +- .../admin/settings/computers/index.server.ts | 4 +- .../admin/settings/users/index.server.ts | 5 +- .../admin/settings/workspaces/index.server.ts | 4 +- .../(sections)/workspace-overview.tsx | 13 +- .../workspaces/[workspace_id]/index.server.ts | 19 +- src/pages/api/admin/bots/list-sessions.ts | 1 + src/pages/api/admin/bots/new-session.ts | 1 + .../api/admin/bots/save-harness-settings.ts | 5 + src/pages/api/admin/bots/send-message.ts | 5 +- src/pages/api/admin/users/save-user.ts | 36 ++-- .../admin/workspaces/save-harness-settings.ts | 5 + src/pages/api/admin/workspaces/update-bot.ts | 5 + src/schemas/user-schema.ts | 6 + src/scripts/move-bot-memory-to-owners.ts | 40 ++++ src/scripts/seed-bots.ts | 23 ++- src/scripts/share-crew.ts | 176 +++++++++++++++++ src/types/index.ts | 8 + src/types/sql-joins.ts | 8 + src/utils/compose-bot-prompt.test.ts | 7 +- src/utils/compose-bot-prompt.ts | 15 +- src/utils/crew-access/chat-bots-sql.ts | 16 ++ src/utils/crew-access/computers-sql.ts | 22 +++ src/utils/crew-access/crew-access-sql.test.ts | 75 +++++++ src/utils/crew-access/types.ts | 18 ++ src/utils/crew-access/workspace-bots-sql.ts | 20 ++ src/utils/crew-access/workspaces-sql.ts | 14 ++ src/utils/parse-id-list.test.ts | 10 + src/utils/parse-id-list.ts | 16 ++ src/utils/rbac/can-manage-crew.ts | 15 ++ src/utils/rbac/crew-role.ts | 23 +++ src/utils/rbac/index.ts | 3 +- 133 files changed, 1854 insertions(+), 436 deletions(-) create mode 100644 src/bots-queue/push-bot-updated.ts delete mode 100644 src/bots-queue/push-to-bot-owner.ts create mode 100644 src/bots-queue/push-to-session-user.ts create mode 100644 src/functions/backend/db/access/assert-crew-manager.ts create mode 100644 src/functions/backend/db/access/grab-bot-audience.ts create mode 100644 src/functions/backend/db/access/grab-computer-audience.ts create mode 100644 src/functions/backend/db/access/grab-crew-options.ts create mode 100644 src/functions/backend/db/access/grab-crew-role.ts create mode 100644 src/functions/backend/db/access/grab-shared-picture-media.ts create mode 100644 src/functions/backend/db/access/set-user-access.ts create mode 100644 src/functions/backend/db/bots/grab-bot-user-memory.ts create mode 100644 src/functions/backend/db/bots/grab-chat-bot.ts create mode 100644 src/functions/backend/db/bots/update-bot-user-memory.ts create mode 100644 src/pages/admin/bots/(sections)/no-bots-section.tsx create mode 100644 src/pages/admin/people/create/(partials)/user-form/user-form-access.tsx create mode 100644 src/pages/admin/settings/(functions)/grab-crew-settings-server-props.ts create mode 100644 src/scripts/move-bot-memory-to-owners.ts create mode 100644 src/scripts/share-crew.ts create mode 100644 src/utils/crew-access/chat-bots-sql.ts create mode 100644 src/utils/crew-access/computers-sql.ts create mode 100644 src/utils/crew-access/crew-access-sql.test.ts create mode 100644 src/utils/crew-access/types.ts create mode 100644 src/utils/crew-access/workspace-bots-sql.ts create mode 100644 src/utils/crew-access/workspaces-sql.ts create mode 100644 src/utils/parse-id-list.test.ts create mode 100644 src/utils/parse-id-list.ts create mode 100644 src/utils/rbac/can-manage-crew.ts create mode 100644 src/utils/rbac/crew-role.ts diff --git a/AGENTS.md b/AGENTS.md index 47cdb52..34c1204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ""` | | Install | `bun install` | @@ -85,23 +85,24 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - 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 " 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: ` with its description and `system_prompt`, the bot's `# Your instructions in ` (`workspace_bots.system_prompt`), `# Memory`, then `# Memory in ` (`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: ` with its description and `system_prompt`, the bot's `# Your instructions in ` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in ` (`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 " 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 `-` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. 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//` (`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 `-` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`. +- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers//` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`). - **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to ", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. - **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`). - **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 ` 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 diff --git a/db/schema.ts b/db/schema.ts index 10cd94f..5e1a110 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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", diff --git a/db/types/db.ts b/db/types/db.ts index e2c1c01..648b625 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -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 \ No newline at end of file +export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_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 \ No newline at end of file diff --git a/src/bots-queue/process-bot-job.ts b/src/bots-queue/process-bot-job.ts index 2e6d660..823c55a 100644 --- a/src/bots-queue/process-bot-job.ts +++ b/src/bots-queue/process-bot-job.ts @@ -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, }), }); diff --git a/src/bots-queue/push-bot-notice.ts b/src/bots-queue/push-bot-notice.ts index 18a4548..a9a3a6a 100644 --- a/src/bots-queue/push-bot-notice.ts +++ b/src/bots-queue/push-bot-notice.ts @@ -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) } }); } } diff --git a/src/bots-queue/push-bot-updated.ts b/src/bots-queue/push-bot-updated.ts new file mode 100644 index 0000000..de70d12 --- /dev/null +++ b/src/bots-queue/push-bot-updated.ts @@ -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; + /** + * Only these users (the connected ones, for the live relay); else + * everyone who sees the bot. + */ + users?: Set; +}; + +/** + * 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 }), + }, + }, + }); + } +} diff --git a/src/bots-queue/push-to-bot-owner.ts b/src/bots-queue/push-to-bot-owner.ts deleted file mode 100644 index befd7bc..0000000 --- a/src/bots-queue/push-to-bot-owner.ts +++ /dev/null @@ -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({ - 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 }); -} diff --git a/src/bots-queue/push-to-session-user.ts b/src/bots-queue/push-to-session-user.ts new file mode 100644 index 0000000..85cf03b --- /dev/null +++ b/src/bots-queue/push-to-session-user.ts @@ -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({ + 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 }); +} diff --git a/src/bots-queue/run-bot-job.ts b/src/bots-queue/run-bot-job.ts index 8f815bf..1202d78 100644 --- a/src/bots-queue/run-bot-job.ts +++ b/src/bots-queue/run-bot-job.ts @@ -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({ + 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 && diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index 2b8cc1a..fec9a57 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -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, diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index 00b09c0..868e862 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -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 }), diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index 4e259df..57110d2 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -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 diff --git a/src/dict/bot-queue-dict.ts b/src/dict/bot-queue-dict.ts index 3f3b65d..fd297d8 100644 --- a/src/dict/bot-queue-dict.ts +++ b/src/dict/bot-queue-dict.ts @@ -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. */ diff --git a/src/dict/settings-pages-dict.ts b/src/dict/settings-pages-dict.ts index 524ceca..9e87624 100644 --- a/src/dict/settings-pages-dict.ts +++ b/src/dict/settings-pages-dict.ts @@ -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", diff --git a/src/dict/user-types-dict.ts b/src/dict/user-types-dict.ts index db186ed..a593dc2 100644 --- a/src/dict/user-types-dict.ts +++ b/src/dict/user-types-dict.ts @@ -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; diff --git a/src/functions/backend/db/access/assert-crew-manager.ts b/src/functions/backend/db/access/assert-crew-manager.ts new file mode 100644 index 0000000..75beb60 --- /dev/null +++ b/src/functions/backend/db/access/assert-crew-manager.ts @@ -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; +} diff --git a/src/functions/backend/db/access/grab-bot-audience.ts b/src/functions/backend/db/access/grab-bot-audience.ts new file mode 100644 index 0000000..655db95 --- /dev/null +++ b/src/functions/backend/db/access/grab-bot-audience.ts @@ -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 { + 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); +} diff --git a/src/functions/backend/db/access/grab-computer-audience.ts b/src/functions/backend/db/access/grab-computer-audience.ts new file mode 100644 index 0000000..040e0b6 --- /dev/null +++ b/src/functions/backend/db/access/grab-computer-audience.ts @@ -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 { + 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); +} diff --git a/src/functions/backend/db/access/grab-crew-options.ts b/src/functions/backend/db/access/grab-crew-options.ts new file mode 100644 index 0000000..5b330a2 --- /dev/null +++ b/src/functions/backend/db/access/grab-crew-options.ts @@ -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({ + sql: `SELECT id, name, role FROM bots WHERE kind = 'worker' ORDER BY name COLLATE NOCASE ASC, id ASC`, + }), + BunSQLite.sql({ + 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 || [] }; +} diff --git a/src/functions/backend/db/access/grab-crew-role.ts b/src/functions/backend/db/access/grab-crew-role.ts new file mode 100644 index 0000000..a9ed157 --- /dev/null +++ b/src/functions/backend/db/access/grab-crew-role.ts @@ -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 }) }); +} diff --git a/src/functions/backend/db/access/grab-shared-picture-media.ts b/src/functions/backend/db/access/grab-shared-picture-media.ts new file mode 100644 index 0000000..4e99862 --- /dev/null +++ b/src/functions/backend/db/access/grab-shared-picture-media.ts @@ -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({ + sql: `SELECT id FROM bots WHERE image_media_id = ? LIMIT 1`, + values: [media_id], + }).then((res) => res.payload?.[0]), + BunSQLite.sql({ + 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({ + sql: `SELECT * FROM media WHERE id = ? LIMIT 1`, + values: [media_id], + }); + + return res.payload?.[0] || null; +} diff --git a/src/functions/backend/db/access/set-user-access.ts b/src/functions/backend/db/access/set-user-access.ts new file mode 100644 index 0000000..594ae51 --- /dev/null +++ b/src/functions/backend/db/access/set-user-access.ts @@ -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], + }); + } + } +} diff --git a/src/functions/backend/db/bot-sessions/create-bot-session.ts b/src/functions/backend/db/bot-sessions/create-bot-session.ts index e4bb887..0f9baef 100644 --- a/src/functions/backend/db/bot-sessions/create-bot-session.ts +++ b/src/functions/backend/db/bot-sessions/create-bot-session.ts @@ -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, diff --git a/src/functions/backend/db/bot-sessions/ensure-bot-session.ts b/src/functions/backend/db/bot-sessions/ensure-bot-session.ts index c9fc47d..4a7bcb5 100644 --- a/src/functions/backend/db/bot-sessions/ensure-bot-session.ts +++ b/src/functions/backend/db/bot-sessions/ensure-bot-session.ts @@ -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 { - 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({ - 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, diff --git a/src/functions/backend/db/bot-sessions/grab-active-bot-session.ts b/src/functions/backend/db/bot-sessions/grab-active-bot-session.ts index 4a20813..ea069ac 100644 --- a/src/functions/backend/db/bot-sessions/grab-active-bot-session.ts +++ b/src/functions/backend/db/bot-sessions/grab-active-bot-session.ts @@ -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 { const res = await BunSQLite.sql({ 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; diff --git a/src/functions/backend/db/bot-sessions/grab-bot-sessions.ts b/src/functions/backend/db/bot-sessions/grab-bot-sessions.ts index f3b55c1..9537161 100644 --- a/src/functions/backend/db/bot-sessions/grab-bot-sessions.ts +++ b/src/functions/backend/db/bot-sessions/grab-bot-sessions.ts @@ -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({ sql, values: [ + user_id, bot_id, workspace_id ? Number(workspace_id) : null, ...(like ? [like, like, like] : []), diff --git a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts index e9a42d0..5f8b0f4 100644 --- a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts +++ b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts @@ -24,7 +24,7 @@ type Params = { * `/admin/workspaces//bots/`): 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 }), ]); diff --git a/src/functions/backend/db/bot-sessions/grab-user-bot-session.ts b/src/functions/backend/db/bot-sessions/grab-user-bot-session.ts index 1e3a059..5ff3c1d 100644 --- a/src/functions/backend/db/bot-sessions/grab-user-bot-session.ts +++ b/src/functions/backend/db/bot-sessions/grab-user-bot-session.ts @@ -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 { const res = await BunSQLite.sql({ - 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], }); diff --git a/src/functions/backend/db/bot-sessions/grab-user-conversation.ts b/src/functions/backend/db/bot-sessions/grab-user-conversation.ts index 3730403..6218605 100644 --- a/src/functions/backend/db/bot-sessions/grab-user-conversation.ts +++ b/src/functions/backend/db/bot-sessions/grab-user-conversation.ts @@ -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`); diff --git a/src/functions/backend/db/bot-sessions/mark-bot-session-read.ts b/src/functions/backend/db/bot-sessions/mark-bot-session-read.ts index ff8ce7e..14c4c5b 100644 --- a/src/functions/backend/db/bot-sessions/mark-bot-session-read.ts +++ b/src/functions/backend/db/bot-sessions/mark-bot-session-read.ts @@ -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], }); } diff --git a/src/functions/backend/db/bot-sessions/reopen-bot-session.ts b/src/functions/backend/db/bot-sessions/reopen-bot-session.ts index 8f4ea5e..f60ae2a 100644 --- a/src/functions/backend/db/bot-sessions/reopen-bot-session.ts +++ b/src/functions/backend/db/bot-sessions/reopen-bot-session.ts @@ -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({ diff --git a/src/functions/backend/db/bot-sessions/start-bot-session.ts b/src/functions/backend/db/bot-sessions/start-bot-session.ts index 32e34d5..7b11bea 100644 --- a/src/functions/backend/db/bot-sessions/start-bot-session.ts +++ b/src/functions/backend/db/bot-sessions/start-bot-session.ts @@ -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 { - 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, diff --git a/src/functions/backend/db/bots-queue/cancel-bot-job.ts b/src/functions/backend/db/bots-queue/cancel-bot-job.ts index 9a72f60..f54e0d8 100644 --- a/src/functions/backend/db/bots-queue/cancel-bot-job.ts +++ b/src/functions/backend/db/bots-queue/cancel-bot-job.ts @@ -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({ 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], }); diff --git a/src/functions/backend/db/bots-queue/enqueue-bot-job.ts b/src/functions/backend/db/bots-queue/enqueue-bot-job.ts index effcff5..e430b80 100644 --- a/src/functions/backend/db/bots-queue/enqueue-bot-job.ts +++ b/src/functions/backend/db/bots-queue/enqueue-bot-job.ts @@ -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.`); diff --git a/src/functions/backend/db/bots-queue/job-session-id.ts b/src/functions/backend/db/bots-queue/job-session-id.ts index d9592d0..d98dec6 100644 --- a/src/functions/backend/db/bots-queue/job-session-id.ts +++ b/src/functions/backend/db/bots-queue/job-session-id.ts @@ -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; + job: Pick; }; /** - * 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); } diff --git a/src/functions/backend/db/bots-queue/relay-job-to-requester.ts b/src/functions/backend/db/bots-queue/relay-job-to-requester.ts index f5adc41..fc11a85 100644 --- a/src/functions/backend/db/bots-queue/relay-job-to-requester.ts +++ b/src/functions/backend/db/bots-queue/relay-job-to-requester.ts @@ -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({ - 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"; diff --git a/src/functions/backend/db/bots/create-bot.ts b/src/functions/backend/db/bots/create-bot.ts index efe4b1d..0ae1f46 100644 --- a/src/functions/backend/db/bots/create-bot.ts +++ b/src/functions/backend/db/bots/create-bot.ts @@ -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< diff --git a/src/functions/backend/db/bots/delete-bot.ts b/src/functions/backend/db/bots/delete-bot.ts index 0388742..d1ca964 100644 --- a/src/functions/backend/db/bots/delete-bot.ts +++ b/src/functions/backend/db/bots/delete-bot.ts @@ -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) { diff --git a/src/functions/backend/db/bots/ensure-coordinator-bot.ts b/src/functions/backend/db/bots/ensure-coordinator-bot.ts index f957494..bf8908e 100644 --- a/src/functions/backend/db/bots/ensure-coordinator-bot.ts +++ b/src/functions/backend/db/bots/ensure-coordinator-bot.ts @@ -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" }, diff --git a/src/functions/backend/db/bots/grab-active-bot-task.ts b/src/functions/backend/db/bots/grab-active-bot-task.ts index 54ad7b0..8b879da 100644 --- a/src/functions/backend/db/bots/grab-active-bot-task.ts +++ b/src/functions/backend/db/bots/grab-active-bot-task.ts @@ -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 { const task_res = await BunSQLite.sql({ 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]; diff --git a/src/functions/backend/db/bots/grab-bot-prompt.ts b/src/functions/backend/db/bots/grab-bot-prompt.ts index e0ca1f6..ac8829f 100644 --- a/src/functions/backend/db/bots/grab-bot-prompt.ts +++ b/src/functions/backend/db/bots/grab-bot-prompt.ts @@ -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, }); diff --git a/src/functions/backend/db/bots/grab-bot-user-memory.ts b/src/functions/backend/db/bots/grab-bot-user-memory.ts new file mode 100644 index 0000000..6712653 --- /dev/null +++ b/src/functions/backend/db/bots/grab-bot-user-memory.ts @@ -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({ + 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; +} diff --git a/src/functions/backend/db/bots/grab-bots.ts b/src/functions/backend/db/bots/grab-bots.ts index d48ad09..9b17dc9 100644 --- a/src/functions/backend/db/bots/grab-bots.ts +++ b/src/functions/backend/db/bots/grab-bots.ts @@ -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 { 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({ sql, - values: [scope, scope, user_id, ...(scope ? [scope] : [])], + values: [scope, user_id, scope, user_id, ...access.values], }); return res.payload || []; diff --git a/src/functions/backend/db/bots/grab-chat-bot.ts b/src/functions/backend/db/bots/grab-chat-bot.ts new file mode 100644 index 0000000..8dbb64e --- /dev/null +++ b/src/functions/backend/db/bots/grab-chat-bot.ts @@ -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 { + const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id }); + + const res = await BunSQLite.sql({ + sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`, + values: [bot_id, ...access.values], + }); + + return res.payload?.[0] || null; +} diff --git a/src/functions/backend/db/bots/grab-user-bot.ts b/src/functions/backend/db/bots/grab-user-bot.ts index b96628e..14c8bf0 100644 --- a/src/functions/backend/db/bots/grab-user-bot.ts +++ b/src/functions/backend/db/bots/grab-user-bot.ts @@ -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 { + const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id }); + const res = await BunSQLite.sql({ - 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; diff --git a/src/functions/backend/db/bots/update-bot-user-memory.ts b/src/functions/backend/db/bots/update-bot-user-memory.ts new file mode 100644 index 0000000..f6b31b2 --- /dev/null +++ b/src/functions/backend/db/bots/update-bot-user-memory.ts @@ -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}`); + } +} diff --git a/src/functions/backend/db/bots/update-bot.ts b/src/functions/backend/db/bots/update-bot.ts index 9e42c80..f7d3d28 100644 --- a/src/functions/backend/db/bots/update-bot.ts +++ b/src/functions/backend/db/bots/update-bot.ts @@ -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 }); diff --git a/src/functions/backend/db/computers/add-bot-to-computer.ts b/src/functions/backend/db/computers/add-bot-to-computer.ts index 2ef3681..083a4e1 100644 --- a/src/functions/backend/db/computers/add-bot-to-computer.ts +++ b/src/functions/backend/db/computers/add-bot-to-computer.ts @@ -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 }); diff --git a/src/functions/backend/db/computers/create-computer.ts b/src/functions/backend/db/computers/create-computer.ts index a1e4fdd..24ec353 100644 --- a/src/functions/backend/db/computers/create-computer.ts +++ b/src/functions/backend/db/computers/create-computer.ts @@ -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 * (`-`), 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 diff --git a/src/functions/backend/db/computers/ensure-main-computer.ts b/src/functions/backend/db/computers/ensure-main-computer.ts index 73e9e07..306a38d 100644 --- a/src/functions/backend/db/computers/ensure-main-computer.ts +++ b/src/functions/backend/db/computers/ensure-main-computer.ts @@ -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" }, diff --git a/src/functions/backend/db/computers/grab-bot-computers.ts b/src/functions/backend/db/computers/grab-bot-computers.ts index 66ec41f..17c1136 100644 --- a/src/functions/backend/db/computers/grab-bot-computers.ts +++ b/src/functions/backend/db/computers/grab-bot-computers.ts @@ -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 { if (workspace_id) { const res = await BunSQLite.sql({ 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({ 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 || []; diff --git a/src/functions/backend/db/computers/grab-user-computer.ts b/src/functions/backend/db/computers/grab-user-computer.ts index 4eb5710..f87f123 100644 --- a/src/functions/backend/db/computers/grab-user-computer.ts +++ b/src/functions/backend/db/computers/grab-user-computer.ts @@ -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 { + const access = computersSql({ role: await grabCrewRole({ user_id }), user_id }); + const res = await BunSQLite.sql({ - 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; diff --git a/src/functions/backend/db/computers/grab-user-computers.ts b/src/functions/backend/db/computers/grab-user-computers.ts index 7d33f69..50a9b96 100644 --- a/src/functions/backend/db/computers/grab-user-computers.ts +++ b/src/functions/backend/db/computers/grab-user-computers.ts @@ -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 { 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({ 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 || []; diff --git a/src/functions/backend/db/computers/push-computer-update.ts b/src/functions/backend/db/computers/push-computer-update.ts index 3c6b4d9..c1434f8 100644 --- a/src/functions/backend/db/computers/push-computer-update.ts +++ b/src/functions/backend/db/computers/push-computer-update.ts @@ -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 } }); + } } diff --git a/src/functions/backend/db/computers/remove-bot-from-computer.ts b/src/functions/backend/db/computers/remove-bot-from-computer.ts index a495b0d..c25878f 100644 --- a/src/functions/backend/db/computers/remove-bot-from-computer.ts +++ b/src/functions/backend/db/computers/remove-bot-from-computer.ts @@ -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 }); diff --git a/src/functions/backend/db/computers/set-bot-computer.ts b/src/functions/backend/db/computers/set-bot-computer.ts index 217c0d3..969857d 100644 --- a/src/functions/backend/db/computers/set-bot-computer.ts +++ b/src/functions/backend/db/computers/set-bot-computer.ts @@ -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) { diff --git a/src/functions/backend/db/cron-jobs/create-cron-job.ts b/src/functions/backend/db/cron-jobs/create-cron-job.ts index 0d4eb92..eedf29a 100644 --- a/src/functions/backend/db/cron-jobs/create-cron-job.ts +++ b/src/functions/backend/db/cron-jobs/create-cron-job.ts @@ -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, }); diff --git a/src/functions/backend/db/cron-jobs/fire-cron-job.ts b/src/functions/backend/db/cron-jobs/fire-cron-job.ts index 34d24c5..8bb1a05 100644 --- a/src/functions/backend/db/cron-jobs/fire-cron-job.ts +++ b/src/functions/backend/db/cron-jobs/fire-cron-job.ts @@ -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; diff --git a/src/functions/backend/db/envs/grab-envs-overview.ts b/src/functions/backend/db/envs/grab-envs-overview.ts index d80669b..be71687 100644 --- a/src/functions/backend/db/envs/grab-envs-overview.ts +++ b/src/functions/backend/db/envs/grab-envs-overview.ts @@ -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 { +export default async function grabEnvsOverview(_params: Params): Promise { const [workspaces, computers, envs] = await Promise.all([ BunSQLite.sql({ - 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({ - 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(), ]); diff --git a/src/functions/backend/db/envs/save-env.ts b/src/functions/backend/db/envs/save-env.ts index e50739c..a0a2eed 100644 --- a/src/functions/backend/db/envs/save-env.ts +++ b/src/functions/backend/db/envs/save-env.ts @@ -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" diff --git a/src/functions/backend/db/profile-images/set-profile-image.ts b/src/functions/backend/db/profile-images/set-profile-image.ts index 9668009..a690ad0 100644 --- a/src/functions/backend/db/profile-images/set-profile-image.ts +++ b/src/functions/backend/db/profile-images/set-profile-image.ts @@ -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 }) diff --git a/src/functions/backend/db/users/delete-user.ts b/src/functions/backend/db/users/delete-user.ts index acd1b80..f091cbc 100644 --- a/src/functions/backend/db/users/delete-user.ts +++ b/src/functions/backend/db/users/delete-user.ts @@ -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({ - 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; } diff --git a/src/functions/backend/db/users/grab-users.ts b/src/functions/backend/db/users/grab-users.ts index 788737e..27deb48 100644 --- a/src/functions/backend/db/users/grab-users.ts +++ b/src/functions/backend/db/users/grab-users.ts @@ -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. diff --git a/src/functions/backend/db/workspaces/add-bot-to-workspace.ts b/src/functions/backend/db/workspaces/add-bot-to-workspace.ts index be3f278..37eb130 100644 --- a/src/functions/backend/db/workspaces/add-bot-to-workspace.ts +++ b/src/functions/backend/db/workspaces/add-bot-to-workspace.ts @@ -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 }); diff --git a/src/functions/backend/db/workspaces/add-computer-to-workspace.ts b/src/functions/backend/db/workspaces/add-computer-to-workspace.ts index 99967f9..95a9e55 100644 --- a/src/functions/backend/db/workspaces/add-computer-to-workspace.ts +++ b/src/functions/backend/db/workspaces/add-computer-to-workspace.ts @@ -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 }); diff --git a/src/functions/backend/db/workspaces/create-workspace.ts b/src/functions/backend/db/workspaces/create-workspace.ts index 6f17152..a3995dd 100644 --- a/src/functions/backend/db/workspaces/create-workspace.ts +++ b/src/functions/backend/db/workspaces/create-workspace.ts @@ -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< diff --git a/src/functions/backend/db/workspaces/delete-workspace.ts b/src/functions/backend/db/workspaces/delete-workspace.ts index a91a1a3..efc2f37 100644 --- a/src/functions/backend/db/workspaces/delete-workspace.ts +++ b/src/functions/backend/db/workspaces/delete-workspace.ts @@ -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) { diff --git a/src/functions/backend/db/workspaces/grab-user-workspace.ts b/src/functions/backend/db/workspaces/grab-user-workspace.ts index 4348af5..5f4c430 100644 --- a/src/functions/backend/db/workspaces/grab-user-workspace.ts +++ b/src/functions/backend/db/workspaces/grab-user-workspace.ts @@ -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 { + const access = workspacesSql({ role: await grabCrewRole({ user_id }), user_id }); + const res = await BunSQLite.sql({ - 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; diff --git a/src/functions/backend/db/workspaces/grab-user-workspaces.ts b/src/functions/backend/db/workspaces/grab-user-workspaces.ts index b3b8730..fedb348 100644 --- a/src/functions/backend/db/workspaces/grab-user-workspaces.ts +++ b/src/functions/backend/db/workspaces/grab-user-workspaces.ts @@ -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 { + const access = workspacesSql({ role: await grabCrewRole({ user_id }), user_id }); + const res = await BunSQLite.sql({ 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 || []; diff --git a/src/functions/backend/db/workspaces/grab-workspace-bot.ts b/src/functions/backend/db/workspaces/grab-workspace-bot.ts index f90afd7..a48f948 100644 --- a/src/functions/backend/db/workspaces/grab-workspace-bot.ts +++ b/src/functions/backend/db/workspaces/grab-workspace-bot.ts @@ -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 { + const role = await grabCrewRole({ user_id }); + const workspace_access = workspacesSql({ role, user_id }); + const workspace = ( await BunSQLite.sql({ - 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({ - 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 }; } diff --git a/src/functions/backend/db/workspaces/grab-workspace-computers.ts b/src/functions/backend/db/workspaces/grab-workspace-computers.ts index ee91f04..3a07f70 100644 --- a/src/functions/backend/db/workspaces/grab-workspace-computers.ts +++ b/src/functions/backend/db/workspaces/grab-workspace-computers.ts @@ -16,7 +16,7 @@ export default async function grabWorkspaceComputers({ const res = await BunSQLite.sql({ 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], diff --git a/src/functions/backend/db/workspaces/remove-bot-from-workspace.ts b/src/functions/backend/db/workspaces/remove-bot-from-workspace.ts index 91debfc..e95a461 100644 --- a/src/functions/backend/db/workspaces/remove-bot-from-workspace.ts +++ b/src/functions/backend/db/workspaces/remove-bot-from-workspace.ts @@ -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) { diff --git a/src/functions/backend/db/workspaces/remove-computer-from-workspace.ts b/src/functions/backend/db/workspaces/remove-computer-from-workspace.ts index d8779d7..099fc11 100644 --- a/src/functions/backend/db/workspaces/remove-computer-from-workspace.ts +++ b/src/functions/backend/db/workspaces/remove-computer-from-workspace.ts @@ -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) { diff --git a/src/functions/backend/db/workspaces/update-workspace.ts b/src/functions/backend/db/workspaces/update-workspace.ts index 562748d..63e54cc 100644 --- a/src/functions/backend/db/workspaces/update-workspace.ts +++ b/src/functions/backend/db/workspaces/update-workspace.ts @@ -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 }); diff --git a/src/functions/server/media/handle-media-server.ts b/src/functions/server/media/handle-media-server.ts index 2d7db1f..7f87748 100644 --- a/src/functions/server/media/handle-media-server.ts +++ b/src/functions/server/media/handle-media-server.ts @@ -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; diff --git a/src/harness/tools/bot/delegate-task-tool.ts b/src/harness/tools/bot/delegate-task-tool.ts index ba91003..958d92e 100644 --- a/src/harness/tools/bot/delegate-task-tool.ts +++ b/src/harness/tools/bot/delegate-task-tool.ts @@ -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, diff --git a/src/harness/tools/bot/grab-bot-chat-tools.ts b/src/harness/tools/bot/grab-bot-chat-tools.ts index a5e9e7a..410199e 100644 --- a/src/harness/tools/bot/grab-bot-chat-tools.ts +++ b/src/harness/tools/bot/grab-bot-chat-tools.ts @@ -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({ 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 }), ]), ), ); diff --git a/src/harness/tools/bot/update-instructions-tool.ts b/src/harness/tools/bot/update-instructions-tool.ts index 835cf2b..5f8483b 100644 --- a/src/harness/tools/bot/update-instructions-tool.ts +++ b/src/harness/tools/bot/update-instructions-tool.ts @@ -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) }), diff --git a/src/harness/tools/bot/update-memory-tool.ts b/src/harness/tools/bot/update-memory-tool.ts index b873da9..30333af 100644 --- a/src/harness/tools/bot/update-memory-tool.ts +++ b/src/harness/tools/bot/update-memory-tool.ts @@ -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.`; }, }; } diff --git a/src/layouts/bots/(partials)/rail-add-button.tsx b/src/layouts/bots/(partials)/rail-add-button.tsx index 7ffa254..8d5c33b 100644 --- a/src/layouts/bots/(partials)/rail-add-button.tsx +++ b/src/layouts/bots/(partials)/rail-add-button.tsx @@ -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 ( } targetWrapperProps={{ className: "shrink-0" }} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx index 16fe6e0..7739524 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-thinking-row.tsx @@ -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 ( diff --git a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx index bea0b21..bf881fc 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx @@ -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} /> ))} - + ); diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index 64f7fb8..db9a134 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -106,7 +106,8 @@ export default function Conversation() { = 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 = 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}` } }; } diff --git a/src/pages/admin/bots/index.server.ts b/src/pages/admin/bots/index.server.ts index 43804f0..83e70d3 100644 --- a/src/pages/admin/bots/index.server.ts +++ b/src/pages/admin/bots/index.server.ts @@ -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 = 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 = 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 } }; }; diff --git a/src/pages/admin/bots/index.tsx b/src/pages/admin/bots/index.tsx index 80d7964..58ca3e5 100644 --- a/src/pages/admin/bots/index.tsx +++ b/src/pages/admin/bots/index.tsx @@ -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 ; } export const meta: BunextPageModuleMeta = { diff --git a/src/pages/admin/people/create/(functions)/submit-user-form.ts b/src/pages/admin/people/create/(functions)/submit-user-form.ts index 73c6f15..a2f4534 100644 --- a/src/pages/admin/people/create/(functions)/submit-user-form.ts +++ b/src/pages/admin/people/create/(functions)/submit-user-form.ts @@ -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 || [] } + : {}), }, }); diff --git a/src/pages/admin/people/create/(partials)/user-form/index.tsx b/src/pages/admin/people/create/(partials)/user-form/index.tsx index 62720ec..87254fa 100644 --- a/src/pages/admin/people/create/(partials)/user-form/index.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/index.tsx @@ -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({ @@ -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) { + diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-access.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-access.tsx new file mode 100644 index 0000000..20381c1 --- /dev/null +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-access.tsx @@ -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> & { + 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 ( + + ({ + 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 })); + }} + /> + ({ + 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 })); + }} + /> + + ); +} diff --git a/src/pages/admin/settings/(functions)/grab-crew-settings-server-props.ts b/src/pages/admin/settings/(functions)/grab-crew-settings-server-props.ts new file mode 100644 index 0000000..fdbbbbe --- /dev/null +++ b/src/pages/admin/settings/(functions)/grab-crew-settings-server-props.ts @@ -0,0 +1,27 @@ +import type { PagePropsType } from "@/src/types"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; +import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; +import grabSettingsServerProps from "./grab-settings-server-props"; + +type Params = Parameters>[0]; + +/** + * `grabSettingsServerProps` for the pages that manage the crew (bots, + * workspaces, computers): super admins and admins only, everyone else + * lands on the general settings. + */ +export default async function grabCrewSettingsServerProps(params: Params) { + const settings = await grabSettingsServerProps(params); + + if (!settings.props?.person?.id) return settings; + + const { user_types } = await userAuth({ req: params.req }); + + if (!canManageCrew({ user_types })) { + return { redirect: { destination: SettingsDefaultPath, permanent: false }, props: undefined }; + } + + return settings; +} diff --git a/src/pages/admin/settings/bots/index.server.ts b/src/pages/admin/settings/bots/index.server.ts index 946a32a..c75cbeb 100644 --- a/src/pages/admin/settings/bots/index.server.ts +++ b/src/pages/admin/settings/bots/index.server.ts @@ -1,10 +1,10 @@ import type { PagePropsType } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import grabBots from "@/src/functions/backend/db/bots/grab-bots"; -import grabSettingsServerProps from "../(functions)/grab-settings-server-props"; +import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props"; export const server: BunextPageServerFn = async (params) => { - const settings = await grabSettingsServerProps(params); + const settings = await grabCrewSettingsServerProps(params); if (!settings.props?.person?.id) return settings; diff --git a/src/pages/admin/settings/computers/index.server.ts b/src/pages/admin/settings/computers/index.server.ts index c1952be..ae45491 100644 --- a/src/pages/admin/settings/computers/index.server.ts +++ b/src/pages/admin/settings/computers/index.server.ts @@ -1,10 +1,10 @@ import type { PagePropsType } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers"; -import grabSettingsServerProps from "../(functions)/grab-settings-server-props"; +import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props"; export const server: BunextPageServerFn = async (params) => { - const settings = await grabSettingsServerProps(params); + const settings = await grabCrewSettingsServerProps(params); if (!settings.props?.person?.id) return settings; diff --git a/src/pages/admin/settings/users/index.server.ts b/src/pages/admin/settings/users/index.server.ts index 83f1cfc..4e6755b 100644 --- a/src/pages/admin/settings/users/index.server.ts +++ b/src/pages/admin/settings/users/index.server.ts @@ -4,11 +4,13 @@ import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict"; import userAuth from "@/src/functions/backend/auth/user-auth"; import canListUsers from "@/src/utils/rbac/can-list-users"; import grabUsers from "@/src/functions/backend/db/users/grab-users"; +import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options"; import grabSettingsServerProps from "../(functions)/grab-settings-server-props"; /** * For those who may list users (`canListUsers`: super admins, admins); - * everyone else lands on the general settings. + * everyone else lands on the general settings. Carries every worker and + * workspace, to pick what each user reaches. */ export const server: BunextPageServerFn = async (params) => { const settings = await grabSettingsServerProps(params); @@ -25,6 +27,7 @@ export const server: BunextPageServerFn = async (params) => { props: { ...settings.props, persons: (await grabUsers()).payload || [], + ...(await grabCrewOptions()), }, }; }; diff --git a/src/pages/admin/settings/workspaces/index.server.ts b/src/pages/admin/settings/workspaces/index.server.ts index 34bbd39..44f60af 100644 --- a/src/pages/admin/settings/workspaces/index.server.ts +++ b/src/pages/admin/settings/workspaces/index.server.ts @@ -1,10 +1,10 @@ import type { PagePropsType } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; -import grabSettingsServerProps from "../(functions)/grab-settings-server-props"; +import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props"; export const server: BunextPageServerFn = async (params) => { - const settings = await grabSettingsServerProps(params); + const settings = await grabCrewSettingsServerProps(params); if (!settings.props?.person?.id) return settings; diff --git a/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-overview.tsx b/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-overview.tsx index ff3b600..748b4b7 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-overview.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-overview.tsx @@ -1,5 +1,7 @@ import { useContext, useState } from "react"; +import EmptyContent from "@moduletrace/twui/elements/EmptyContent"; import { AppContext } from "@/src/pages/__root"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell"; import WorkspaceBotsGroup from "../(partials)/workspace-bots-group"; import WorkspaceComputersGroup from "../(partials)/workspace-computers-group"; @@ -11,7 +13,8 @@ import WorkspaceSectionHeading from "../(partials)/workspace-section-heading"; /** * A workspace's page beside its rail: its context, bots, computers, then - * its models, and deleting it. + * its models, and deleting it. A member only lands here while it has no + * bots yet. */ export default function WorkspaceOverview() { const { pageProps } = useContext(AppContext); @@ -19,6 +22,14 @@ export default function WorkspaceOverview() { if (!workspace?.id) return null; + if (!canManageCrew({ user_types: pageProps?.user_types })) { + return ( + + + + ); + } + const workspace_id = Number(workspace.id); const harnesses = pageProps?.workspace_harness_overview?.harnesses; diff --git a/src/pages/admin/workspaces/[workspace_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/index.server.ts index cd62cc0..c7f1599 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/index.server.ts @@ -6,16 +6,18 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user- import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers"; import { AdminHomePath } from "@/src/dict/admin-sections-dict"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import type { PagePropsType, PageQueryObject } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; /** * A workspace's page: its details, crew, computers and models, with the - * rail showing its crew. + * rail showing its crew. Only super admins and admins manage it; a member + * opens its first bot instead. */ export const server: BunextPageServerFn = 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" } }; @@ -30,6 +32,19 @@ export const server: BunextPageServerFn = async (params) => { const workspace_id = Number(workspace.id); + /** + * Members don't manage it: they go straight to its first bot. + */ + if (!canManageCrew({ user_types })) { + const workspace_bots = await grabWorkspaceBots({ user_id: user.id, workspace_id }); + + if (workspace_bots[0]?.id) { + return { redirect: { destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}` } }; + } + + return { props: { workspaces, workspace, bots: workspace_bots, workspace_bots } }; + } + const [workspace_bots, all_bots, workspace_computers, computers, workspace_harness_overview] = await Promise.all([ grabWorkspaceBots({ user_id: user.id, workspace_id }), diff --git a/src/pages/api/admin/bots/list-sessions.ts b/src/pages/api/admin/bots/list-sessions.ts index 715b6d4..3e9f466 100644 --- a/src/pages/api/admin/bots/list-sessions.ts +++ b/src/pages/api/admin/bots/list-sessions.ts @@ -39,6 +39,7 @@ export const handler: BunextAPIRouteHandler< return { success: true, payload: await grabBotSessions({ + user_id: user.id, bot_id: conversation.bot.id, workspace_id: conversation.workspace_id, query, diff --git a/src/pages/api/admin/bots/new-session.ts b/src/pages/api/admin/bots/new-session.ts index 94bcb98..4464a83 100644 --- a/src/pages/api/admin/bots/new-session.ts +++ b/src/pages/api/admin/bots/new-session.ts @@ -44,6 +44,7 @@ export const handler: BunextAPIRouteHandler< return { success: true, singleRes: await startBotSession({ + user_id: user.id, bot_id: conversation.bot.id, workspace_id: conversation.workspace_id, }), diff --git a/src/pages/api/admin/bots/save-harness-settings.ts b/src/pages/api/admin/bots/save-harness-settings.ts index b8a9d73..8c66f4c 100644 --- a/src/pages/api/admin/bots/save-harness-settings.ts +++ b/src/pages/api/admin/bots/save-harness-settings.ts @@ -9,6 +9,7 @@ import type { BotHarnessOverviewPayload } from "@/src/types"; import AppError from "@/src/utils/app-error"; import parseWithSchema from "@/src/utils/parse-with-schema"; import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user"; +import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import safeErrorMessage from "@/src/utils/safe-error-message"; import type { APIResponseObject, @@ -37,6 +38,10 @@ export const handler: BunextAPIRouteHandler< throw new AppError(`This user can only read records.`); } + if (!canManageCrew({ user_types })) { + throw new AppError(`Only an admin can do that.`); + } + const { bot_id, settings } = parseWithSchema({ schema: SaveBotHarnessSettingsRequestSchema, data: body, diff --git a/src/pages/api/admin/bots/send-message.ts b/src/pages/api/admin/bots/send-message.ts index 53a3ab5..2fb3a74 100644 --- a/src/pages/api/admin/bots/send-message.ts +++ b/src/pages/api/admin/bots/send-message.ts @@ -17,8 +17,8 @@ import type { } from "@moduletrace/bunext/types"; /** - * Posts a message from the session user to one of their own bots, in its - * own chat or in a workspace it's in, and queues the bot's reply, which + * Posts a message from the session user to a bot they chat with, in its + * own chat or in a workspace, and queues the bot's reply, which * arrives over the WebSocket. The message goes in the conversation's * active session (the next one when it's full) and also to the user's * other open tabs. Files are uploaded first (`upload-attachment`) and @@ -57,6 +57,7 @@ export const handler: BunextAPIRouteHandler = async ({ const media = await grabAttachableMedia({ user_id: Number(user.id), media_ids }); const session = await ensureBotSession({ + user_id: user.id, bot_id: bot.id, workspace_id: conversation.workspace_id, rollover: true, diff --git a/src/pages/api/admin/users/save-user.ts b/src/pages/api/admin/users/save-user.ts index 16759e7..621b15d 100644 --- a/src/pages/api/admin/users/save-user.ts +++ b/src/pages/api/admin/users/save-user.ts @@ -1,10 +1,9 @@ import userAuth from "@/src/functions/backend/auth/user-auth"; -import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot"; -import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer"; +import setUserAccess from "@/src/functions/backend/db/access/set-user-access"; import grabUsers from "@/src/functions/backend/db/users/grab-users"; import saveUser from "@/src/functions/backend/db/users/save-user"; import setUserProfileImage from "@/src/functions/backend/db/users/set-user-profile-image"; -import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger"; +import logEvent, { grabRequestIP } from "@/src/functions/backend/logger"; import { SaveUserRequestSchema } from "@/src/schemas/user-schema"; import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; import AppError from "@/src/utils/app-error"; @@ -22,8 +21,10 @@ import type { /** * Creates a user (`canCreateUser`) or updates one (`canUpdateUser`): * name, email, username, password, user types (each checked against the - * assignment matrix) and, when one is sent, their profile photo. A new user gets their coordinator and main computer - * straight away, as at signup. Returns the user. + * assignment matrix), when one is sent their profile photo, and when sent + * the workers assigned to them and the workspaces they're in + * (`setUserAccess`). Whoever may create or update users manages the crew + * too (super admins and admins). Returns the user. */ export const handler: BunextAPIRouteHandler> = async ({ req, body }) => { try { @@ -64,21 +65,8 @@ export const handler: BunextAPIRouteHandler ensureMainComputer({ user_id, coordinator_id })) - .catch((error) => - logError({ - title: "Coordinator or main computer not created with the user", - error, - user_id, - ip_address: grabRequestIP(req), - }).catch(() => {}), - ); + if (data.bot_ids || data.workspace_ids) { + await setUserAccess({ user_id, bot_ids: data.bot_ids, workspace_ids: data.workspace_ids }); } await logEvent({ @@ -86,7 +74,13 @@ export const handler: BunextAPIRouteHandler> = a throw new AppError(`This user can only read records.`); } + if (!canManageCrew({ user_types })) { + throw new AppError(`Only an admin can do that.`); + } + const { workspace_id, bot_id, system_prompt, memory } = parseWithSchema({ schema: UpdateWorkspaceBotRequestSchema, data: body }); await updateWorkspaceBot({ user_id: user.id, workspace_id, bot_id, system_prompt, memory }); diff --git a/src/schemas/user-schema.ts b/src/schemas/user-schema.ts index bbafca0..bc7d2c9 100644 --- a/src/schemas/user-schema.ts +++ b/src/schemas/user-schema.ts @@ -26,6 +26,12 @@ export const SaveUserRequestSchema = z * A new profile photo as a base64 data URL; left out keeps theirs. */ image: z.string().startsWith("data:image/", "Pick an image").nullish(), + /** + * Every worker assigned to them and every workspace they're in, + * replacing the old lists. Left out: unchanged. + */ + bot_ids: z.array(z.coerce.number().int().positive()).optional(), + workspace_ids: z.array(z.coerce.number().int().positive()).optional(), }) .refine((data) => data.password == data.confirm_password, { message: "The passwords don't match", diff --git a/src/scripts/move-bot-memory-to-owners.ts b/src/scripts/move-bot-memory-to-owners.ts new file mode 100644 index 0000000..ae6d349 --- /dev/null +++ b/src/scripts/move-bot-memory-to-owners.ts @@ -0,0 +1,40 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import grabBotUserMemory from "@/src/functions/backend/db/bots/grab-bot-user-memory"; +import updateBotUserMemory from "@/src/functions/backend/db/bots/update-bot-user-memory"; + +/** + * One-off: before `bot_user_memories`, bots saved what they learned about + * their owner in their core memory (`bots.memory`), which every user they + * work for would see. This moves each bot's core memory into its owner's + * memory (after anything already there) and empties the core one. + * + * Run once per install: `bun src/scripts/move-bot-memory-to-owners.ts`. + * Run again later it would move real core memory too. + */ +async function moveBotMemoryToOwners() { + const res = await BunSQLite.sql({ + sql: `SELECT id, user_id, memory FROM bots WHERE TRIM(COALESCE(memory, '')) != ''`, + }); + + const bots = res.payload || []; + + for (const bot of bots) { + const existing = await grabBotUserMemory({ bot_id: bot.id!, user_id: bot.user_id! }); + const memory = [existing?.trim(), bot.memory!.trim()].filter(Boolean).join("\n\n"); + + await updateBotUserMemory({ user_id: bot.user_id!, bot_id: bot.id!, memory }); + + await BunSQLite.sql({ + sql: `UPDATE bots SET memory = '', updated_at = ? WHERE id = ?`, + values: [Date.now(), bot.id!], + }); + + console.log(`Moved bot ${bot.id}'s memory to user ${bot.user_id}.`); + } + + console.log(`Done: ${bots.length} bot(s).`); +} + +await moveBotMemoryToOwners(); +process.exit(0); diff --git a/src/scripts/seed-bots.ts b/src/scripts/seed-bots.ts index 6f38d00..0914911 100644 --- a/src/scripts/seed-bots.ts +++ b/src/scripts/seed-bots.ts @@ -210,7 +210,7 @@ async function insertBot({ values: [bot.face, bot.status_line, coordinator_id], }); - await insertMessages({ bot_id: coordinator_id, bot, now, last_ago }); + await insertMessages({ user_id, bot_id: coordinator_id, bot, now, last_ago }); console.log(` ${bot.name} (#${coordinator_id}, existing)`); return; @@ -237,7 +237,16 @@ async function insertBot({ throw new Error(`Failed to create bot ${bot.name}: ${bot_res.msg}`); } - const session_id = await insertMessages({ bot_id, bot, now, last_ago }); + /** + * Bots belong to the install: the user reaches a sample worker once + * it's assigned to them. + */ + await BunSQLite.sql({ + sql: `INSERT OR IGNORE INTO bot_users (created_at, updated_at, bot_id, user_id) VALUES (?, ?, ?, ?)`, + values: [now, now, bot_id, user_id], + }); + + const session_id = await insertMessages({ user_id, bot_id, bot, now, last_ago }); if (bot.task) { await insertTask({ bot_id, session_id, task: bot.task }); @@ -247,21 +256,23 @@ async function insertBot({ } /** - * Adds the bot's sample messages to its own chat's active session and - * returns the session's id. + * Adds the bot's sample messages to the user's own chat with it (its + * active session) and returns the session's id. */ async function insertMessages({ + user_id, bot_id, bot, now, last_ago, }: { + user_id: number; bot_id: number; bot: SeedBot; now: number; last_ago: number; }) { - const session = await ensureBotSession({ bot_id }); + const session = await ensureBotSession({ user_id, bot_id }); const session_id = Number(session.id); await BunSQLite.sql({ @@ -363,7 +374,7 @@ async function seedBots() { if (existing_workers?.length) { console.log( - `User #${user.id} <${EMAIL}> already has ${existing_workers.length} worker bots. Skipping.`, + `User #${user.id} <${EMAIL}> already created ${existing_workers.length} worker bots. Skipping.`, ); return; } diff --git a/src/scripts/share-crew.ts b/src/scripts/share-crew.ts new file mode 100644 index 0000000..9c018ee --- /dev/null +++ b/src/scripts/share-crew.ts @@ -0,0 +1,176 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db"; +import { MainComputerName } from "@/src/dict/computer-dict"; + +/** + * One-off, safe to run again: moves an install from one crew per user to + * one shared crew. Before, every user owned their bots, workspaces and + * computers, with a coordinator and a main computer each; now they belong + * to the install, with one coordinator (the super admin's alone) and one + * main computer, and users reach bots through `bot_users` and + * `workspace_users`. + * + * - Every conversation is given to the user who owned its bot. + * - The super admin's coordinator is kept; every other one is folded into + * it (conversations, jobs, routines, memories kept), then deleted. + * - The super admin's main computer is kept; the others become sandboxes. + * - A member keeps reaching what they had: their workers are assigned to + * them and they're added to their workspaces. + * + * Run once per install: `bun src/scripts/share-crew.ts`. + */ +async function shareCrew() { + const now = Date.now(); + + await BunSQLite.sql({ + sql: `UPDATE bot_sessions SET user_id = (SELECT user_id FROM bots WHERE bots.id = bot_sessions.bot_id) + WHERE user_id IS NULL`, + }); + + const super_admin_id = Number( + ( + await BunSQLite.sql<{ user_id: number }>({ + sql: `SELECT user_id FROM user_types WHERE user_type = 'super_admin' ORDER BY user_id ASC LIMIT 1`, + }) + ).payload?.[0]?.user_id, + ); + + if (!super_admin_id) { + console.log(`No super admin yet: nothing to share.`); + return; + } + + await foldCoordinators({ super_admin_id, now }); + await keepOneMainComputer({ super_admin_id, now }); + await keepMembersAccess({ now }); + await keepOneActiveSession({ now }); + + console.log(`Done.`); +} + +async function foldCoordinators({ super_admin_id, now }: { super_admin_id: number; now: number }) { + const coordinators = + ( + await BunSQLite.sql({ + sql: `SELECT id, user_id FROM bots WHERE kind = 'coordinator' + ORDER BY user_id = ? DESC, id ASC`, + values: [super_admin_id], + }) + ).payload || []; + + const [kept, ...extra] = coordinators; + + if (!kept?.id) return; + + for (const bot of extra) { + for (const [table, column] of [ + ["bot_sessions", "bot_id"], + ["bot_messages", "bot_id"], + ["bot_tasks", "bot_id"], + ["bots_queue", "bot_id"], + ["bots_queue", "requested_by_bot_id"], + ["bots_queue_updates", "bot_id"], + ["cron_jobs", "bot_id"], + ] as const) { + await BunSQLite.sql({ + sql: `UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`, + values: [kept.id, bot.id!], + }); + } + + /** + * What it remembered about a user joins what the kept one has. + */ + await BunSQLite.sql({ + sql: `INSERT OR IGNORE INTO bot_user_memories (created_at, updated_at, bot_id, user_id, memory) + SELECT ?, ?, ?, user_id, '' FROM bot_user_memories WHERE bot_id = ?`, + values: [now, now, kept.id, bot.id!], + }); + + await BunSQLite.sql({ + sql: `UPDATE bot_user_memories SET updated_at = ?, memory = TRIM(memory || char(10) || char(10) || ( + SELECT old.memory FROM bot_user_memories old WHERE old.bot_id = ? AND old.user_id = bot_user_memories.user_id + ), char(10)) + WHERE bot_id = ? AND user_id IN (SELECT user_id FROM bot_user_memories WHERE bot_id = ? AND TRIM(COALESCE(memory, '')) != '')`, + values: [now, bot.id!, kept.id, bot.id!], + }); + + await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id!] }); + + console.log(`Folded coordinator #${bot.id} (user #${bot.user_id}) into #${kept.id}.`); + } +} + +async function keepOneMainComputer({ super_admin_id, now }: { super_admin_id: number; now: number }) { + const mains = + ( + await BunSQLite.sql({ + sql: `SELECT id, user_id, name FROM computers WHERE kind = 'main' ORDER BY user_id = ? DESC, id ASC`, + values: [super_admin_id], + }) + ).payload || []; + + for (const computer of mains.slice(1)) { + await BunSQLite.sql({ + sql: `UPDATE computers SET kind = 'sandbox', name = ?, updated_at = ? WHERE id = ?`, + values: [ + computer.name == MainComputerName ? `Computer ${computer.id}` : computer.name || `Computer ${computer.id}`, + now, + computer.id!, + ], + }); + + console.log(`Computer #${computer.id} (user #${computer.user_id}) is a sandbox now.`); + } +} + +/** + * Members (not super admins or admins, who reach everything) keep the + * workers and workspaces that were theirs. + */ +async function keepMembersAccess({ now }: { now: number }) { + const members = `user_id NOT IN (SELECT user_id FROM user_types WHERE user_type IN ('super_admin', 'admin'))`; + + await BunSQLite.sql({ + sql: `INSERT OR IGNORE INTO bot_users (created_at, updated_at, bot_id, user_id) + SELECT ?, ?, id, user_id FROM bots WHERE kind = 'worker' AND user_id IS NOT NULL AND ${members}`, + values: [now, now], + }); + + await BunSQLite.sql({ + sql: `INSERT OR IGNORE INTO workspace_users (created_at, updated_at, workspace_id, user_id) + SELECT ?, ?, id, user_id FROM workspaces WHERE user_id IS NOT NULL AND ${members}`, + values: [now, now], + }); +} + +/** + * One active session per user's conversation: an empty extra one is + * deleted, and of the rest only the latest stays active. + */ +async function keepOneActiveSession({ now }: { now: number }) { + const same = `s2.user_id IS s.user_id AND s2.bot_id = s.bot_id AND s2.workspace_id IS s.workspace_id + AND s2.status = 'active' AND s2.id != s.id`; + + await BunSQLite.sql({ + sql: `DELETE FROM bot_sessions WHERE id IN ( + SELECT s.id FROM bot_sessions s + WHERE s.status = 'active' + AND NOT EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id) + AND EXISTS (SELECT 1 FROM bot_sessions s2 WHERE ${same}) + )`, + }); + + await BunSQLite.sql({ + sql: `UPDATE bot_sessions SET status = 'closed', updated_at = ? WHERE id IN ( + SELECT s.id FROM bot_sessions s + WHERE s.status = 'active' + AND EXISTS (SELECT 1 FROM bot_sessions s2 WHERE ${same} + AND (s2.last_activity_at > s.last_activity_at OR (s2.last_activity_at = s.last_activity_at AND s2.id > s.id))) + )`, + values: [now], + }); +} + +await shareCrew(); +process.exit(0); diff --git a/src/types/index.ts b/src/types/index.ts index 1b262a1..e1864dc 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -329,6 +329,12 @@ export type PagePropsType = { user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null; person?: BUN_SQLITE_COBBLE_USERS_JOIN | null; persons?: BUN_SQLITE_COBBLE_USERS_JOIN[] | null; + /** + * Every worker and workspace, to pick what a user reaches + * (`grabCrewOptions`). + */ + crew_bots?: BUN_SQLITE_COBBLE_BOTS[] | null; + crew_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null; bots?: BotWithLastMessage[] | null; bot?: BotWithLastMessage | null; bot_messages?: BotMessageWithAttachments[] | null; @@ -755,6 +761,8 @@ export type AdminFetchParams = { export type UserFormObject = BUN_SQLITE_COBBLE_USERS & { user_types?: UserType[]; + bot_ids?: number[]; + workspace_ids?: number[]; password?: string; confirm_password?: string; }; diff --git a/src/types/sql-joins.ts b/src/types/sql-joins.ts index 9a5089b..7d2a1b2 100644 --- a/src/types/sql-joins.ts +++ b/src/types/sql-joins.ts @@ -8,6 +8,14 @@ import type { export type BUN_SQLITE_COBBLE_USERS_JOIN = BUN_SQLITE_COBBLE_USERS & { user_types?: string; profile_media_id?: number; + /** + * The workers assigned to them (`bot_users`), comma separated. + */ + bot_ids?: string | null; + /** + * The workspaces they were added to (`workspace_users`), comma separated. + */ + workspace_ids?: string | null; }; export type BUN_SQLITE_COBBLE_MEDIA_PARADIGMS_JOIN = diff --git a/src/utils/compose-bot-prompt.test.ts b/src/utils/compose-bot-prompt.test.ts index 718d006..7e32753 100644 --- a/src/utils/compose-bot-prompt.test.ts +++ b/src/utils/compose-bot-prompt.test.ts @@ -6,7 +6,8 @@ describe("composeBotPrompt", () => { expect( composeBotPrompt({ system_prompt: "# You are Scout\n\nFind things.", - memory: "- The user prefers short replies.", + memory: "- Cite sources.", + user_memory: "- The user prefers short replies.", workspace: { name: "Launch", description: "The spring launch.", @@ -22,7 +23,8 @@ describe("composeBotPrompt", () => { "# You are Scout\n\nFind things.", `# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe spring launch.\n\nShip by Friday.`, "# Your instructions in Launch\n\nOwn the changelog.", - "# Memory\n\n- The user prefers short replies.", + "# Memory\n\n- Cite sources.", + "# Memory about the user\n\n- The user prefers short replies.", "# Memory in Launch\n\n- Marketing signs off on copy.", ].join("\n\n"), ); @@ -33,6 +35,7 @@ describe("composeBotPrompt", () => { composeBotPrompt({ system_prompt: "Core.", memory: " ", + user_memory: "", workspace_bot: { system_prompt: "Ignored." }, }), ).toBe("Core."); diff --git a/src/utils/compose-bot-prompt.ts b/src/utils/compose-bot-prompt.ts index 990d35d..735e4d1 100644 --- a/src/utils/compose-bot-prompt.ts +++ b/src/utils/compose-bot-prompt.ts @@ -12,6 +12,10 @@ type WorkspaceBotPrompt = { type Params = { system_prompt?: string | null; memory?: string | null; + /** + * What the bot remembers about the user it's working with. + */ + user_memory?: string | null; /** * The workspace the bot is working in, if any. */ @@ -25,13 +29,14 @@ type Params = { /** * The full prompt a bot runs with (markdown): its own core instructions, * then, in a workspace, the workspace's context and the bot's - * instructions there, then its core memory and its memory there. Empty - * parts are left out; the workspace heading always shows, so the bot - * knows where it is. + * instructions there, then its core memory, its memory about the user and + * its memory in the workspace. Empty parts are left out; the workspace + * heading always shows, so the bot knows where it is. */ export default function composeBotPrompt({ system_prompt, memory, + user_memory, workspace, workspace_bot, }: Params) { @@ -63,6 +68,10 @@ export default function composeBotPrompt({ parts.push(`# Memory\n\n${memory.trim()}`); } + if (user_memory?.trim()) { + parts.push(`# Memory about the user\n\n${user_memory.trim()}`); + } + if (workspace && workspace_bot?.memory?.trim()) { parts.push(`# Memory in ${name}\n\n${workspace_bot.memory.trim()}`); } diff --git a/src/utils/crew-access/chat-bots-sql.ts b/src/utils/crew-access/chat-bots-sql.ts new file mode 100644 index 0000000..2fa0819 --- /dev/null +++ b/src/utils/crew-access/chat-bots-sql.ts @@ -0,0 +1,16 @@ +import type { CrewAccessParams, SqlCondition } from "./types"; + +/** + * The bots a user chats with in their own chat (outside a workspace): the + * super admin all of them, an admin every worker, anyone else only the + * workers assigned to them. The coordinator is the super admin's alone. + */ +export default function chatBotsSql({ role, user_id, alias = "b" }: CrewAccessParams): SqlCondition { + if (role == "super_admin") return { sql: `1`, values: [] }; + if (role == "admin") return { sql: `${alias}.kind != 'coordinator'`, values: [] }; + + return { + sql: `(${alias}.kind != 'coordinator' AND ${alias}.id IN (SELECT bot_id FROM bot_users WHERE user_id = ?))`, + values: [Number(user_id)], + }; +} diff --git a/src/utils/crew-access/computers-sql.ts b/src/utils/crew-access/computers-sql.ts new file mode 100644 index 0000000..bf0ff76 --- /dev/null +++ b/src/utils/crew-access/computers-sql.ts @@ -0,0 +1,22 @@ +import type { CrewAccessParams, SqlCondition } from "./types"; + +/** + * The computers a user opens and sees: super admins and admins every one, + * anyone else only those of the bots they chat with (the computers of + * their workspaces, and those their assigned workers were given). + */ +export default function computersSql({ role, user_id, alias = "c" }: CrewAccessParams): SqlCondition { + if (role != "member") return { sql: `1`, values: [] }; + + return { + sql: `${alias}.id IN ( + SELECT wc.computer_id FROM workspace_computers wc + JOIN workspace_users wu ON wu.workspace_id = wc.workspace_id AND wu.user_id = ? + UNION + SELECT bc.computer_id FROM bot_computers bc + JOIN bot_users bu ON bu.bot_id = bc.bot_id AND bu.user_id = ? + JOIN bots b ON b.id = bc.bot_id AND b.kind != 'coordinator' + )`, + values: [Number(user_id), Number(user_id)], + }; +} diff --git a/src/utils/crew-access/crew-access-sql.test.ts b/src/utils/crew-access/crew-access-sql.test.ts new file mode 100644 index 0000000..2355a3d --- /dev/null +++ b/src/utils/crew-access/crew-access-sql.test.ts @@ -0,0 +1,75 @@ +import { Database } from "bun:sqlite"; +import { beforeAll, describe, expect, test } from "bun:test"; +import chatBotsSql from "./chat-bots-sql"; +import computersSql from "./computers-sql"; +import workspaceBotsSql from "./workspace-bots-sql"; +import workspacesSql from "./workspaces-sql"; +import type { SqlCondition } from "./types"; + +const db = new Database(":memory:"); + +beforeAll(() => { + db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY, kind TEXT)`); + db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY)`); + db.run(`CREATE TABLE bot_users (bot_id INTEGER, user_id INTEGER)`); + db.run(`CREATE TABLE workspace_users (workspace_id INTEGER, user_id INTEGER)`); + db.run(`CREATE TABLE workspace_bots (workspace_id INTEGER, bot_id INTEGER)`); + db.run(`INSERT INTO bots VALUES (1, 'coordinator'), (2, 'worker'), (3, 'worker')`); + db.run(`INSERT INTO workspaces VALUES (10), (11)`); + db.run(`INSERT INTO bot_users VALUES (2, 7), (1, 7)`); + db.run(`INSERT INTO workspace_users VALUES (11, 7)`); + db.run(`INSERT INTO workspace_bots VALUES (11, 3)`); + db.run(`CREATE TABLE computers (id INTEGER PRIMARY KEY)`); + db.run(`CREATE TABLE workspace_computers (workspace_id INTEGER, computer_id INTEGER)`); + db.run(`CREATE TABLE bot_computers (bot_id INTEGER, computer_id INTEGER)`); + db.run(`INSERT INTO computers VALUES (20), (21), (22), (23)`); + db.run(`INSERT INTO workspace_computers VALUES (11, 21), (10, 23)`); + db.run(`INSERT INTO bot_computers VALUES (1, 20), (2, 22)`); +}); + +function ids({ table, alias, condition }: { table: string; alias: string; condition: SqlCondition }) { + return db + .query(`SELECT ${alias}.id FROM ${table} ${alias} WHERE ${condition.sql} ORDER BY ${alias}.id`) + .all(...condition.values) + .map((row) => (row as { id: number }).id); +} + +describe("chatBotsSql", () => { + test("the super admin chats with every bot, the coordinator too", () => { + expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "super_admin", user_id: 1 }) })).toEqual([1, 2, 3]); + }); + + test("an admin chats with every worker, never the coordinator", () => { + expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "admin", user_id: 5 }) })).toEqual([2, 3]); + }); + + test("a member only with workers assigned to them, even if the coordinator was", () => { + expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 7 }) })).toEqual([2]); + expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 8 }) })).toEqual([]); + }); +}); + +describe("workspacesSql", () => { + test("managers reach every workspace, members only theirs", () => { + expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "admin", user_id: 5 }) })).toEqual([10, 11]); + expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "member", user_id: 7 }) })).toEqual([11]); + expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "member", user_id: 8 }) })).toEqual([]); + }); +}); + +describe("workspaceBotsSql", () => { + test("the coordinator joins the workspace's workers for the super admin only", () => { + expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "super_admin", workspace_id: 11 }) })).toEqual([1, 3]); + expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "admin", workspace_id: 11 }) })).toEqual([3]); + expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "member", workspace_id: 11 }) })).toEqual([3]); + expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "member", workspace_id: 10 }) })).toEqual([]); + }); +}); + +describe("computersSql", () => { + test("managers see every computer, members those of their bots, never the coordinator's", () => { + expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "admin", user_id: 5 }) })).toEqual([20, 21, 22, 23]); + expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 7 }) })).toEqual([21, 22]); + expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 8 }) })).toEqual([]); + }); +}); diff --git a/src/utils/crew-access/types.ts b/src/utils/crew-access/types.ts new file mode 100644 index 0000000..951132d --- /dev/null +++ b/src/utils/crew-access/types.ts @@ -0,0 +1,18 @@ +import type { CrewRole } from "../rbac/crew-role"; + +/** + * A SQL condition with its values, to splice into a WHERE. + */ +export type SqlCondition = { + sql: string; + values: (string | number)[]; +}; + +export type CrewAccessParams = { + role: CrewRole; + user_id: number | string; + /** + * The table alias the condition reads. + */ + alias?: string; +}; diff --git a/src/utils/crew-access/workspace-bots-sql.ts b/src/utils/crew-access/workspace-bots-sql.ts new file mode 100644 index 0000000..5574f63 --- /dev/null +++ b/src/utils/crew-access/workspace-bots-sql.ts @@ -0,0 +1,20 @@ +import type { CrewAccessParams, SqlCondition } from "./types"; + +type Params = Pick & { + workspace_id: number | string; +}; + +/** + * The bots of one workspace a user chats with there (check the user is in + * the workspace first, `workspacesSql`): its workers, plus for the super + * admin the coordinator, which works in every workspace. + */ +export default function workspaceBotsSql({ role, workspace_id, alias = "b" }: Params): SqlCondition { + const members = `${alias}.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?)`; + + if (role == "super_admin") { + return { sql: `(${alias}.kind = 'coordinator' OR ${members})`, values: [Number(workspace_id)] }; + } + + return { sql: `(${alias}.kind != 'coordinator' AND ${members})`, values: [Number(workspace_id)] }; +} diff --git a/src/utils/crew-access/workspaces-sql.ts b/src/utils/crew-access/workspaces-sql.ts new file mode 100644 index 0000000..5d7acba --- /dev/null +++ b/src/utils/crew-access/workspaces-sql.ts @@ -0,0 +1,14 @@ +import type { CrewAccessParams, SqlCondition } from "./types"; + +/** + * The workspaces a user works in: super admins and admins every one, + * anyone else only those they were added to. + */ +export default function workspacesSql({ role, user_id, alias = "w" }: CrewAccessParams): SqlCondition { + if (role != "member") return { sql: `1`, values: [] }; + + return { + sql: `${alias}.id IN (SELECT workspace_id FROM workspace_users WHERE user_id = ?)`, + values: [Number(user_id)], + }; +} diff --git a/src/utils/parse-id-list.test.ts b/src/utils/parse-id-list.test.ts new file mode 100644 index 0000000..a0d5b7c --- /dev/null +++ b/src/utils/parse-id-list.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import parseIdList from "./parse-id-list"; + +describe("parseIdList", () => { + test("reads comma separated ids and drops the rest", () => { + expect(parseIdList({ ids: "3,10, 7" })).toEqual([3, 10, 7]); + expect(parseIdList({ ids: "x,0,-2,4" })).toEqual([4]); + expect(parseIdList({ ids: null })).toEqual([]); + }); +}); diff --git a/src/utils/parse-id-list.ts b/src/utils/parse-id-list.ts new file mode 100644 index 0000000..c677f18 --- /dev/null +++ b/src/utils/parse-id-list.ts @@ -0,0 +1,16 @@ +type Params = { + /** + * Ids, comma separated (a `GROUP_CONCAT`). + */ + ids?: string | null; +}; + +/** + * Comma separated ids as numbers, leaving out anything that isn't one. + */ +export default function parseIdList({ ids }: Params): number[] { + return (ids || "") + .split(",") + .map((id) => Number(id.trim())) + .filter((id) => Number.isInteger(id) && id > 0); +} diff --git a/src/utils/rbac/can-manage-crew.ts b/src/utils/rbac/can-manage-crew.ts new file mode 100644 index 0000000..50bf2e3 --- /dev/null +++ b/src/utils/rbac/can-manage-crew.ts @@ -0,0 +1,15 @@ +import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db"; +import crewRole from "./crew-role"; + +type Params = { + user_types?: Pick[] | null; +}; + +/** + * Whether the user creates, edits and deletes workers and workspaces, and + * assigns them to users: super admins and admins. Only the super admin + * edits the coordinator. + */ +export default function canManageCrew({ user_types }: Params): boolean { + return crewRole({ user_types }) != "member"; +} diff --git a/src/utils/rbac/crew-role.ts b/src/utils/rbac/crew-role.ts new file mode 100644 index 0000000..a7e9fba --- /dev/null +++ b/src/utils/rbac/crew-role.ts @@ -0,0 +1,23 @@ +import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db"; + +type Params = { + user_types?: Pick[] | null; +}; + +export type CrewRole = "super_admin" | "admin" | "member"; + +/** + * How a user reaches the crew. `super_admin`: everything, the coordinator + * included (no one else ever reaches it). `admin`: every worker and + * workspace, and hands out access. `member` (everyone else): only the + * workers assigned to them (`bot_users`) and the workspaces they were + * added to (`workspace_users`). + */ +export default function crewRole({ user_types }: Params): CrewRole { + const types = new Set(user_types?.map((ut) => ut.user_type)); + + if (types.has("super_admin")) return "super_admin"; + if (types.has("admin")) return "admin"; + + return "member"; +} diff --git a/src/utils/rbac/index.ts b/src/utils/rbac/index.ts index 9b9ff98..a175d39 100644 --- a/src/utils/rbac/index.ts +++ b/src/utils/rbac/index.ts @@ -5,4 +5,5 @@ export { default as canManageUserTypes } from "./can-manage-user-types"; export { default as canRevokeUser } from "./can-revoke-user"; export { default as canUpdateUser } from "./can-update-user"; export { default as filterAssignableUserTypes } from "./filter-assignable-user-types"; -export { default as isPassThroughUser } from "./is-pass-through-user"; \ No newline at end of file +export { default as isPassThroughUser } from "./is-pass-through-user";export { default as crewRole } from "./crew-role"; +export { default as canManageCrew } from "./can-manage-crew";