From ea62dd11f0cd4b484c66515583e6f79864c9e903 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 09:04:14 +0100 Subject: [PATCH] Add bot types with a permission matrix, and workspace admin bots A bot's powers came from bots.kind alone, so only the coordinator could hand work to other bots. bot_types now works like user_types: a row with no workspace holds everywhere (the coordinator), one with a workspace only there (a workspace admin), and no row is a worker. bots.kind stays the legacy marker of the one coordinator for who sees it. The matrices in src/dict/bot-types-dict.ts say what each type may do, whom it may direct and who grants it, read by one generic interpreter (rbac/type-assignment.ts) that userTypeAssignment now wraps too. Every run reads its powers once (grabBotRunPowers) and the chat tools, task runs and task tools gate on them instead of kind; a run's powers are its type's and its user's together. In the workspace it leads, an admin hands tasks to the workers there (results relayed back to it), sets their instructions, adds tasks to their lists and keeps the workspace's context when its user may. Super admins and admins grant it from the workspace's settings or the chat header's menu, and the coordinator with set_bot_type. Admins are marked with a filled green shield beside their name across the workspace, kept live by pushBotUpdated's admin_workspace_ids. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- db/schema.ts | 57 ++++++- db/types/db.ts | 34 +++- src/bots-queue/push-bot-updated.ts | 13 ++ src/bots-queue/run-bot-reply.ts | 36 +++- src/bots-queue/run-bot-task.ts | 42 +++-- src/bots-queue/workspace-admins-note.ts | 20 +++ src/components/general/admin-shield.tsx | 41 +++++ src/data/harness-prompts.ts | 22 +++ src/dict/bot-types-dict.ts | 127 ++++++++++++++ .../db/bot-types/assert-bot-type-change.ts | 87 ++++++++++ .../db/bot-types/ensure-coordinator-type.ts | 19 +++ .../db/bot-types/grab-bot-run-powers.ts | 52 ++++++ .../backend/db/bot-types/grab-bot-types.ts | 21 +++ .../backend/db/bot-types/grab-bots-types.ts | 34 ++++ .../db/bot-types/grab-directable-bots.ts | 35 ++++ .../db/bot-types/grab-workspace-admins.ts | 21 +++ .../backend/db/bot-types/remove-bot-type.ts | 29 ++++ .../backend/db/bot-types/set-bot-type.ts | 45 +++++ src/functions/backend/db/bots/delete-bot.ts | 5 + .../backend/db/bots/ensure-coordinator-bot.ts | 8 +- src/functions/backend/db/bots/grab-bots.ts | 10 +- .../backend/db/tasks/grab-run-task.ts | 7 +- src/functions/backend/db/tasks/grab-tasks.ts | 7 +- .../backend/db/workspaces/delete-workspace.ts | 5 + .../db/workspaces/grab-bot-workspaces.ts | 14 +- .../db/workspaces/grab-workspace-bots.ts | 2 +- .../workspaces/remove-bot-from-workspace.ts | 8 + .../tools/bot/delegate-task-tool.test.ts | 20 ++- src/harness/tools/bot/delegate-task-tool.ts | 31 ++-- src/harness/tools/bot/find-bot-tool.ts | 17 +- src/harness/tools/bot/grab-bot-chat-tools.ts | 52 +++++- src/harness/tools/bot/grab-bot-tools.ts | 6 + .../tools/bot/grab-workspace-admin-tools.ts | 83 +++++++++ src/harness/tools/bot/set-bot-type-tool.ts | 92 ++++++++++ .../bot/set-workspace-instructions-tool.ts | 13 +- .../tools/bot/tasks/create-task-tool.ts | 36 +++- .../tools/bot/tasks/grab-bot-task-tools.ts | 27 ++- .../tools/bot/tasks/list-tasks-tool.ts | 7 +- .../tools/bot/tasks/update-task-tool.ts | 8 +- src/hooks/use-live-bots.ts | 6 + src/layouts/bots/(partials)/bot-row.tsx | 13 +- .../(partials)/bot-admin-menu-item.tsx | 91 ++++++++++ .../bots/[bot_id]/(partials)/bot-ref-chip.tsx | 2 + .../(partials)/conversation-header-menu.tsx | 30 +++- .../(partials)/conversation-header.tsx | 25 ++- .../[bot_id]/(partials)/header-menu-item.tsx | 15 +- .../settings/(partials)/settings-list-row.tsx | 7 +- .../[bot_id]/(partials)/bot-workspace-row.tsx | 2 + .../(partials)/workspace-bot-row.tsx | 44 ++++- .../(partials)/workspace-bots-group.tsx | 40 ++++- .../workspace-dashboard-bot-row.tsx | 2 + src/pages/api/admin/bots/set-bot-type.ts | 60 +++++++ src/schemas/bot-type-schema.ts | 14 ++ src/scripts/backfill-bot-types.ts | 20 +++ src/types/index.ts | 40 +++++ src/utils/rbac/bot-can-target.ts | 33 ++++ src/utils/rbac/bot-can.ts | 24 +++ src/utils/rbac/bot-type-assignment.ts | 47 ++++++ src/utils/rbac/bot-types-in.ts | 44 +++++ src/utils/rbac/bot-types.test.ts | 97 +++++++++++ src/utils/rbac/type-assignment.ts | 152 +++++++++++++++++ src/utils/rbac/user-type-assignment.ts | 157 ++---------------- src/utils/task-access/bot-task-scopes-sql.ts | 29 +++- src/utils/task-access/task-scopes.test.ts | 19 ++- 65 files changed, 1970 insertions(+), 239 deletions(-) create mode 100644 src/bots-queue/workspace-admins-note.ts create mode 100644 src/components/general/admin-shield.tsx create mode 100644 src/dict/bot-types-dict.ts create mode 100644 src/functions/backend/db/bot-types/assert-bot-type-change.ts create mode 100644 src/functions/backend/db/bot-types/ensure-coordinator-type.ts create mode 100644 src/functions/backend/db/bot-types/grab-bot-run-powers.ts create mode 100644 src/functions/backend/db/bot-types/grab-bot-types.ts create mode 100644 src/functions/backend/db/bot-types/grab-bots-types.ts create mode 100644 src/functions/backend/db/bot-types/grab-directable-bots.ts create mode 100644 src/functions/backend/db/bot-types/grab-workspace-admins.ts create mode 100644 src/functions/backend/db/bot-types/remove-bot-type.ts create mode 100644 src/functions/backend/db/bot-types/set-bot-type.ts create mode 100644 src/harness/tools/bot/grab-workspace-admin-tools.ts create mode 100644 src/harness/tools/bot/set-bot-type-tool.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx create mode 100644 src/pages/api/admin/bots/set-bot-type.ts create mode 100644 src/schemas/bot-type-schema.ts create mode 100644 src/scripts/backfill-bot-types.ts create mode 100644 src/utils/rbac/bot-can-target.ts create mode 100644 src/utils/rbac/bot-can.ts create mode 100644 src/utils/rbac/bot-type-assignment.ts create mode 100644 src/utils/rbac/bot-types-in.ts create mode 100644 src/utils/rbac/bot-types.test.ts create mode 100644 src/utils/rbac/type-assignment.ts diff --git a/AGENTS.md b/AGENTS.md index f286954..b01a787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl ### Bots (the chat shell) - **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. -- **Tables:** `bots` (`kind` worker|coordinator, `color` (a free `#rrggbb` the user picks) and `stone` (which flat-cut shape it's drawn on), `role`, `system_prompt` (the bot's core instructions, markdown); `hue` is the palette column they replaced, read only as a fallback), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. +- **Bot types (what a bot can do):** `bot_types` is to bots what `user_types` is to users (`bot_id`, `bot_type`, `workspace_id` null for install-wide, `granted_by_user_id`/`granted_by_bot_id`; one per bot, type and workspace, enforced in `setBotType`). Types live in `src/dict/bot-types-dict.ts`: `coordinator` (install-wide, written only by `ensureCoordinatorBot` through `ensureCoordinatorType`, which also backfills older installs; `src/scripts/backfill-bot-types.ts` for a headless one), `admin` (a workspace admin, scoped to one workspace) and `worker` (no row). `BotTypePermissionMatrix` says what each may do (`BotPermissions`: a workspace admin gets `delegate_task`, `set_member_instructions`, `manage_workspace_tasks`, `edit_workspace` in its workspace; the coordinator everything), `BotTypeTargetMatrix` whom it may direct (an admin directs that workspace's workers, never another admin or the coordinator), and `BotTypeAssignmentMatrix`/`BotTypeDeletionMatrix` who grants them (super admins and admins, and the coordinator bot through `set_bot_type`; both the bot and the user it runs for must pass). All three are read by the generic interpreter `src/utils/rbac/type-assignment.ts`, which `userTypeAssignment` wraps too; `botTypesIn`, `botCan`, `botCanTarget` and `botTypeAssignment` sit beside it (`bot-types.test.ts`). Helpers in `src/functions/backend/db/bot-types/` (`grabBotTypes`, which also counts the legacy `kind = 'coordinator'` bot as the coordinator, `grabBotsTypes`, `grabWorkspaceAdmins`, `grabDirectableBots`, `setBotType`/`removeBotType` checked by `assertBotTypeChange`: only workspace types, only for a bot in that workspace) and `grabBotRunPowers`, which every run reads once: `run-bot-reply`, `run-bot-task` and `grabBotChatTools` gate on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, never on `kind`. A run's powers are its type's and its user's together: an admin's `update_workspace` only shows when the user is a crew manager. In the workspace it leads an admin gets `grabWorkspaceAdminTools` (`delegate_task` for the members it may direct, results relayed back to it like the coordinator's, `set_bot_workspace_instructions`, `update_workspace` for that workspace only), `create_task` onto members' own lists and the tasks it put there (`botTaskScopesSql`'s `admin_workspace_ids`), and `workspaceAdminNote`; the other bots there are told who leads them (`workspaceAdminsNote`). `bots.kind` stays the legacy marker of the one coordinator for who sees it (the crew-access SQL, rail, audience). A crew manager makes a bot admin from the workspace's settings (`workspace-bot-row.tsx`'s shield toggle) or from the bot's chat in that workspace (the header menu's "Make workspace admin" row, `bot-admin-menu-item.tsx`, its state kept by `ConversationHeader` from the live bot), both through `api/admin/bots/set-bot-type`, which pushes `pushBotUpdated` (its `admin_workspace_ids`, applied by `useLiveBots`). In a workspace every crew row carries `is_admin` (`grabBots`; `BotWorkspace.is_admin` on a bot's settings), and an admin is marked with `AdminShield` (`src/components/general/admin-shield.tsx`: a filled success-green shield, beside the name and never on the stone) in the rail, the chat header, `[bot:ID]` chips, the workspace dashboard and both settings lists; the menu row and the settings toggle fill green too. Removing a bot from a workspace, or deleting either, drops its types there. +- **Tables:** `bots` (`kind` worker|coordinator, legacy for access, see Bot types, `color` (a free `#rrggbb` the user picks) and `stone` (which flat-cut shape it's drawn on), `role`, `system_prompt` (the bot's core instructions, markdown); `hue` is the palette column they replaced, read only as a fallback), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `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). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/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`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. 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` opens the bot this browser was last in (`readLastVisitedBot` from the single `cobble_last_visited_bot_id` cookie, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears; a conversation inside a workspace is never remembered, so the landing page always reopens the bot's own chat), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. diff --git a/db/schema.ts b/db/schema.ts index d4c14b9..f8ea852 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -1,4 +1,5 @@ import { LogCategories } from "@/src/dict/log-categories"; +import { BotTypes } from "@/src/dict/bot-types-dict"; import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict"; import { BotFaces, @@ -230,7 +231,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, { fieldName: "kind", - fieldDescription: `One coordinator per owner; everything else is a worker`, + fieldDescription: `Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from \`bot_types\``, dataType: "TEXT", options: BotKinds.map((bk) => bk.value), defaultValue: "worker", @@ -1231,6 +1232,60 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, ], }, + { + tableName: "bot_types", + tableDescription: `A bot's types, like \`user_types\` for users: what it can do (\`BotTypePermissionMatrix\`). A row with no workspace holds everywhere (the coordinator); one with a workspace only there (a workspace admin). A bot with no row is a worker. One per bot, type and workspace, enforced in \`setBotType\` since SQLite treats NULL ids as distinct`, + fields: [ + { + fieldName: "bot_id", + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "bots", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "bot_types_bot_fk", + }, + }, + { + fieldName: "bot_type", + dataType: "TEXT", + options: BotTypes.map((bt) => bt.value), + }, + { + fieldName: "workspace_id", + fieldDescription: `The workspace the type holds in; null for install-wide`, + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "workspaces", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "bot_types_workspace_fk", + }, + }, + { + fieldName: "granted_by_user_id", + fieldDescription: `The user who granted it (or for whom the granting bot ran). Not a foreign key`, + dataType: "INTEGER", + }, + { + fieldName: "granted_by_bot_id", + fieldDescription: `The bot that granted it from chat (the coordinator), if any. Not a foreign key`, + dataType: "INTEGER", + }, + ], + indexes: [ + { + indexName: "idx_bot_types_bot_id_workspace_id", + indexTableFields: ["bot_id", "workspace_id"], + }, + { + indexName: "idx_bot_types_workspace_id", + indexTableFields: ["workspace_id"], + }, + ], + }, { tableName: "workspace_users", tableDescription: `Users added to a workspace (by a super admin or admin): they chat with every worker in it there`, diff --git a/db/types/db.ts b/db/types/db.ts index 848e31e..dcc7adf 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -19,6 +19,7 @@ export const BunSQLiteTables = [ "cron_jobs", "workspaces", "workspace_bots", + "bot_types", "workspace_users", "bot_users", "bot_user_memories", @@ -193,7 +194,7 @@ export type BUN_SQLITE_COBBLE_BOTS = { user_id?: number | ""; name?: string; /** - * One coordinator per owner; everything else is a worker + * Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from `bot_types` */ kind?: "worker" | "coordinator" | ""; /** @@ -767,6 +768,35 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = { memory?: string; } +export type BUN_SQLITE_COBBLE_BOT_TYPES = { + /** + * 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 | ""; + bot_type?: "coordinator" | "admin" | "worker" | ""; + /** + * The workspace the type holds in; null for install-wide + */ + workspace_id?: number | ""; + /** + * The user who granted it (or for whom the granting bot ran). Not a foreign key + */ + granted_by_user_id?: number | ""; + /** + * The bot that granted it from chat (the coordinator), if any. Not a foreign key + */ + granted_by_bot_id?: number | ""; +} + export type BUN_SQLITE_COBBLE_WORKSPACE_USERS = { /** * The unique identifier of the record. @@ -1950,4 +1980,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = { value?: string; } -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_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS \ 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_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & 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_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS \ No newline at end of file diff --git a/src/bots-queue/push-bot-updated.ts b/src/bots-queue/push-bot-updated.ts index 4c1eb7b..42ab69f 100644 --- a/src/bots-queue/push-bot-updated.ts +++ b/src/bots-queue/push-bot-updated.ts @@ -74,6 +74,18 @@ async function push({ }) ).payload?.[0]; + /** + * The workspaces it leads, so each crew shows its shield. + */ + const admin_workspace_ids = ( + ( + await BunSQLite.sql<{ workspace_id: number }>({ + sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin' AND workspace_id IS NOT NULL`, + values: [bot_id], + }) + ).payload || [] + ).map((row) => Number(row.workspace_id)); + for (const viewer_id of audience) { if (users && !users.has(String(viewer_id))) continue; @@ -87,6 +99,7 @@ async function push({ color: identity?.color || null, stone: identity?.stone || null, image_media_id: identity?.image_media_id ? Number(identity.image_media_id) : null, + admin_workspace_ids, ...(computer_id !== undefined ? { computer_id: computer_id || null } : {}), ...(workspace_ids !== undefined ? { workspace_ids } : {}), task: await grabActiveBotTask({ bot_id, user_id: viewer_id }), diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index c3134c4..d6fbf51 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -9,8 +9,12 @@ import { CoordinatorSoloNote, TasksNote, TaskReminderNote, + workspaceAdminNote, WorkspaceLearningNote, } from "@/src/data/harness-prompts"; +import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers"; +import workspaceAdminsNote from "./workspace-admins-note"; +import type { WorkspaceAdminTools } from "@/src/harness/tools/bot/grab-workspace-admin-tools"; import { grabReplyMaxToolSteps } from "@/src/functions/backend/db/app-settings/grab-run-limits"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt"; @@ -158,8 +162,16 @@ export default async function runBotReply({ onAttach: ({ media_id }) => attachments.push(media_id), }); + /** + * What the bot's types let it do here, and what it leads when it's this + * workspace's admin. + */ + const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id }); + let led: WorkspaceAdminTools | null = null; + const tools = await grabBotChatTools({ bot, + powers, user_id, computers, session_id, @@ -168,6 +180,9 @@ export default async function runBotReply({ message_id: job.message_id || undefined, onTakeOver: ({ ref }) => take_overs.push(ref), onPropose: ({ ref }) => proposals.push(ref), + onWorkspaceAdmin: (admin) => { + led = admin; + }, }); /** @@ -193,14 +208,19 @@ export default async function runBotReply({ }); /** - * delegate_task is only there while the coordinator has workers. + * delegate_task is only there while the coordinator, or a workspace's + * admin, has bots to hand work to. Anyone else in a workspace with an + * admin is told who leads it. */ - const crew_note = - bot.kind != "coordinator" - ? "" - : tools.some((tool) => tool.name == "delegate_task") - ? coordinatorReplyNote({ bot_id: bot.id }) - : CoordinatorSoloNote; + const can_delegate = tools.some((tool) => tool.name == "delegate_task"); + const lead = led as WorkspaceAdminTools | null; + const crew_note = powers.is_coordinator + ? can_delegate + ? coordinatorReplyNote({ bot_id: bot.id }) + : CoordinatorSoloNote + : lead?.workspace && can_delegate + ? workspaceAdminNote({ workspace_name: lead.workspace.name || "this workspace", members: lead.members }) + : await workspaceAdminsNote({ bot_id: bot.id, workspace_id }); try { const result = await runTextLoop({ @@ -216,7 +236,7 @@ export default async function runBotReply({ workspace_id ? WorkspaceLearningNote : "", composeComputersNote({ computers, - is_coordinator: bot.kind == "coordinator", + is_coordinator: powers.is_coordinator, in_workspace: Boolean(workspace_id), }), crew_note, diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index f630e32..15535c3 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -1,3 +1,6 @@ +import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers"; +import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools"; +import workspaceAdminsNote from "./workspace-admins-note"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import { ActionCheckNote, @@ -87,13 +90,15 @@ export default async function runBotTask({ const computers = await grabBotComputers({ bot_id: bot.id, workspace_id }); /** - * A coordinator's task can also change workspaces: the findings - * it was asked to record go there, not only in the report. + * What the bot's types let it do here. A coordinator's task can + * also change workspaces, and a workspace admin's its workspace: + * the findings it was asked to record go there, not only in the + * report. */ - const coordinator_workspaces = - bot.kind == "coordinator" - ? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived) - : []; + const powers = await grabBotRunPowers({ bot_id: bot.id, workspace_id }); + const coordinator_workspaces = powers.is_coordinator + ? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived) + : []; const carry_over = await grabSessionCarryOver({ session_id, bot_id: bot.id, @@ -121,6 +126,16 @@ export default async function runBotTask({ */ const scopes = await grabRunScopes({ bot, user_id, workspace_id }); + /** + * A workspace admin's task keeps the workspace's context and its + * members' instructions; it hands nothing over (no session to report + * back to mid-task). + */ + const admin_tools = + !powers.is_coordinator && workspace_id && powers.is_workspace_admin + ? (await grabWorkspaceAdminTools({ bot, user_id, workspace_id, powers, scopes })).tools + : []; + const tools = [ ...(await grabBotTools({ user_id, @@ -128,10 +143,12 @@ export default async function runBotTask({ workspace_id, computers, session_id, - is_coordinator: bot.kind == "coordinator", + is_coordinator: powers.is_coordinator, + admin_workspace_ids: powers.admin_workspace_ids, scopes, })), - ...(bot.kind == "coordinator" + ...admin_tools, + ...(powers.is_coordinator ? [ createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }), ...(coordinator_workspaces.length @@ -204,9 +221,14 @@ export default async function runBotTask({ TasksNote, ActionCheckNote, workspace_id ? WorkspaceLearningNote : "", - bot.kind == "coordinator" + powers.is_coordinator ? `If this brief asked you to record something in a workspace (its context, or a new one), do it yourself with update_workspace or create_workspace before you report: the report alone changes nothing.` - : "", + : admin_tools.some((tool) => tool.name == "update_workspace") + ? `If this brief asked you to record something in this workspace's context, do it yourself with update_workspace before you report: the report alone changes nothing.` + : "", + powers.is_coordinator || powers.is_workspace_admin + ? "" + : await workspaceAdminsNote({ bot_id: bot.id, workspace_id }), computer ? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.` : workspace_id diff --git a/src/bots-queue/workspace-admins-note.ts b/src/bots-queue/workspace-admins-note.ts new file mode 100644 index 0000000..92bf73c --- /dev/null +++ b/src/bots-queue/workspace-admins-note.ts @@ -0,0 +1,20 @@ +import { workspaceAdminsLine } from "@/src/data/harness-prompts"; +import grabWorkspaceAdmins from "@/src/functions/backend/db/bot-types/grab-workspace-admins"; + +/** + * For a bot in a workspace that has admins other than itself: the line + * naming them (`workspaceAdminsLine`). + */ +export default async function workspaceAdminsNote({ + bot_id, + workspace_id, +}: { + bot_id: number | string | "" | undefined; + workspace_id?: number | string | null; +}) { + if (!workspace_id) return ""; + + const admins = (await grabWorkspaceAdmins({ workspace_id })).filter((admin) => admin.id != Number(bot_id)); + + return admins.length ? workspaceAdminsLine({ admins }) : ""; +} diff --git a/src/components/general/admin-shield.tsx b/src/components/general/admin-shield.tsx new file mode 100644 index 0000000..82f15ad --- /dev/null +++ b/src/components/general/admin-shield.tsx @@ -0,0 +1,41 @@ +import { ShieldCheck } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +type Props = { + /** + * Its size in pixels; 14 sits beside a 14px name. + */ + size?: number; + /** + * The tooltip and accessible name. + */ + label?: string; + className?: string; +}; + +/** + * The mark of a workspace's admin bot: a filled success-green shield with + * its check cut out in the background's colour, set beside the bot's name + * (never on its stone, which takes no badges). A role, not a status, and + * never the bot's own colour. + */ +export default function AdminShield({ + size = 17, + label = "Workspace admin", + className, +}: Props) { + return ( + + {label} + + ); +} diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index ab21fab..ba144ee 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -150,6 +150,28 @@ export const coordinatorReplyNote = ({ bot_id }: { bot_id: number | string }) => */ export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, propose a new bot for it, and create it with create_bot once the user agrees.`; +/** + * Added to a workspace admin's chat prompt in the workspace it leads, while + * it has members to hand work to (`delegate_task` is there). `members` are + * the bots it directs, written as refs. + */ +export const workspaceAdminNote = ({ + workspace_name, + members, +}: { + workspace_name: string; + members: { id?: number | "" }[]; +}) => `# Leading ${workspace_name} + +You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself; they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`; + +/** + * Added to a workspace member's prompt while the workspace has an admin, + * so it knows who hands it work there. + */ +export const workspaceAdminsLine = ({ admins }: { admins: { id?: number | "" }[] }) => + `In this workspace, ${admins.map((a) => botRef({ id: a.id || 0 })).join(" and ")} ${admins.length == 1 ? "is the admin and hands" : "are the admins and hand"} out work: a task from them is part of your job here.`; + /** * Added to a bot's prompt when it works through a queued task. */ diff --git a/src/dict/bot-types-dict.ts b/src/dict/bot-types-dict.ts new file mode 100644 index 0000000..e10cca4 --- /dev/null +++ b/src/dict/bot-types-dict.ts @@ -0,0 +1,127 @@ +import type { BotPermission, BotType, TypeAssignmentMatrixType } from "../types"; + +/** + * Every bot type. A bot with no `bot_types` row is a worker. A type holds + * install-wide (`scope: "install"`, its row has no workspace) or in one + * workspace (`scope: "workspace"`, its row names it). `bots.kind` stays the + * legacy marker of the one coordinator for who sees it; what a bot can do + * comes from these. + */ +export const BotTypes = [ + { + value: "coordinator", + title: "Coordinator", + description: "Coordinates the whole crew: hands work to every bot, grows the crew, runs workspaces and extensions. One per install, the super admin's alone.", + scope: "install", + }, + { + value: "admin", + title: "Workspace admin", + description: "Leads one workspace: hands tasks to the other bots there, sets their instructions there, runs the workspace's task list and keeps its context.", + scope: "workspace", + }, + { + value: "worker", + title: "Worker", + description: "Does its own job. Every bot without another type.", + scope: "install", + }, +] as const; + +/** + * What a bot type lets a bot do. + */ +export const BotPermissions = [ + { + value: "delegate_task", + title: "Hand tasks to other bots", + }, + { + value: "set_member_instructions", + title: "Set other bots' workspace instructions", + }, + { + value: "manage_workspace_tasks", + title: "Run the workspace's task list", + }, + { + value: "edit_workspace", + title: "Edit the workspace's name, description and context", + }, + { + value: "manage_crew", + title: "Create, edit and delete bots, their computers and workspaces", + }, + { + value: "manage_workspaces", + title: "Create and delete workspaces", + }, + { + value: "manage_extensions", + title: "Manage skills, MCP servers and tools", + }, + { + value: "set_bot_types", + title: "Make bots workspace admins", + }, + { + value: "set_any_picture", + title: "Set any bot's picture", + }, + { + value: "reach_all_tasks", + title: "Reach every task on the install", + }, +] as const; + +/** + * What each bot type may do, where it holds (everywhere for an + * install-wide type, in its workspace for a workspace one). + */ +export const BotTypePermissionMatrix: Record = { + coordinator: BotPermissions.map((bp) => bp.value), + admin: [ + "delegate_task", + "set_member_instructions", + "manage_workspace_tasks", + "edit_workspace", + ], + worker: [], +}; + +/** + * Which bots a bot may act on (hand a task to, set instructions for), keyed + * by the target's type. Same shape as `UserTypeAssignmentMatrix`: the + * coordinator passes through. A workspace admin directs workers only, so + * two admins never hand work back and forth. + */ +export const BotTypeTargetMatrix: TypeAssignmentMatrixType = { + coordinator: {}, + admin: { + allowed_actors: [], + }, + worker: { + allowed_actors: ["admin"], + }, +}; + +/** + * Who may grant a bot type: user types (`admin`; the super admin passes + * through) and bot types (`coordinator`). The coordinator type is never + * granted: `ensureCoordinatorBot` writes it. + */ +export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType = { + coordinator: {}, + admin: { + allowed_actors: ["admin", "coordinator"], + }, + worker: {}, +}; + +export const BotTypeDeletionMatrix: TypeAssignmentMatrixType = { + coordinator: {}, + admin: { + allowed_actors: ["admin", "coordinator"], + }, + worker: {}, +}; diff --git a/src/functions/backend/db/bot-types/assert-bot-type-change.ts b/src/functions/backend/db/bot-types/assert-bot-type-change.ts new file mode 100644 index 0000000..4b17aac --- /dev/null +++ b/src/functions/backend/db/bot-types/assert-bot-type-change.ts @@ -0,0 +1,87 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import { BotTypes } from "@/src/dict/bot-types-dict"; +import type { BotType } from "@/src/types"; +import AppError from "@/src/utils/app-error"; +import botTypeAssignment from "@/src/utils/rbac/bot-type-assignment"; +import grabUserBot from "../bots/grab-user-bot"; +import grabUserTypes from "../users/grab-user-types"; +import grabUserWorkspace from "../workspaces/grab-user-workspace"; +import grabBotTypes from "./grab-bot-types"; + +export type BotTypeChangeParams = { + /** + * The user making the change, or the user a granting bot runs for. + */ + user_id: number | string; + /** + * The bot making the change from chat (the coordinator), if any: it + * must be allowed as well as the user. + */ + actor_bot_id?: number | string | null; + bot_id: number | string; + bot_type: BotType; + workspace_id?: number | string | null; +}; + +/** + * Checks a grant or removal of a bot type: the user (and the bot, when one + * acts) may do it (`botTypeAssignment`), only workspace types are ever + * changed (the coordinator's is written by `ensureCoordinatorBot`), and the + * bot works in the workspace. Returns the bot and the workspace id. + */ +export default async function assertBotTypeChange({ + user_id, + actor_bot_id, + bot_id, + bot_type, + workspace_id, + action, +}: BotTypeChangeParams & { action: "assign" | "delete" }) { + const type = BotTypes.find((bt) => bt.value == bot_type); + + if (!type || type.scope != "workspace") { + throw new AppError(`Only a workspace type can be given or taken away.`); + } + + if (!workspace_id) { + throw new AppError(`Pick the workspace the bot is ${type.title.toLowerCase()} of.`); + } + + const user_types = await grabUserTypes({ user_id }); + + if (!botTypeAssignment({ actor_user_types: user_types, target_bot_type: bot_type, action })) { + throw new AppError(`Only an admin can do that.`); + } + + if (actor_bot_id) { + const actor_bot_types = await grabBotTypes({ bot_id: actor_bot_id }); + + if (!botTypeAssignment({ actor_bot_types, target_bot_type: bot_type, action })) { + throw new AppError(`Only the coordinator can do that.`); + } + } + + const bot = await grabUserBot({ bot_id, user_id }); + const workspace = await grabUserWorkspace({ workspace_id, user_id }); + + if (!bot?.id || !workspace?.id) { + throw new AppError(`Bot or workspace not found.`); + } + + if (bot.kind == "coordinator") { + throw new AppError(`The coordinator already leads every workspace.`); + } + + if (action == "assign") { + const member = await BunSQLite.sql<{ n: number }>({ + sql: `SELECT COUNT(*) AS n FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`, + values: [workspace.id, bot.id], + }); + + if (!member.payload?.[0]?.n) { + throw new AppError(`${bot.name} isn't in ${workspace.name}. Add it to the workspace first.`); + } + } + + return { bot, workspace }; +} diff --git a/src/functions/backend/db/bot-types/ensure-coordinator-type.ts b/src/functions/backend/db/bot-types/ensure-coordinator-type.ts new file mode 100644 index 0000000..79c27d1 --- /dev/null +++ b/src/functions/backend/db/bot-types/ensure-coordinator-type.ts @@ -0,0 +1,19 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; + +type Params = { + bot_id: number | string; +}; + +/** + * Gives the coordinator its install-wide `coordinator` bot type, once. + * The only place that type is ever written. + */ +export default async function ensureCoordinatorType({ bot_id }: Params) { + await BunSQLite.sql({ + sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, created_at, updated_at) + SELECT ?, 'coordinator', NULL, ?, ? WHERE NOT EXISTS ( + SELECT 1 FROM bot_types WHERE bot_id = ? AND bot_type = 'coordinator' AND workspace_id IS NULL + )`, + values: [Number(bot_id), Date.now(), Date.now(), Number(bot_id)], + }); +} diff --git a/src/functions/backend/db/bot-types/grab-bot-run-powers.ts b/src/functions/backend/db/bot-types/grab-bot-run-powers.ts new file mode 100644 index 0000000..d300961 --- /dev/null +++ b/src/functions/backend/db/bot-types/grab-bot-run-powers.ts @@ -0,0 +1,52 @@ +import type { BotPermission } from "@/src/types"; +import botCan from "@/src/utils/rbac/bot-can"; +import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in"; +import grabBotTypes from "./grab-bot-types"; + +type Params = { + bot_id: number | string; + /** + * Where the run works: its own chat (null) or a workspace. + */ + workspace_id?: number | string | null; +}; + +export type BotRunPowers = { + bot_types: BotTypeRow[]; + /** + * Holds the install-wide coordinator type. + */ + is_coordinator: boolean; + /** + * The workspaces it's admin of. + */ + admin_workspace_ids: number[]; + /** + * Admin of the workspace the run is in. + */ + is_workspace_admin: boolean; + /** + * Whether its types allow `permission` where the run works. The bot's + * side only: the run's user must still be allowed. + */ + can: (permission: BotPermission) => boolean; +}; + +/** + * What a bot's types let it do on one run, read once (`grabBotTypes`). + */ +export default async function grabBotRunPowers({ bot_id, workspace_id }: Params): Promise { + const bot_types = await grabBotTypes({ bot_id }); + + const admin_workspace_ids = bot_types + .filter((bt) => bt.bot_type == "admin" && bt.workspace_id) + .map((bt) => Number(bt.workspace_id)); + + return { + bot_types, + is_coordinator: bot_types.some((bt) => bt.bot_type == "coordinator" && !bt.workspace_id), + admin_workspace_ids, + is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))), + can: (permission) => botCan({ bot_types, permission, workspace_id }), + }; +} diff --git a/src/functions/backend/db/bot-types/grab-bot-types.ts b/src/functions/backend/db/bot-types/grab-bot-types.ts new file mode 100644 index 0000000..fa3152c --- /dev/null +++ b/src/functions/backend/db/bot-types/grab-bot-types.ts @@ -0,0 +1,21 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in"; + +type Params = { + bot_id: number | string; +}; + +/** + * A bot's types (`bot_types`), plus the install-wide coordinator type for + * the legacy `kind = 'coordinator'` bot, so the coordinator keeps its + * powers before `ensureCoordinatorBot` has written its row. + */ +export default async function grabBotTypes({ bot_id }: Params): Promise { + const res = await BunSQLite.sql({ + sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ? + UNION SELECT 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id = ? AND kind = 'coordinator'`, + values: [Number(bot_id), Number(bot_id)], + }); + + return res.payload || []; +} diff --git a/src/functions/backend/db/bot-types/grab-bots-types.ts b/src/functions/backend/db/bot-types/grab-bots-types.ts new file mode 100644 index 0000000..c5ab49e --- /dev/null +++ b/src/functions/backend/db/bot-types/grab-bots-types.ts @@ -0,0 +1,34 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in"; + +type Params = { + bot_ids: (number | string | "" | undefined)[]; +}; + +type Row = BotTypeRow & { bot_id: number }; + +/** + * `grabBotTypes` for a list of bots at once, keyed by bot id. A bot with no + * row is missing from the map (a worker). + */ +export default async function grabBotsTypes({ bot_ids }: Params): Promise> { + const ids = [...new Set(bot_ids.map(Number).filter(Boolean))]; + const map = new Map(); + + if (!ids.length) return map; + + const marks = ids.map(() => "?").join(", "); + const res = await BunSQLite.sql({ + sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks}) + UNION SELECT id AS bot_id, 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id IN (${marks}) AND kind = 'coordinator'`, + values: [...ids, ...ids], + }); + + for (const row of res.payload || []) { + const list = map.get(row.bot_id) || []; + list.push({ bot_type: row.bot_type, workspace_id: row.workspace_id }); + map.set(row.bot_id, list); + } + + return map; +} diff --git a/src/functions/backend/db/bot-types/grab-directable-bots.ts b/src/functions/backend/db/bot-types/grab-directable-bots.ts new file mode 100644 index 0000000..3121abf --- /dev/null +++ b/src/functions/backend/db/bot-types/grab-directable-bots.ts @@ -0,0 +1,35 @@ +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import botCanTarget from "@/src/utils/rbac/bot-can-target"; +import grabCrewWorkers from "../bots/grab-crew-workers"; +import grabBotTypes from "./grab-bot-types"; +import grabBotsTypes from "./grab-bots-types"; + +type Params = { + /** + * The bot handing out the work. + */ + bot_id: number | string; + workspace_id: number | string; +}; + +/** + * The bots in a workspace that a bot may direct there (hand a task to, set + * instructions for): read fresh, so a member added mid-run is there, and + * filtered through `BotTypeTargetMatrix` (`botCanTarget`), never itself. + */ +export default async function grabDirectableBots({ bot_id, workspace_id }: Params): Promise { + const members = (await grabCrewWorkers({ workspace_id })).filter((bot) => bot.id != Number(bot_id)); + + if (!members.length) return []; + + const actor_bot_types = await grabBotTypes({ bot_id }); + const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) }); + + return members.filter((bot) => + botCanTarget({ + actor_bot_types, + target_bot_types: types.get(Number(bot.id)) || [], + workspace_id, + }), + ); +} diff --git a/src/functions/backend/db/bot-types/grab-workspace-admins.ts b/src/functions/backend/db/bot-types/grab-workspace-admins.ts new file mode 100644 index 0000000..8035015 --- /dev/null +++ b/src/functions/backend/db/bot-types/grab-workspace-admins.ts @@ -0,0 +1,21 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; + +type Params = { + workspace_id: number | string; +}; + +/** + * The bots that are admins of a workspace and still work in it. + */ +export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise { + const res = await BunSQLite.sql({ + sql: `SELECT b.* FROM bots b + JOIN bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ? + JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = bt.workspace_id + GROUP BY b.id ORDER BY b.id ASC`, + values: [Number(workspace_id)], + }); + + return res.payload || []; +} diff --git a/src/functions/backend/db/bot-types/remove-bot-type.ts b/src/functions/backend/db/bot-types/remove-bot-type.ts new file mode 100644 index 0000000..68d429a --- /dev/null +++ b/src/functions/backend/db/bot-types/remove-bot-type.ts @@ -0,0 +1,29 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import logEvent from "@/src/functions/backend/logger"; +import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change"; + +/** + * Takes a workspace type away from a bot. Taking one it doesn't have is a + * no-op. Checked by `assertBotTypeChange`. + */ +export default async function removeBotType(params: BotTypeChangeParams) { + const { bot, workspace } = await assertBotTypeChange({ ...params, action: "delete" }); + + const res = await BunSQLite.sql({ + sql: `DELETE FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ?`, + values: [Number(bot.id), params.bot_type, Number(workspace.id)], + }); + + if (!res.success) { + throw new Error(`Couldn't remove the bot's type: ${res.error}`); + } + + await logEvent({ + title: `Bot type removed`, + category: "system", + user_id: Number(params.user_id), + plain_text: `${bot.name} (#${bot.id}) is no longer ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`, + }).catch(() => {}); + + return { bot, workspace }; +} diff --git a/src/functions/backend/db/bot-types/set-bot-type.ts b/src/functions/backend/db/bot-types/set-bot-type.ts new file mode 100644 index 0000000..60a0076 --- /dev/null +++ b/src/functions/backend/db/bot-types/set-bot-type.ts @@ -0,0 +1,45 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import logEvent from "@/src/functions/backend/logger"; +import assertBotTypeChange, { type BotTypeChangeParams } from "./assert-bot-type-change"; + +/** + * Gives a bot a workspace type (makes it admin of a workspace it works + * in). Giving it again is a no-op. Checked by `assertBotTypeChange`. + */ +export default async function setBotType(params: BotTypeChangeParams) { + const { bot, workspace } = await assertBotTypeChange({ ...params, action: "assign" }); + + const existing = await BunSQLite.sql<{ id: number }>({ + sql: `SELECT id FROM bot_types WHERE bot_id = ? AND bot_type = ? AND workspace_id = ? LIMIT 1`, + values: [Number(bot.id), params.bot_type, Number(workspace.id)], + }); + + if (existing.payload?.[0]?.id) return { bot, workspace, changed: false }; + + const res = await BunSQLite.sql({ + sql: `INSERT INTO bot_types (bot_id, bot_type, workspace_id, granted_by_user_id, granted_by_bot_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + values: [ + Number(bot.id), + params.bot_type, + Number(workspace.id), + Number(params.user_id), + params.actor_bot_id ? Number(params.actor_bot_id) : null, + Date.now(), + Date.now(), + ], + }); + + if (!res.success) { + throw new Error(`Couldn't save the bot's type: ${res.error}`); + } + + await logEvent({ + title: `Bot type given`, + category: "system", + user_id: Number(params.user_id), + plain_text: `${bot.name} (#${bot.id}) is now ${params.bot_type} of ${workspace.name} (#${workspace.id})${params.actor_bot_id ? `, by bot #${params.actor_bot_id}` : ""}.`, + }).catch(() => {}); + + return { bot, workspace, changed: true }; +} diff --git a/src/functions/backend/db/bots/delete-bot.ts b/src/functions/backend/db/bots/delete-bot.ts index dc849eb..a9be2d7 100644 --- a/src/functions/backend/db/bots/delete-bot.ts +++ b/src/functions/backend/db/bots/delete-bot.ts @@ -38,6 +38,11 @@ export default async function deleteBot({ user_id, bot_id }: Params) { ); const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []); + /** + * Explicitly, since foreign keys are off outside the server process. + */ + await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] }); + const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] }); if (!res.success) { diff --git a/src/functions/backend/db/bots/ensure-coordinator-bot.ts b/src/functions/backend/db/bots/ensure-coordinator-bot.ts index a4fc9b9..67aeb90 100644 --- a/src/functions/backend/db/bots/ensure-coordinator-bot.ts +++ b/src/functions/backend/db/bots/ensure-coordinator-bot.ts @@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db"; import { CoordinatorBotName } from "@/src/dict/bot-dict"; import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt"; import attachDefaultExtensions from "../extensions/attach-default-extensions"; +import ensureCoordinatorType from "../bot-types/ensure-coordinator-type"; type Params = { /** @@ -16,7 +17,9 @@ type Params = { * 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. + * overwritten. Its install-wide `coordinator` bot type is written too + * (`ensureCoordinatorType`), which backfills older installs. Safe to call + * on every visit. */ export default async function ensureCoordinatorBot({ user_id }: Params) { const existing = ( @@ -43,6 +46,8 @@ export default async function ensureCoordinatorBot({ user_id }: Params) { }); } + await ensureCoordinatorType({ bot_id: existing.id }); + return existing.id; } @@ -68,6 +73,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) { } await attachDefaultExtensions({ bot_id }); + await ensureCoordinatorType({ bot_id }); return bot_id; } diff --git a/src/functions/backend/db/bots/grab-bots.ts b/src/functions/backend/db/bots/grab-bots.ts index 1b9b24e..d3e8a46 100644 --- a/src/functions/backend/db/bots/grab-bots.ts +++ b/src/functions/backend/db/bots/grab-bots.ts @@ -20,7 +20,8 @@ type Params = { * (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The * coordinator is pinned first, then workers by latest activity. Each row * carries the user's own face (`botFaceSql`) and conversation there: its - * unread flag, latest activity and latest message. + * unread flag, latest activity and latest message, and in a workspace + * whether it's that workspace's admin (`is_admin`). */ export default async function grabBots({ user_id, @@ -40,7 +41,8 @@ export default async function grabBots({ sql += ` COALESCE(t.unread, 0) AS unread,\n`; sql += ` t.last_activity_at AS last_activity_at,\n`; sql += ` lm.text AS last_message_text,\n`; - sql += ` lm.created_at AS last_message_at\n`; + sql += ` lm.created_at AS last_message_at,\n`; + sql += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'admin' AND bt.workspace_id = ?) AS is_admin\n`; 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`; @@ -59,8 +61,8 @@ export default async function grabBots({ const res = await BunSQLite.sql({ sql, - values: [...face.values, scope, user_id, scope, user_id, ...access.values], + values: [...face.values, scope, scope, user_id, scope, user_id, ...access.values], }); - return res.payload || []; + return (res.payload || []).map((bot) => ({ ...bot, is_admin: Boolean(bot.is_admin) })); } diff --git a/src/functions/backend/db/tasks/grab-run-task.ts b/src/functions/backend/db/tasks/grab-run-task.ts index 33bfe33..a10dd19 100644 --- a/src/functions/backend/db/tasks/grab-run-task.ts +++ b/src/functions/backend/db/tasks/grab-run-task.ts @@ -10,6 +10,10 @@ type Params = { */ bot_id: number | string; is_coordinator: boolean; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`). + */ + admin_workspace_ids?: number[]; }; /** @@ -22,8 +26,9 @@ export default async function grabRunTask({ task_id, bot_id, is_coordinator, + admin_workspace_ids, }: Params): Promise { - const access = botTaskScopesSql({ bot_id, is_coordinator }); + const access = botTaskScopesSql({ bot_id, is_coordinator, admin_workspace_ids }); const res = await BunSQLite.sql({ sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`, diff --git a/src/functions/backend/db/tasks/grab-tasks.ts b/src/functions/backend/db/tasks/grab-tasks.ts index cdc0dd3..fdce64b 100644 --- a/src/functions/backend/db/tasks/grab-tasks.ts +++ b/src/functions/backend/db/tasks/grab-tasks.ts @@ -19,6 +19,10 @@ type Params = { */ bot_id?: number | string; is_coordinator?: boolean; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`). + */ + admin_workspace_ids?: number[]; /** * Only one collection. */ @@ -44,6 +48,7 @@ export default async function grabTasks({ user_id, bot_id, is_coordinator, + admin_workspace_ids, only_bot_id, only_workspace_id, statuses, @@ -58,7 +63,7 @@ export default async function grabTasks({ where.push(access.sql); values.push(...access.values); } else if (bot_id != null) { - const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator) }); + const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator), admin_workspace_ids }); where.push(access.sql); values.push(...access.values); } diff --git a/src/functions/backend/db/workspaces/delete-workspace.ts b/src/functions/backend/db/workspaces/delete-workspace.ts index d1f4e19..3b0bb8e 100644 --- a/src/functions/backend/db/workspaces/delete-workspace.ts +++ b/src/functions/backend/db/workspaces/delete-workspace.ts @@ -36,6 +36,11 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params) */ const task_media_ids = await grabTaskMediaIds({ workspace_id: workspace.id! }).catch(() => []); + /** + * Explicitly, since foreign keys are off outside the server process. + */ + await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE workspace_id = ?`, values: [workspace.id] }); + const res = await BunSQLite.sql({ sql: `DELETE FROM workspaces WHERE id = ?`, values: [workspace.id], diff --git a/src/functions/backend/db/workspaces/grab-bot-workspaces.ts b/src/functions/backend/db/workspaces/grab-bot-workspaces.ts index 42d984a..923015e 100644 --- a/src/functions/backend/db/workspaces/grab-bot-workspaces.ts +++ b/src/functions/backend/db/workspaces/grab-bot-workspaces.ts @@ -11,7 +11,8 @@ type Params = { /** * The workspaces a bot works in, for its settings page. The coordinator * works in every workspace; a worker only in the ones it was added to. - * Each row carries what the bot has for that workspace alone. + * Each row carries what the bot has for that workspace alone, and whether + * it's that workspace's admin. */ export default async function grabBotWorkspaces({ user_id, @@ -36,6 +37,16 @@ export default async function grabBotWorkspaces({ ).payload || []; const by_workspace = new Map(rows.map((row) => [Number(row.workspace_id), row])); + const admin_of = new Set( + ( + ( + await BunSQLite.sql<{ workspace_id: number }>({ + sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin'`, + values: [Number(bot.id)], + }) + ).payload || [] + ).map((row) => Number(row.workspace_id)), + ); return workspaces .filter((workspace) => by_workspace.has(Number(workspace.id))) @@ -46,6 +57,7 @@ export default async function grabBotWorkspaces({ ...workspace, workspace_system_prompt: row?.system_prompt || null, workspace_memory: row?.memory || null, + is_admin: admin_of.has(Number(workspace.id)), }; }); } diff --git a/src/functions/backend/db/workspaces/grab-workspace-bots.ts b/src/functions/backend/db/workspaces/grab-workspace-bots.ts index 51f01f8..df066df 100644 --- a/src/functions/backend/db/workspaces/grab-workspace-bots.ts +++ b/src/functions/backend/db/workspaces/grab-workspace-bots.ts @@ -11,7 +11,7 @@ type Params = { /** * A workspace's crew (the coordinator first, then its bots by latest * activity there), each with its instructions and memory for the - * workspace. + * workspace (whether it's the workspace's admin comes with `grabBots`). */ export default async function grabWorkspaceBots({ user_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 e95a461..eefdfd5 100644 --- a/src/functions/backend/db/workspaces/remove-bot-from-workspace.ts +++ b/src/functions/backend/db/workspaces/remove-bot-from-workspace.ts @@ -29,4 +29,12 @@ export default async function removeBotFromWorkspace({ sql: `DELETE FROM workspace_bots WHERE workspace_id = ? AND bot_id = ?`, values: [workspace.id, bot_id], }); + + /** + * Its types there (workspace admin) go with its place. + */ + await BunSQLite.sql({ + sql: `DELETE FROM bot_types WHERE workspace_id = ? AND bot_id = ?`, + values: [workspace.id, bot_id], + }); } diff --git a/src/harness/tools/bot/delegate-task-tool.test.ts b/src/harness/tools/bot/delegate-task-tool.test.ts index 4861a6b..c8aa5a2 100644 --- a/src/harness/tools/bot/delegate-task-tool.test.ts +++ b/src/harness/tools/bot/delegate-task-tool.test.ts @@ -13,10 +13,10 @@ function coordinatorTool({ }: { workspace_id?: number; workers?: BUN_SQLITE_COBBLE_BOTS[]; workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] } = {}) { return delegateTaskTool({ user_id: 1, - coordinator_id: 1, + requester_id: 1, session_id: 10, workspace_id: workspace_id ?? null, - coordinator_name: "Coordinator", + requester_name: "Coordinator", workers, workspaces, }); @@ -69,4 +69,20 @@ describe("delegateTaskTool", () => { }), ).rejects.not.toThrow(/runs in that workspace/); }); + + test("a workspace admin can only hand work to the bots it may direct", async () => { + const tool = delegateTaskTool({ + user_id: 1, + requester_id: 7, + session_id: 10, + workspace_id: 3, + requester_name: "Lead", + workers: [worker], + grabTargets: async () => [worker], + }); + + await expect( + tool.run({ args: { bot_id: 8, title: "Dig", instructions: "x" }, signal }), + ).rejects.toThrow(/No crew member has id 8 in workspace 3. Crew: 5: Researcher/); + }); }); diff --git a/src/harness/tools/bot/delegate-task-tool.ts b/src/harness/tools/bot/delegate-task-tool.ts index fcf48fd..7561099 100644 --- a/src/harness/tools/bot/delegate-task-tool.ts +++ b/src/harness/tools/bot/delegate-task-tool.ts @@ -16,26 +16,33 @@ import type { HarnessTool } from "../../types"; type Params = { user_id: number | string; /** - * The coordinator: the worker's result comes back to its conversation. + * The bot handing the work over (the coordinator, or a workspace's + * admin): the worker's result comes back to its conversation. */ - coordinator_id: number | string; + requester_id: number | string; /** - * The coordinator's session the task is handed over from. + * The requester's session the task is handed over from. */ session_id: number | string; /** - * The workspace the coordinator is working in: the worker works on it + * The workspace the requester is working in: the worker works on it * there, in its conversation in the workspace. */ workspace_id?: number | string | null; /** - * The coordinator's name, shown in the worker's conversation. + * The requester's name, shown in the worker's conversation. */ - coordinator_name: string; + requester_name: string; /** * The worker bots it can hand work to (in a workspace, its members). */ workers: BUN_SQLITE_COBBLE_BOTS[]; + /** + * Reads who it may hand work to when the task is handed over, so a bot + * added earlier in the run is there. `grabCrewWorkers` by default (the + * coordinator); a workspace admin's are `grabDirectableBots`. + */ + grabTargets?: (params: { workspace_id: number | null }) => Promise; /** * The user's live workspaces, for the picker. In a workspace there's * nothing to pick: the task runs there. @@ -58,6 +65,7 @@ type Args = { /** * Lets the coordinator hand a task to a worker bot: in its own chat, or in * one of the user's workspaces, which decides the work's context entirely. + * A workspace admin gets it too, for the workers in its workspace. * The worker runs it on the computer it's on there (if it has one) and * posts the result in that conversation, where a note says who asked; the * result (or failure) is also reported back in the coordinator's @@ -65,12 +73,13 @@ type Args = { */ export default function delegateTaskTool({ user_id, - coordinator_id, + requester_id, session_id, workspace_id, - coordinator_name, + requester_name, workers, workspaces = [], + grabTargets = ({ workspace_id }) => grabCrewWorkers({ workspace_id }), }: Params): HarnessTool { const crew = workers .map((bot) => `${bot.id}: ${bot.name}${bot.role ? ` (${bot.role})` : ""}`) @@ -117,7 +126,7 @@ export default function delegateTaskTool({ ? Number(args.workspace_id) : null; - const fresh = await grabCrewWorkers({ workspace_id: target_workspace_id }); + const fresh = await grabTargets({ workspace_id: target_workspace_id }); const worker = fresh.find((bot) => bot.id == args.bot_id); if (!worker?.id) { @@ -162,7 +171,7 @@ export default function delegateTaskTool({ title: args.title, instructions: args.instructions, computer_id: computers[0]?.id || undefined, - requested_by_bot_id: coordinator_id, + requested_by_bot_id: requester_id, requested_by_session_id: session_id, }); @@ -172,7 +181,7 @@ export default function delegateTaskTool({ bot_id: worker.id, session_id: Number(worker_session.id), workspace_id: target_workspace_id, - text: `${coordinator_name} asked me to: **${args.title}**`, + text: `${requester_name} asked me to: **${args.title}**`, }), }); diff --git a/src/harness/tools/bot/find-bot-tool.ts b/src/harness/tools/bot/find-bot-tool.ts index 00ef486..2dd54f6 100644 --- a/src/harness/tools/bot/find-bot-tool.ts +++ b/src/harness/tools/bot/find-bot-tool.ts @@ -1,3 +1,5 @@ +import workspaceRef from "@/src/utils/workspace-ref"; +import grabBotsTypes from "@/src/functions/backend/db/bot-types/grab-bots-types"; import BunSQLite from "@moduletrace/bun-sqlite"; import { z } from "zod"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; @@ -18,7 +20,7 @@ type Args = { export default function findBotTool(): HarnessTool { return { name: "find_bot", - description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's ref (its id, e.g. [bot:3]), name, kind and role. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`, + description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's ref (its id, e.g. [bot:3]), name, kind, role and the workspaces it's admin of. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`, schema: z.object({ query: z.string().trim().max(200).optional(), limit: z.number().int().min(1).max(50).optional(), @@ -53,11 +55,16 @@ export default function findBotTool(): HarnessTool { return `No crew member matches "${args.query || ""}".`; } + const types = await grabBotsTypes({ bot_ids: matches.map((bot) => bot.id) }); + return matches - .map( - (bot) => - `${botRef({ id: bot.id || 0 })} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})\n## Instructions:\n${bot.system_prompt}`, - ) + .map((bot) => { + const led = (types.get(Number(bot.id)) || []) + .filter((bt) => bt.bot_type == "admin" && bt.workspace_id) + .map((bt) => workspaceRef({ id: Number(bt.workspace_id) })); + + return `${botRef({ id: bot.id || 0 })} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})${led.length ? `, admin of ${led.join(", ")}` : ""}\n## Instructions:\n${bot.system_prompt}`; + }) .join("\n"); }, }; diff --git a/src/harness/tools/bot/grab-bot-chat-tools.ts b/src/harness/tools/bot/grab-bot-chat-tools.ts index bbcfd6b..b0e9e48 100644 --- a/src/harness/tools/bot/grab-bot-chat-tools.ts +++ b/src/harness/tools/bot/grab-bot-chat-tools.ts @@ -23,9 +23,16 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user- import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool"; import startTaskTool from "./start-task-tool"; import computerRunDirs from "@/src/utils/computer-work-dirs"; +import type { BotRunPowers } from "@/src/functions/backend/db/bot-types/grab-bot-run-powers"; +import grabWorkspaceAdminTools, { type WorkspaceAdminTools } from "./grab-workspace-admin-tools"; +import setBotTypeTool from "./set-bot-type-tool"; type Params = { bot: BUN_SQLITE_COBBLE_BOTS; + /** + * What the bot's types let it do on this run (`grabBotRunPowers`). + */ + powers: BotRunPowers; user_id: number | string; /** * The bot's computers where it runs, the current one first @@ -58,6 +65,11 @@ type Params = { * user to confirm. */ onPropose?: (params: { ref: string }) => void; + /** + * Called with what a workspace admin leads, when the run is one, so + * the reply can say so in its prompt. + */ + onWorkspaceAdmin?: (params: WorkspaceAdminTools) => void; }; /** @@ -76,11 +88,15 @@ type Params = { * computers, and which name, role and core instructions each crew member * gets, and what goes) and the install's skills, MCP servers and custom * tools (`grabCoordinatorExtensionTools`) or, in a workspace, - * set_bot_workspace_instructions. Anything else on a computer goes through - * a task, never mid-reply. + * set_bot_workspace_instructions, plus set_bot_type to make a worker a + * workspace's admin. A workspace admin, in the workspace it leads, gets + * `grabWorkspaceAdminTools`. Every gate reads the bot's types (`powers`), + * never `bots.kind`. Anything else on a computer goes through a task, + * never mid-reply. */ export default async function grabBotChatTools({ bot, + powers, user_id, computers, session_id, @@ -89,6 +105,7 @@ export default async function grabBotChatTools({ scopes = [], onTakeOver, onPropose, + onWorkspaceAdmin, }: Params): Promise { const bot_id = bot.id || 0; const tools = [ @@ -98,7 +115,8 @@ export default async function grabBotChatTools({ workspace_id, computers, session_id, - is_coordinator: bot.kind == "coordinator", + is_coordinator: powers.is_coordinator, + admin_workspace_ids: powers.admin_workspace_ids, scopes, })), startTaskTool({ @@ -121,7 +139,16 @@ export default async function grabBotChatTools({ : []), ]; - if (bot.kind != "coordinator") return tools; + if (!powers.is_coordinator) { + if (!workspace_id || !powers.is_workspace_admin) return tools; + + const admin = await grabWorkspaceAdminTools({ bot, user_id, workspace_id, powers, session_id, scopes }); + + onWorkspaceAdmin?.(admin); + tools.push(...admin.tools); + + return tools; + } const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived); @@ -140,13 +167,18 @@ export default async function grabBotChatTools({ tools.push(delegateTaskTool({ user_id, - coordinator_id: bot_id, + requester_id: bot_id, session_id, workspace_id, - coordinator_name: bot.name || "The coordinator", + requester_name: bot.name || "The coordinator", workers, })); tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers })); + + if (powers.can("set_bot_types")) { + tools.push(setBotTypeTool({ user_id, bot_id, workers, workspaces, workspace_id })); + } + return tools; } @@ -165,10 +197,10 @@ export default async function grabBotChatTools({ tools.push( delegateTaskTool({ user_id, - coordinator_id: bot_id, + requester_id: bot_id, session_id, workspace_id, - coordinator_name: bot.name || "The coordinator", + requester_name: bot.name || "The coordinator", workers, workspaces, }), @@ -197,6 +229,10 @@ export default async function grabBotChatTools({ addBotToWorkspaceTool({ user_id, workers, workspaces }), removeBotFromWorkspaceTool({ user_id, workers, workspaces }), ); + + if (powers.can("set_bot_types")) { + tools.push(setBotTypeTool({ user_id, bot_id, workers, workspaces })); + } } return tools; diff --git a/src/harness/tools/bot/grab-bot-tools.ts b/src/harness/tools/bot/grab-bot-tools.ts index c971e3c..4421a79 100644 --- a/src/harness/tools/bot/grab-bot-tools.ts +++ b/src/harness/tools/bot/grab-bot-tools.ts @@ -33,6 +33,10 @@ type Params = { * add to any bot's or workspace's list. */ is_coordinator?: boolean; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`). + */ + admin_workspace_ids?: number[]; /** * The files the run reaches (`grabRunScopes`), for the picture it may * set from one. @@ -52,6 +56,7 @@ export default async function grabBotTools({ computers = [], session_id, is_coordinator, + admin_workspace_ids, scopes = [], }: Params): Promise { return [ @@ -70,6 +75,7 @@ export default async function grabBotTools({ user_id, bot_id, is_coordinator: Boolean(is_coordinator), + admin_workspace_ids, workspace_id, })), ]; diff --git a/src/harness/tools/bot/grab-workspace-admin-tools.ts b/src/harness/tools/bot/grab-workspace-admin-tools.ts new file mode 100644 index 0000000..d53e835 --- /dev/null +++ b/src/harness/tools/bot/grab-workspace-admin-tools.ts @@ -0,0 +1,83 @@ +import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; +import type { BotRunPowers } from "@/src/functions/backend/db/bot-types/grab-bot-run-powers"; +import grabDirectableBots from "@/src/functions/backend/db/bot-types/grab-directable-bots"; +import grabCrewRole from "@/src/functions/backend/db/access/grab-crew-role"; +import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace"; +import type { StorageScope } from "@/src/utils/storage-access"; +import type { HarnessTool } from "../../types"; +import delegateTaskTool from "./delegate-task-tool"; +import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool"; +import updateWorkspaceTool from "./update-workspace-tool"; + +type Params = { + bot: BUN_SQLITE_COBBLE_BOTS; + user_id: number | string; + workspace_id: number | string; + powers: BotRunPowers; + /** + * The session being answered: delegated work reports back to it. Left + * out (a task run), nothing is handed over. + */ + session_id?: number | string; + scopes?: StorageScope[]; +}; + +export type WorkspaceAdminTools = { + tools: HarnessTool[]; + /** + * The bots it may direct there. + */ + members: BUN_SQLITE_COBBLE_BOTS[]; + workspace: BUN_SQLITE_COBBLE_WORKSPACES | null; +}; + +/** + * The tools a workspace admin leads its workspace with, each only while + * its types allow it there (`BotRunPowers.can`): delegate_task and + * set_bot_workspace_instructions for the members it may direct + * (`grabDirectableBots`), and update_workspace for this workspace alone, + * only when the user the run works for may change it too (a crew manager). + */ +export default async function grabWorkspaceAdminTools({ + bot, + user_id, + workspace_id, + powers, + session_id, + scopes = [], +}: Params): Promise { + const workspace = await grabUserWorkspace({ workspace_id, user_id }); + + if (!workspace?.id || workspace.archived || !powers.is_workspace_admin) { + return { tools: [], members: [], workspace: null }; + } + + const bot_id = bot.id || 0; + const tools: HarnessTool[] = []; + const members = await grabDirectableBots({ bot_id, workspace_id }); + const grabTargets = () => grabDirectableBots({ bot_id, workspace_id }); + + if (members.length && session_id && powers.can("delegate_task")) { + tools.push( + delegateTaskTool({ + user_id, + requester_id: bot_id, + session_id, + workspace_id, + requester_name: bot.name || "The workspace's admin", + workers: members, + grabTargets, + }), + ); + } + + if (members.length && powers.can("set_member_instructions")) { + tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers: members, grabTargets })); + } + + if (powers.can("edit_workspace") && (await grabCrewRole({ user_id })) != "member") { + tools.push(updateWorkspaceTool({ user_id, workspaces: [workspace], workspace_id, scopes })); + } + + return { tools, members, workspace }; +} diff --git a/src/harness/tools/bot/set-bot-type-tool.ts b/src/harness/tools/bot/set-bot-type-tool.ts new file mode 100644 index 0000000..d1d59e3 --- /dev/null +++ b/src/harness/tools/bot/set-bot-type-tool.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; +import grabBotTypes from "@/src/functions/backend/db/bot-types/grab-bot-types"; +import removeBotType from "@/src/functions/backend/db/bot-types/remove-bot-type"; +import setBotType from "@/src/functions/backend/db/bot-types/set-bot-type"; +import botRef from "@/src/utils/bot-ref"; +import pushBotUpdated from "@/src/bots-queue/push-bot-updated"; +import type { HarnessTool } from "../../types"; + +type Params = { + user_id: number | string; + /** + * The coordinator: it must be allowed as well as the user + * (`BotTypeAssignmentMatrix`). + */ + bot_id: number | string; + /** + * The workers it may make admins. + */ + workers: BUN_SQLITE_COBBLE_BOTS[]; + /** + * The workspaces they can lead. + */ + workspaces: BUN_SQLITE_COBBLE_WORKSPACES[]; + /** + * The workspace the conversation is in: the one meant when no id is + * given. + */ + workspace_id?: number | string | null; +}; + +type Args = { + bot_id: number; + workspace_id?: number; + admin: boolean; +}; + +/** + * Lets the coordinator make a worker the admin of a workspace it works in, + * or take that away. An admin hands tasks to the other bots there, sets + * their instructions there, runs the workspace's task list and keeps its + * context (`BotTypePermissionMatrix`). Read back before it says it's done. + */ +export default function setBotTypeTool({ user_id, bot_id, workers, workspaces, workspace_id }: Params): HarnessTool { + const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; "); + const places = workspaces.map((w) => `${w.id}: ${w.name}`).join("; "); + + return { + name: "set_bot_type", + description: `Make a worker the admin of a workspace it works in (admin: true), or take that away (admin: false). A workspace admin leads the bots there: it hands them tasks with delegate_task, sets their instructions there, runs the workspace's task list and keeps its context. Only do it once the user asked for it or agreed. Crew: ${crew || "none"}. Workspaces: ${places || "none"}.`, + schema: z.object({ + bot_id: z.number().int(), + workspace_id: z + .number() + .int() + .optional() + .describe(workspace_id ? `Defaults to this workspace (${workspace_id}).` : `The workspace it leads.`), + admin: z.boolean(), + }), + run: async ({ args }) => { + const target_workspace_id = args.workspace_id || (workspace_id ? Number(workspace_id) : null); + + if (!target_workspace_id) { + throw new Error(`Name the workspace: ${places || "none"}.`); + } + + const change = { + user_id, + actor_bot_id: bot_id, + bot_id: args.bot_id, + bot_type: "admin" as const, + workspace_id: target_workspace_id, + }; + + const { bot, workspace } = args.admin ? await setBotType(change) : await removeBotType(change); + + const saved = (await grabBotTypes({ bot_id: args.bot_id })).some( + (bt) => bt.bot_type == "admin" && bt.workspace_id == target_workspace_id, + ); + + if (saved != args.admin) { + throw new Error(`The change didn't take: check the bot is still in the workspace and try again.`); + } + + await pushBotUpdated({ bot_id: args.bot_id }); + + return args.admin + ? `${botRef({ id: bot.id || 0 })} is now the admin of "${workspace.name}". It gets the admin's tools from its next reply there.` + : `${botRef({ id: bot.id || 0 })} is no longer the admin of "${workspace.name}".`; + }, + }; +} diff --git a/src/harness/tools/bot/set-workspace-instructions-tool.ts b/src/harness/tools/bot/set-workspace-instructions-tool.ts index e71c1c6..c469fd0 100644 --- a/src/harness/tools/bot/set-workspace-instructions-tool.ts +++ b/src/harness/tools/bot/set-workspace-instructions-tool.ts @@ -12,6 +12,12 @@ type Params = { * The workspace's crew members (not the coordinator). */ workers: BUN_SQLITE_COBBLE_BOTS[]; + /** + * Reads whose instructions it may set when it's called. + * `grabCrewWorkers` by default (the coordinator); a workspace admin's + * are `grabDirectableBots`. + */ + grabTargets?: () => Promise; }; type Args = { @@ -20,13 +26,14 @@ type Args = { }; /** - * Lets the coordinator set a crew member's instructions for this - * workspace: how that bot should do its job here. + * Lets the coordinator, or the workspace's admin, set a crew member's + * instructions for this workspace: how that bot should do its job here. */ export default function setWorkspaceInstructionsTool({ user_id, workspace_id, workers, + grabTargets = () => grabCrewWorkers({ workspace_id }), }: Params): HarnessTool { const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; "); @@ -38,7 +45,7 @@ export default function setWorkspaceInstructionsTool({ instructions: z.string().trim().max(AppData.PromptMaxLength), }), run: async ({ args }) => { - const fresh = await grabCrewWorkers({ workspace_id }); + const fresh = await grabTargets(); const worker = fresh.find((bot) => bot.id == args.bot_id); if (!worker?.id) { diff --git a/src/harness/tools/bot/tasks/create-task-tool.ts b/src/harness/tools/bot/tasks/create-task-tool.ts index 76e9f52..6af9894 100644 --- a/src/harness/tools/bot/tasks/create-task-tool.ts +++ b/src/harness/tools/bot/tasks/create-task-tool.ts @@ -23,6 +23,15 @@ type Params = { */ workers?: BUN_SQLITE_COBBLE_BOTS[]; workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[]; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`). + */ + admin_workspace_ids?: number[]; + /** + * In a workspace it's admin of: the other bots there, whose own lists + * it may add to. + */ + members?: BUN_SQLITE_COBBLE_BOTS[]; }; type Args = { @@ -38,18 +47,21 @@ type Place = { bot_id?: number; workspace_id?: number }; /** * Worked out from the arguments and who is asking: where the task goes, or * why it can't. A worker may only add to its own list or the workspace it - * works in; the coordinator to any worker's, any workspace's, or its own. + * works in; a workspace admin also to its members' own lists there; the + * coordinator to any worker's, any workspace's, or its own. */ function resolvePlace({ args, bot_id, workspace_id, is_coordinator, + members = [], }: { args: Args; bot_id: number | string; workspace_id?: number | string | null; is_coordinator: boolean; + members?: BUN_SQLITE_COBBLE_BOTS[]; }): Place | string { const target_bot = args.bot_id ? Number(args.bot_id) : null; const target_workspace = args.workspace_id ? Number(args.workspace_id) : null; @@ -64,8 +76,12 @@ function resolvePlace({ return { bot_id: Number(bot_id) }; } - if (target_bot && target_bot != Number(bot_id)) { - return `You can only add tasks to your own list (#${bot_id}).`; + const is_member = members.some((member) => member.id == target_bot); + + if (target_bot && target_bot != Number(bot_id) && !is_member) { + return members.length + ? `You can only add tasks to your own list (#${bot_id}), this workspace's, or a member's here (${members.map((m) => m.id).join(", ")}).` + : `You can only add tasks to your own list (#${bot_id}).`; } if (target_workspace && Number(workspace_id || 0) != target_workspace) { @@ -93,15 +109,18 @@ export default function createTaskTool({ is_coordinator, workers = [], workspaces = [], + admin_workspace_ids = [], + members = [], }: Params): HarnessTool { const crew = workers.map((worker) => `${worker.id}: ${worker.name}`).join("; "); const places = workspaces.map((workspace) => `${workspace.id}: ${workspace.name}`).join("; "); + const team = members.map((member) => `${member.id}: ${member.name}`).join("; "); return { name: "create_task", description: is_coordinator ? `Add a task to a list: a worker's own (${crew || "no workers yet"}), a workspace's shared one (${places || "no workspaces yet"}), or your own (leave both ids out). Write the description as a brief: what to do, where, and how you'll know it's done. A workspace's tasks are visible to every bot working there.` - : `Add a task to a list: ${workspace_id ? `this workspace's shared one (leave the ids out), or your own (bot_id: ${bot_id})` : `your own, bot_id ${bot_id} (or leave the id out, since it defaults to yours)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`, + : `Add a task to a list: ${workspace_id ? `this workspace's shared one (leave the ids out), or your own (bot_id: ${bot_id})${team ? `, or, as this workspace's admin, a member's own (${team})` : ""}` : `your own, bot_id ${bot_id} (or leave the id out, since it defaults to yours)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`, schema: z.object({ title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`), description: z @@ -111,7 +130,9 @@ export default function createTaskTool({ bot_id: z.number().int().optional().describe( is_coordinator ? `Whose own list, when it isn't a workspace's. Leave out for your own.` - : `Your own id (${bot_id}), to keep it in your list rather than the workspace's; leaving it out has the same effect here.`, + : team + ? `Your own id (${bot_id}) or a member's here (${team}), to put it on that list rather than the workspace's.` + : `Your own id (${bot_id}), to keep it in your list rather than the workspace's; leaving it out has the same effect here.`, ), workspace_id: z .number() @@ -125,13 +146,14 @@ export default function createTaskTool({ status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`), }), run: async ({ args }) => { - const place = resolvePlace({ args, bot_id, workspace_id, is_coordinator }); + const place = resolvePlace({ args, bot_id, workspace_id, is_coordinator, members }); if (typeof place == "string") return place; const existing = await grabTasks({ bot_id, is_coordinator, + admin_workspace_ids, only_bot_id: place.bot_id, only_workspace_id: place.workspace_id, open_only: true, @@ -158,7 +180,7 @@ export default function createTaskTool({ created_by_bot_id: bot_id, }); - return `Task #${task.id} "${task.title}" added ${place.workspace_id ? "to this workspace's list" : "to your list"}. Check it with list_tasks before you mention it to the user.`; + return `Task #${task.id} "${task.title}" added ${place.workspace_id ? "to this workspace's list" : place.bot_id == Number(bot_id) ? "to your list" : `to bot ${place.bot_id}'s list`}. Check it with list_tasks before you mention it to the user.`; }, }; } diff --git a/src/harness/tools/bot/tasks/grab-bot-task-tools.ts b/src/harness/tools/bot/tasks/grab-bot-task-tools.ts index 647ade1..16a6550 100644 --- a/src/harness/tools/bot/tasks/grab-bot-task-tools.ts +++ b/src/harness/tools/bot/tasks/grab-bot-task-tools.ts @@ -15,6 +15,11 @@ type Params = { * to any list and reaches every folder. */ is_coordinator: boolean; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`): in one of + * them, create_task can also add to the other members' own lists. + */ + admin_workspace_ids?: number[]; workspace_id?: number | string | null; }; @@ -23,15 +28,18 @@ type Params = { * shared to-do list, one per bot and one per workspace, so a chat reply, a * task and a scheduled run all read it, move it along and file what they * produced. The coordinator's create_task may target any worker or - * workspace; its update_task may link any file it can reach. + * workspace; its update_task may link any file it can reach. A workspace + * admin's may add to the other members' own lists there. */ export default async function grabBotTaskTools({ user_id, bot_id, is_coordinator, + admin_workspace_ids = [], workspace_id, }: Params): Promise { let workers: BUN_SQLITE_COBBLE_BOTS[] = []; + let members: BUN_SQLITE_COBBLE_BOTS[] = []; let workspaces: BUN_SQLITE_COBBLE_WORKSPACES[] = []; let all_workspace_ids: number[] = []; @@ -39,6 +47,8 @@ export default async function grabBotTaskTools({ workers = await grabCrewWorkers({ workspace_id }); workspaces = (await grabUserWorkspaces({ user_id })).filter((workspace) => !workspace.archived); all_workspace_ids = workspaces.map((workspace) => Number(workspace.id)); + } else if (workspace_id && admin_workspace_ids.includes(Number(workspace_id))) { + members = (await grabCrewWorkers({ workspace_id })).filter((member) => member.id != Number(bot_id)); } /** @@ -54,8 +64,17 @@ export default async function grabBotTaskTools({ }); return [ - listTasksTool({ user_id, bot_id, is_coordinator, workspace_id }), - createTaskTool({ user_id, bot_id, workspace_id, is_coordinator, workers, workspaces }), - updateTaskTool({ user_id, bot_id, is_coordinator, scopes }), + listTasksTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, workspace_id }), + createTaskTool({ + user_id, + bot_id, + workspace_id, + is_coordinator, + workers, + workspaces, + admin_workspace_ids, + members, + }), + updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes }), ]; } diff --git a/src/harness/tools/bot/tasks/list-tasks-tool.ts b/src/harness/tools/bot/tasks/list-tasks-tool.ts index bc487e7..4e949ef 100644 --- a/src/harness/tools/bot/tasks/list-tasks-tool.ts +++ b/src/harness/tools/bot/tasks/list-tasks-tool.ts @@ -10,6 +10,10 @@ type Params = { bot_id: number | string; user_id: number | string; is_coordinator: boolean; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`). + */ + admin_workspace_ids?: number[]; /** * The workspace the run is in, if any. */ @@ -27,7 +31,7 @@ type Args = { * workspace it works in, or for the coordinator the whole install. Used * both to plan work and to remind the user of what's pending. */ -export default function listTasksTool({ bot_id, user_id, is_coordinator, workspace_id }: Params): HarnessTool { +export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool { return { name: "list_tasks", description: `List tasks: yours, the ones in a workspace you work in, or ${is_coordinator ? "every task in the install" : "every task you can reach"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`, @@ -50,6 +54,7 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, workspa const tasks = await grabTasks({ bot_id, is_coordinator, + admin_workspace_ids, only_bot_id: scope == "mine" ? Number(bot_id) : undefined, only_workspace_id, statuses: args.status ? [args.status] : undefined, diff --git a/src/harness/tools/bot/tasks/update-task-tool.ts b/src/harness/tools/bot/tasks/update-task-tool.ts index a944c99..b02af08 100644 --- a/src/harness/tools/bot/tasks/update-task-tool.ts +++ b/src/harness/tools/bot/tasks/update-task-tool.ts @@ -13,6 +13,10 @@ type Params = { user_id: number | string; bot_id: number | string; is_coordinator: boolean; + /** + * The workspaces the bot is admin of (`grabBotRunPowers`). + */ + admin_workspace_ids?: number[]; /** * The run's folders, for linking a deliverable by its `[file:ID]` ref. */ @@ -33,7 +37,7 @@ type Args = { * a progress note, and file the deliverables it produced. One call does all * of them, so a step in the work is a step in the log. */ -export default function updateTaskTool({ user_id, bot_id, is_coordinator, scopes = [] }: Params): HarnessTool { +export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool { return { name: "update_task", description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`, @@ -60,7 +64,7 @@ export default function updateTaskTool({ user_id, bot_id, is_coordinator, scopes .describe(`The [file:ID] refs of files it works from`), }), run: async ({ args }) => { - const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator }); + const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids }); const said: string[] = []; if (args.status || args.blocked_reason !== undefined) { diff --git a/src/hooks/use-live-bots.ts b/src/hooks/use-live-bots.ts index 96c7c7d..ead62c8 100644 --- a/src/hooks/use-live-bots.ts +++ b/src/hooks/use-live-bots.ts @@ -97,6 +97,12 @@ export default function useLiveBots({ bots, open_bot_id, workspace_id = null }: ...(data.image_media_id !== undefined ? { image_media_id: data.image_media_id || undefined } : {}), + /** + * Made this workspace's admin, or no longer. + */ + ...(workspace_id && data.admin_workspace_ids + ? { is_admin: data.admin_workspace_ids.includes(Number(workspace_id)) } + : {}), } : bot, ); diff --git a/src/layouts/bots/(partials)/bot-row.tsx b/src/layouts/bots/(partials)/bot-row.tsx index 1b6f58d..ce09a86 100644 --- a/src/layouts/bots/(partials)/bot-row.tsx +++ b/src/layouts/bots/(partials)/bot-row.tsx @@ -10,6 +10,7 @@ import botStoneProps from "@/src/utils/bot-stone-props"; import messagePreview from "@/src/utils/message-preview"; import relativeTime from "@/src/utils/relative-time"; import UnreadDot from "./unread-dot"; +import AdminShield from "@/src/components/general/admin-shield"; type Props = { bot: BotWithLastMessage; @@ -27,7 +28,8 @@ type Props = { /** * 48px rail row. Status is the face and the subtitle (the bot's status - * line while it has one): no badges, spinners or state dots. + * line while it has one): no badges, spinners or state dots. A workspace's + * admin has its shield beside its name: a role, not a status. * The selected background (ink at 5%, never a hue) is painted by * RailBotCard, which also holds the sessions. */ @@ -55,9 +57,12 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) { > - - {bot.name} - + + + {bot.name} + + {bot.is_admin ? : null} + {snippet} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx new file mode 100644 index 0000000..b803661 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-admin-menu-item.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { ShieldCheck } from "lucide-react"; +import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; +import HeaderMenuItem from "./header-menu-item"; + +type Props = { + bot_id: number; + bot_name?: string | null; + workspace_id: number; + workspace_name?: string | null; + /** + * Whether the bot is this workspace's admin now. + */ + is_admin: boolean; + /** + * After a change saved: the header keeps the new state, so the menu + * shows it when it opens again. + */ + on_changed: (is_admin: boolean) => void; +}; + +/** + * The header menu's row that makes the bot this workspace's admin, or + * takes that away (super admins and admins; `api/admin/bots/set-bot-type`). + * It asks first. The bot gets or loses the admin's tools from its next + * reply here. + */ +export default function BotAdminMenuItem({ + bot_id, + bot_name, + workspace_id, + workspace_name, + is_admin, + on_changed, +}: Props) { + const [loading, setLoading] = useState(false); + const name = bot_name || "this bot"; + const place = workspace_name || "this workspace"; + + const toggle = async () => { + const given = !is_admin; + + if ( + !window.confirm( + given + ? `Make ${name} the admin of ${place}? It will hand tasks to the other bots here, set their instructions here, run the workspace's tasks and keep its context.` + : `Take away ${name}'s admin role in ${place}?`, + ) + ) { + return; + } + + setLoading(true); + + const res = await postAdminApi({ + path: "bots/set-bot-type", + body: { bot_id, workspace_id, bot_type: "admin", given }, + }).catch(() => null); + + setLoading(false); + + if (res?.success) { + on_changed(given); + return; + } + + window.alert(res?.msg || "That didn't work."); + }; + + return ( + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-ref-chip.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-ref-chip.tsx index 2956450..8df8b32 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-ref-chip.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-ref-chip.tsx @@ -6,6 +6,7 @@ import type { BotFace } from "@/src/dict/bot-dict"; import botStoneProps from "@/src/utils/bot-stone-props"; import { AppContext } from "@/src/pages/__root"; import { CrewBotsContext } from "../(data)/crew-bots-context"; +import AdminShield from "@/src/components/general/admin-shield"; type Props = { bot_id: number; @@ -44,6 +45,7 @@ export default function BotRefChip({ bot_id }: Props) { size={18} /> {bot.name} + {workspace_id && bot.is_admin ? : null} ); } diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx index e3ee257..e9d40f5 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header-menu.tsx @@ -14,6 +14,7 @@ import BotFilesModal from "./bot-files-modal"; import BotTasksModal from "./bot-tasks-modal"; import BotRoutinesModal from "./bot-routines-modal"; import BotModelsModal from "./bot-models-modal"; +import BotAdminMenuItem from "./bot-admin-menu-item"; import HeaderMenuItem from "./header-menu-item"; import NewSessionButton from "./new-session-button"; import StopBotButton from "./stop-bot-button"; @@ -45,11 +46,17 @@ type Props = { * A row is opening something: the menu closes, leaving only that. */ on_open: () => void; + /** + * In a workspace: whether the bot is its admin, kept by the header. + */ + is_admin?: boolean; + on_admin_changed?: (is_admin: boolean) => void; }; /** * The conversation header's menu: the sessions, a new session, the bot's - * computer, its models, its settings and, while it works, stopping it. A + * computer, its models, in a workspace making it the admin there, its + * settings and, while it works, stopping it. A * row that opens a modal closes the menu first, so the modal never sits * behind an open menu. */ @@ -61,12 +68,23 @@ export default function ConversationHeaderMenu({ model_name, on_model_saved, on_open, + is_admin, + on_admin_changed, }: Props) { const { pageProps } = useContext(AppContext); const can_open_settings = bot.kind == "coordinator" ? isPassThroughUser({ user_types: pageProps?.user_types }) : canManageCrew({ user_types: pageProps?.user_types }); + /** + * Admin is per workspace, so only in one, for a worker, and only for + * those who hand out roles. + */ + const can_set_admin = Boolean( + workspace?.id && + bot.kind != "coordinator" && + canManageCrew({ user_types: pageProps?.user_types }), + ); return ( + {can_set_admin && workspace?.id ? ( + on_admin_changed?.(value)} + /> + ) : null} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx index e051d4a..273fc80 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/conversation-header.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import Row from "@moduletrace/twui/layout/Row"; import Stack from "@moduletrace/twui/layout/Stack"; import H1 from "@moduletrace/twui/layout/H1"; @@ -17,6 +17,7 @@ import BotProfileModal from "./bot-profile-modal"; import ConversationHeaderMenu from "./conversation-header-menu"; import HeaderMetaItem from "./header-meta-item"; import BackButton from "@/src/components/general/back-button"; +import AdminShield from "@/src/components/general/admin-shield"; type Props = { bot: BotWithLastMessage; @@ -43,6 +44,15 @@ export default function ConversationHeader({ const { computers } = useContext(BotComputersContext); const { pageProps } = useContext(AppContext); const [model, setModel] = useState(pageProps?.bot_text_model); + /** + * The menu's toggle shows its change at once; a push (here or from + * another tab) brings the live bot's. + */ + const [is_admin, setIsAdmin] = useState(Boolean(bot.is_admin)); + + useEffect(() => { + setIsAdmin(Boolean(bot.is_admin)); + }, [bot.is_admin]); const { open: open_menu, setOpen: set_open_menu } = useStatus(); const computer = computers[0]; const computer_name = computer?.id @@ -57,9 +67,14 @@ export default function ConversationHeader({ {is_workspace ? : null} -

- {bot.name} -

+ +

+ {bot.name} +

+ {workspace?.id && is_admin ? ( + + ) : null} +
{workspace?.id ? ( @@ -138,6 +153,8 @@ export default function ConversationHeader({ ); }} on_open={() => set_open_menu(false)} + is_admin={is_admin} + on_admin_changed={setIsAdmin} /> diff --git a/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx b/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx index cd6bbe2..004b917 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/header-menu-item.tsx @@ -3,11 +3,17 @@ import Loading from "@moduletrace/twui/elements/Loading"; import Link from "@moduletrace/twui/layout/Link"; import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; +import { twMerge } from "tailwind-merge"; +import { AppData } from "@/src/data/app-data"; const RowClassName = "w-full h-9 gap-2.5! flex-nowrap items-center px-3"; type Props = { icon: LucideIcon; + /** + * Classes for the icon, e.g. a filled state. + */ + icon_class_name?: string; /** * The row's label. */ @@ -27,6 +33,7 @@ type Props = { onClick?: () => void; disabled?: boolean; loading?: boolean; + icon_size?: number; }; /** @@ -35,6 +42,7 @@ type Props = { */ export default function HeaderMenuItem({ icon: Icon, + icon_class_name, title, hint, description, @@ -42,13 +50,18 @@ export default function HeaderMenuItem({ onClick, disabled, loading, + icon_size = AppData["DefaultIconSize"], }: Props) { const content = ( <> {loading ? ( ) : ( - + )} {title} diff --git a/src/pages/admin/settings/(partials)/settings-list-row.tsx b/src/pages/admin/settings/(partials)/settings-list-row.tsx index 0d46e01..3006934 100644 --- a/src/pages/admin/settings/(partials)/settings-list-row.tsx +++ b/src/pages/admin/settings/(partials)/settings-list-row.tsx @@ -15,6 +15,10 @@ type Props = { * A word beside the title, e.g. "Default" or "You". */ badge?: string; + /** + * A mark right after the title, e.g. a workspace admin's shield. + */ + title_icon?: ReactNode; subtitle?: string; /** * Its actions, at the end of the row. @@ -26,10 +30,11 @@ type Props = { * One row of a settings list: an avatar, a title and one line about it, * then what can be done with it. */ -export default function SettingsListRow({ avatar, title, href, badge, subtitle, children }: Props) { +export default function SettingsListRow({ avatar, title, href, badge, title_icon, subtitle, children }: Props) { const title_content = ( {title} + {title_icon ? {title_icon} : null} {badge ? {badge} : null} ); diff --git a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx index ab0db87..8eafd8a 100644 --- a/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx +++ b/src/pages/admin/settings/bots/[bot_id]/(partials)/bot-workspace-row.tsx @@ -1,4 +1,5 @@ import WorkspaceAvatar from "@/src/components/general/workspace-avatar"; +import AdminShield from "@/src/components/general/admin-shield"; import type { BotWorkspace } from "@/src/types"; import SettingsListRow from "../../../(partials)/settings-list-row"; import SettingsRowAction from "../../../(partials)/settings-row-action"; @@ -33,6 +34,7 @@ export default function BotWorkspaceRow({ } href={`/admin/settings/workspaces/${workspace.id}`} title={name} + title_icon={workspace.is_admin ? : undefined} subtitle={ has_own ? "Has instructions for this workspace" diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx index f9ba7b9..cb38e54 100644 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(partials)/workspace-bot-row.tsx @@ -3,8 +3,10 @@ import Link from "@moduletrace/twui/layout/Link"; import Row from "@moduletrace/twui/layout/Row"; import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; -import { MessageSquare, NotebookPen, X } from "lucide-react"; +import { MessageSquare, NotebookPen, ShieldCheck, X } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import BotAvatar from "@/src/components/general/bot-avatar"; +import AdminShield from "@/src/components/general/admin-shield"; import type { BotFace } from "@/src/dict/bot-dict"; import botStoneProps from "@/src/utils/bot-stone-props"; import type { WorkspaceBot } from "@/src/types"; @@ -21,22 +23,30 @@ type Props = { */ chat_href: string; removing?: boolean; + /** + * Its admin role here is being changed. + */ + toggling_admin?: boolean; onEdit: () => void; onRemove: () => void; + onToggleAdmin: () => void; }; /** * A bot in the workspace: opens its settings (or, for the coordinator, - * its conversation here), chats with it here, edits what it has for this - * workspace alone, or takes it out. + * its conversation here), chats with it here, makes it the workspace's + * admin (or takes that away), edits what it has for this workspace alone, + * or takes it out. */ export default function WorkspaceBotRow({ bot, settings_href, chat_href, removing, + toggling_admin, onEdit, onRemove, + onToggleAdmin, }: Props) { const name = bot.name || "This bot"; const has_own = Boolean( @@ -60,10 +70,14 @@ export default function WorkspaceBotRow({ } className="grow min-w-0 flex flex-col border-0 leading-normal hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink rounded-default-sm" > - - {name} - + + + {name} + + {bot.is_admin ? : null} + + {bot.is_admin ? "Admin: leads the bots here ยท " : ""} {has_own ? "Has instructions for this workspace" : bot.role || "Uses its own instructions here"} @@ -83,6 +97,24 @@ export default function WorkspaceBotRow({ ) : null} +