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 <[email protected]>
This commit is contained in:
@@ -97,7 +97,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
### Bots (the chat shell)
|
||||
|
||||
- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the 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.
|
||||
|
||||
+56
-1
@@ -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`,
|
||||
|
||||
+32
-2
@@ -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
|
||||
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
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) : "";
|
||||
}
|
||||
@@ -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 (
|
||||
<ShieldCheck
|
||||
size={size}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={twMerge(
|
||||
"shrink-0 inline-block align-middle",
|
||||
"fill-success text-on-ink dark:fill-success-dark",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<title>{label}</title>
|
||||
</ShieldCheck>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<BotType, BotPermission[]> = {
|
||||
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<BotType> = {
|
||||
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<BotType> = {
|
||||
coordinator: {},
|
||||
admin: {
|
||||
allowed_actors: ["admin", "coordinator"],
|
||||
},
|
||||
worker: {},
|
||||
};
|
||||
|
||||
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||
coordinator: {},
|
||||
admin: {
|
||||
allowed_actors: ["admin", "coordinator"],
|
||||
},
|
||||
worker: {},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)],
|
||||
});
|
||||
}
|
||||
@@ -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<BotRunPowers> {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -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<BotTypeRow[]> {
|
||||
const res = await BunSQLite.sql<BotTypeRow>({
|
||||
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 || [];
|
||||
}
|
||||
@@ -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<Map<number, BotTypeRow[]>> {
|
||||
const ids = [...new Set(bot_ids.map(Number).filter(Boolean))];
|
||||
const map = new Map<number, BotTypeRow[]>();
|
||||
|
||||
if (!ids.length) return map;
|
||||
|
||||
const marks = ids.map(() => "?").join(", ");
|
||||
const res = await BunSQLite.sql<Row>({
|
||||
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;
|
||||
}
|
||||
@@ -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<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
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 || [];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<BotWithLastMessage>({
|
||||
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) }));
|
||||
}
|
||||
|
||||
@@ -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<BUN_SQLITE_COBBLE_TASKS> {
|
||||
const access = botTaskScopesSql({ bot_id, is_coordinator });
|
||||
const access = botTaskScopesSql({ bot_id, is_coordinator, admin_workspace_ids });
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
||||
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BUN_SQLITE_COBBLE_BOTS[]>;
|
||||
/**
|
||||
* 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<Args> {
|
||||
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}**`,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Args> {
|
||||
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<Args> {
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<HarnessTool[]> {
|
||||
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;
|
||||
|
||||
@@ -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<HarnessTool[]> {
|
||||
return [
|
||||
@@ -70,6 +75,7 @@ export default async function grabBotTools({
|
||||
user_id,
|
||||
bot_id,
|
||||
is_coordinator: Boolean(is_coordinator),
|
||||
admin_workspace_ids,
|
||||
workspace_id,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -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<WorkspaceAdminTools> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<Args> {
|
||||
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}".`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<BUN_SQLITE_COBBLE_BOTS[]>;
|
||||
};
|
||||
|
||||
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<Args> {
|
||||
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) {
|
||||
|
||||
@@ -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<Args> {
|
||||
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.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<HarnessTool[]> {
|
||||
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 }),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<Args> {
|
||||
export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> {
|
||||
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,
|
||||
|
||||
@@ -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<Args> {
|
||||
export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
>
|
||||
<BotAvatar image_media_id={bot.image_media_id} {...stone_props} face={(bot.face || "idle") as BotFace} />
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</Span>
|
||||
<Row className="w-full min-w-0 gap-1! flex-nowrap">
|
||||
<Span className="min-w-0 text-sm font-bold leading-5 text-ink truncate">
|
||||
{bot.name}
|
||||
</Span>
|
||||
{bot.is_admin ? <AdminShield /> : null}
|
||||
</Row>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{snippet}
|
||||
</Span>
|
||||
|
||||
@@ -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 (
|
||||
<HeaderMenuItem
|
||||
icon={ShieldCheck}
|
||||
icon_class_name={
|
||||
is_admin
|
||||
? "fill-success text-on-ink dark:fill-success-dark"
|
||||
: undefined
|
||||
}
|
||||
title={is_admin ? "Remove as admin" : "Make workspace admin"}
|
||||
hint={is_admin ? "Admin" : undefined}
|
||||
description={
|
||||
is_admin
|
||||
? `${name} leads the bots in ${place}: take that away`
|
||||
: `Make ${name} the admin of ${place}: it leads the other bots here`
|
||||
}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={toggle}
|
||||
icon_size={18}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <AdminShield size={13} /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Paper
|
||||
@@ -137,6 +155,16 @@ export default function ConversationHeaderMenu({
|
||||
on_open={on_open}
|
||||
onSaved={on_model_saved}
|
||||
/>
|
||||
{can_set_admin && workspace?.id ? (
|
||||
<BotAdminMenuItem
|
||||
bot_id={Number(bot.id)}
|
||||
bot_name={bot.name}
|
||||
workspace_id={Number(workspace.id)}
|
||||
workspace_name={workspace.name}
|
||||
is_admin={Boolean(is_admin)}
|
||||
on_changed={(value) => on_admin_changed?.(value)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
<StopBotButton menu bot_name={bot.name} />
|
||||
|
||||
@@ -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 ? <BackButton /> : null}
|
||||
<BotProfileModal bot={bot} />
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full line-clamp-1">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Row className="w-full min-w-0 gap-1.5! flex-nowrap">
|
||||
<H1 className="min-w-0 text-lg! leading-6! tracking-normal! font-bold text-ink truncate line-clamp-1">
|
||||
{bot.name}
|
||||
</H1>
|
||||
{workspace?.id && is_admin ? (
|
||||
<AdminShield size={16} label={`Admin of ${workspace.name || "this workspace"}`} />
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Row className="flex-nowrap w-full">
|
||||
{workspace?.id ? (
|
||||
@@ -138,6 +153,8 @@ export default function ConversationHeader({
|
||||
);
|
||||
}}
|
||||
on_open={() => set_open_menu(false)}
|
||||
is_admin={is_admin}
|
||||
on_admin_changed={setIsAdmin}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Row>
|
||||
|
||||
@@ -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 ? (
|
||||
<Loading size="small" className="shrink-0" />
|
||||
) : (
|
||||
<Icon size={16} aria-hidden className="shrink-0" />
|
||||
<Icon
|
||||
size={icon_size}
|
||||
aria-hidden
|
||||
className={twMerge("shrink-0", icon_class_name)}
|
||||
/>
|
||||
)}
|
||||
<Span className="min-w-0 grow truncate text-sm leading-5">
|
||||
{title}
|
||||
|
||||
@@ -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 = (
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{title}
|
||||
{title_icon ? <Span className="ml-1.5 inline-flex align-middle">{title_icon}</Span> : null}
|
||||
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
|
||||
</Span>
|
||||
);
|
||||
|
||||
@@ -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 ? <AdminShield label="Admin of this workspace" /> : undefined}
|
||||
subtitle={
|
||||
has_own
|
||||
? "Has instructions for this workspace"
|
||||
|
||||
+38
-6
@@ -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"
|
||||
>
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
</Span>
|
||||
<Row className="w-full min-w-0 gap-1! flex-nowrap">
|
||||
<Span className="min-w-0 text-sm font-bold leading-5 text-ink truncate">
|
||||
{name}
|
||||
</Span>
|
||||
{bot.is_admin ? <AdminShield /> : null}
|
||||
</Row>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{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({
|
||||
<MessageSquare size={16} aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
title={bot.is_admin ? `${name} is this workspace's admin: take that away` : `Make ${name} this workspace's admin`}
|
||||
aria-label={bot.is_admin ? `Take away ${name}'s admin role here` : `Make ${name} this workspace's admin`}
|
||||
aria-pressed={Boolean(bot.is_admin)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={toggling_admin}
|
||||
disabled={toggling_admin}
|
||||
onClick={onToggleAdmin}
|
||||
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
|
||||
>
|
||||
<ShieldCheck
|
||||
size={16}
|
||||
aria-hidden
|
||||
className={twMerge(bot.is_admin && "fill-success text-on-ink dark:fill-success-dark")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
title={`${name}'s instructions and memory here`}
|
||||
aria-label={`${name}'s instructions and memory here`}
|
||||
|
||||
+39
-1
@@ -34,6 +34,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const [bots, setBots] = useState(initial);
|
||||
const [busy_id, setBusyId] = useState<number | "add">();
|
||||
const [admin_busy_id, setAdminBusyId] = useState<number>();
|
||||
const [editing, setEditing] = useState<WorkspaceBot>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@@ -58,11 +59,46 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
||||
setError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a bot this workspace's admin, or takes that away, in place.
|
||||
*/
|
||||
const toggleAdmin = async ({ bot }: { bot: WorkspaceBot }) => {
|
||||
const given = !bot.is_admin;
|
||||
const name = bot.name || "this bot";
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
given
|
||||
? `Make ${name} the admin of ${workspace_name}? 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 ${workspace_name}?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminBusyId(Number(bot.id));
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<WorkspaceBot>({
|
||||
path: "bots/set-bot-type",
|
||||
body: { workspace_id, bot_id: bot.id, bot_type: "admin", given },
|
||||
});
|
||||
|
||||
setAdminBusyId(undefined);
|
||||
|
||||
if (res.success && res.payload) {
|
||||
setBots(res.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="bots"
|
||||
title="Bots"
|
||||
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here."
|
||||
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here. Make one the admin to have it lead the others here."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
{workers.map((bot) => (
|
||||
@@ -76,6 +112,8 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
||||
}
|
||||
chat_href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||
removing={busy_id == bot.id}
|
||||
toggling_admin={admin_busy_id == bot.id}
|
||||
onToggleAdmin={() => toggleAdmin({ bot })}
|
||||
onEdit={() => setEditing(bot)}
|
||||
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
@@ -30,6 +31,7 @@ export default function WorkspaceDashboardBotRow({ workspace_id, bot }: Props) {
|
||||
title={name}
|
||||
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||
badge={is_coordinator ? "Coordinator" : undefined}
|
||||
title_icon={bot.is_admin ? <AdminShield /> : undefined}
|
||||
subtitle={
|
||||
has_own
|
||||
? "Has instructions for this workspace"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import pushBotUpdated from "@/src/bots-queue/push-bot-updated";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
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 grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import { SetBotTypeRequestSchema } from "@/src/schemas/bot-type-schema";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Makes a bot the admin of a workspace it works in, or takes that away
|
||||
* (super admins and admins, `BotTypeAssignmentMatrix`). Returns the
|
||||
* workspace's crew, and pushes the change to everyone who sees the bot.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id, bot_type, given } = parseWithSchema({
|
||||
schema: SetBotTypeRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const change = { user_id: user.id, bot_id, workspace_id, bot_type };
|
||||
|
||||
if (given) await setBotType(change);
|
||||
else await removeBotType(change);
|
||||
|
||||
/**
|
||||
* Every open crew list shows or drops its shield.
|
||||
*/
|
||||
await pushBotUpdated({ bot_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Giving a bot a workspace type, or taking it away: today, making it the
|
||||
* workspace's admin.
|
||||
*/
|
||||
export const SetBotTypeRequestSchema = z.object({
|
||||
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
||||
workspace_id: z.coerce.number().int().positive("Pick a workspace"),
|
||||
bot_type: z.enum(["admin"]).default("admin"),
|
||||
given: z.boolean(),
|
||||
});
|
||||
|
||||
export type SetBotTypeRequest = z.infer<typeof SetBotTypeRequestSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import ensureCoordinatorType from "@/src/functions/backend/db/bot-types/ensure-coordinator-type";
|
||||
|
||||
/**
|
||||
* Safe to run again: gives the coordinator (`kind = 'coordinator'`) its
|
||||
* install-wide `coordinator` bot type. The super admin's `/admin/bots` does
|
||||
* the same through `ensureCoordinatorBot`; this is for a headless install.
|
||||
*
|
||||
* `bun src/scripts/backfill-bot-types.ts`
|
||||
*/
|
||||
async function backfillBotTypes() {
|
||||
const res = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM bots WHERE kind = 'coordinator'` });
|
||||
|
||||
for (const row of res.payload || []) await ensureCoordinatorType({ bot_id: row.id });
|
||||
|
||||
console.log(`Checked ${res.payload?.length || 0} coordinator bot types.`);
|
||||
}
|
||||
|
||||
await backfillBotTypes();
|
||||
process.exit(0);
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { UserTypes } from "../dict/user-types-dict";
|
||||
import type { BotPermissions, BotTypes } from "../dict/bot-types-dict";
|
||||
import type { StorageVolume } from "../utils/storage-volumes";
|
||||
import type { RunningVersion } from "../utils/running-version";
|
||||
import type { WebsocketRooms } from "../dict/websocket-rooms";
|
||||
@@ -72,6 +73,11 @@ export type BotWithFace = BUN_SQLITE_COBBLE_BOTS & {
|
||||
* unread, latest activity and latest message, for the rail.
|
||||
*/
|
||||
export type BotWithLastMessage = BotWithFace & {
|
||||
/**
|
||||
* In a workspace's crew: the bot is that workspace's admin
|
||||
* (`bot_types`), shown with `AdminShield` beside its name.
|
||||
*/
|
||||
is_admin?: boolean | null;
|
||||
unread?: number | null;
|
||||
last_activity_at?: number | null;
|
||||
last_message_text?: string | null;
|
||||
@@ -198,6 +204,10 @@ export type WorkspaceBot = BotWithLastMessage & {
|
||||
export type BotWorkspace = WorkspaceWithActivity & {
|
||||
workspace_system_prompt?: string | null;
|
||||
workspace_memory?: string | null;
|
||||
/**
|
||||
* The bot is this workspace's admin (`bot_types`).
|
||||
*/
|
||||
is_admin?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -307,6 +317,11 @@ export type BotUpdatedData = {
|
||||
* it has none.
|
||||
*/
|
||||
image_media_id?: number | null;
|
||||
/**
|
||||
* The workspaces it's admin of, when the push read them: a workspace's
|
||||
* crew shows or drops its shield without a reload.
|
||||
*/
|
||||
admin_workspace_ids?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1676,6 +1691,31 @@ export type UserTypeAssignmentMatrixType = Record<
|
||||
UserTypeAssignmentEntry
|
||||
>;
|
||||
|
||||
/**
|
||||
* The type-matrix shapes, for any set of types (`BotTypeAssignmentMatrix`,
|
||||
* `BotTypeTargetMatrix`). Actors may be user types or bot types.
|
||||
*/
|
||||
export type TypeAssignmentRules<A extends string = string> = {
|
||||
all?: boolean;
|
||||
same_as?: A;
|
||||
allowed_targets?: string[];
|
||||
excluded_targets?: string[];
|
||||
};
|
||||
|
||||
export type TypeAssignmentEntry<A extends string = string> = {
|
||||
allowed_actors?: A[] | Partial<Record<A, TypeAssignmentRules<A>>>;
|
||||
excluded_targets?: string[];
|
||||
};
|
||||
|
||||
export type TypeAssignmentMatrixType<
|
||||
T extends string,
|
||||
A extends string = string,
|
||||
> = Record<T, TypeAssignmentEntry<A>>;
|
||||
|
||||
export type BotType = (typeof BotTypes)[number]["value"];
|
||||
|
||||
export type BotPermission = (typeof BotPermissions)[number]["value"];
|
||||
|
||||
export type LoginFormObject = {
|
||||
username_or_email?: string;
|
||||
password?: string;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict";
|
||||
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
||||
import typeAssignment from "./type-assignment";
|
||||
|
||||
type Params = {
|
||||
actor_bot_types?: BotTypeRow[] | null;
|
||||
target_bot_types?: BotTypeRow[] | null;
|
||||
/**
|
||||
* Where they're working: types count there.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a bot may act on another (hand it a task, set its instructions)
|
||||
* where they're working, through `BotTypeTargetMatrix`: the coordinator
|
||||
* acts on anyone, a workspace admin on the workers there.
|
||||
*/
|
||||
export default function botCanTarget({ actor_bot_types, target_bot_types, workspace_id }: Params): boolean {
|
||||
const actor_types = botTypesIn({ bot_types: actor_bot_types, workspace_id });
|
||||
const target_types = botTypesIn({ bot_types: target_bot_types, workspace_id });
|
||||
|
||||
return typeAssignment({
|
||||
actor_types,
|
||||
pass_through: actor_types.includes("coordinator") && !target_types.includes("coordinator"),
|
||||
target_type: target_types[0] || "worker",
|
||||
target_types,
|
||||
action: "assign",
|
||||
assignment_matrix: BotTypeTargetMatrix,
|
||||
deletion_matrix: BotTypeTargetMatrix,
|
||||
label: "Bot RBAC",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict";
|
||||
import type { BotPermission } from "@/src/types";
|
||||
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
||||
|
||||
type Params = {
|
||||
bot_types?: BotTypeRow[] | null;
|
||||
permission: BotPermission;
|
||||
/**
|
||||
* Where the bot is working: its own chat (null) or a workspace. A
|
||||
* workspace type only counts there.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a bot's types let it do `permission` where it's working
|
||||
* (`BotTypePermissionMatrix`). This is the bot's side only: the user the
|
||||
* run works for must still be allowed to do it.
|
||||
*/
|
||||
export default function botCan({ bot_types, permission, workspace_id }: Params): boolean {
|
||||
return botTypesIn({ bot_types, workspace_id }).some((bt) =>
|
||||
BotTypePermissionMatrix[bt].includes(permission),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import { BotTypeAssignmentMatrix, BotTypeDeletionMatrix } from "@/src/dict/bot-types-dict";
|
||||
import type { BotType } from "@/src/types";
|
||||
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import typeAssignment from "./type-assignment";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A user granting it: their user types (the super admin passes
|
||||
* through).
|
||||
*/
|
||||
actor_user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
|
||||
/**
|
||||
* A bot granting it from chat: its types. Only install-wide ones count,
|
||||
* so a workspace admin never grants types.
|
||||
*/
|
||||
actor_bot_types?: BotTypeRow[] | null;
|
||||
target_bot_type: BotType;
|
||||
action: "assign" | "delete";
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a user, or a bot, may grant or remove `target_bot_type`
|
||||
* (`BotTypeAssignmentMatrix`, `BotTypeDeletionMatrix`). A bot acting for a
|
||||
* user needs both: call it once for the bot and once for the user.
|
||||
*/
|
||||
export default function botTypeAssignment({
|
||||
actor_user_types,
|
||||
actor_bot_types,
|
||||
target_bot_type,
|
||||
action,
|
||||
}: Params): boolean {
|
||||
const actor_types = actor_bot_types
|
||||
? botTypesIn({ bot_types: actor_bot_types })
|
||||
: (actor_user_types || []).map((ut) => ut.user_type).filter((ut): ut is string => Boolean(ut));
|
||||
|
||||
return typeAssignment({
|
||||
actor_types,
|
||||
pass_through: actor_bot_types ? false : isPassThroughUser({ user_types: actor_user_types as never }),
|
||||
target_type: target_bot_type,
|
||||
action,
|
||||
assignment_matrix: BotTypeAssignmentMatrix,
|
||||
deletion_matrix: BotTypeDeletionMatrix,
|
||||
label: "Bot RBAC",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_TYPES } from "@/db/types/db";
|
||||
import type { BotType } from "@/src/types";
|
||||
|
||||
export type BotTypeRow = Pick<BUN_SQLITE_COBBLE_BOT_TYPES, "bot_type"> & {
|
||||
/**
|
||||
* Null (or empty) for an install-wide type.
|
||||
*/
|
||||
workspace_id?: number | "" | null;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
bot_types?: BotTypeRow[] | null;
|
||||
/**
|
||||
* Where the bot is working: its own chat (null) or a workspace.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strongest first, so a bot holding several is judged by the one that
|
||||
* reaches furthest.
|
||||
*/
|
||||
const BotTypeRank: BotType[] = ["coordinator", "admin", "worker"];
|
||||
|
||||
/**
|
||||
* The bot types that hold where the bot is working: install-wide rows
|
||||
* everywhere, workspace rows only in their workspace. Strongest first,
|
||||
* `worker` when nothing else holds.
|
||||
*/
|
||||
export default function botTypesIn({ bot_types, workspace_id }: Params): BotType[] {
|
||||
const held = new Set<BotType>();
|
||||
|
||||
for (const row of bot_types || []) {
|
||||
if (!row.bot_type) continue;
|
||||
|
||||
const applies = !row.workspace_id || (workspace_id && row.workspace_id == Number(workspace_id));
|
||||
|
||||
if (applies) held.add(row.bot_type);
|
||||
}
|
||||
|
||||
if (!held.size) held.add("worker");
|
||||
|
||||
return BotTypeRank.filter((bt) => held.has(bt));
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import botCan from "./bot-can";
|
||||
import botCanTarget from "./bot-can-target";
|
||||
import botTypeAssignment from "./bot-type-assignment";
|
||||
import botTypesIn from "./bot-types-in";
|
||||
|
||||
const coordinator = [{ bot_type: "coordinator" as const, workspace_id: null }];
|
||||
const admin_of_3 = [{ bot_type: "admin" as const, workspace_id: 3 }];
|
||||
|
||||
describe("botTypesIn", () => {
|
||||
test("a bot with no rows is a worker", () => {
|
||||
expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
test("a workspace type only holds in its workspace", () => {
|
||||
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 3 })).toEqual(["admin"]);
|
||||
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 4 })).toEqual(["worker"]);
|
||||
expect(botTypesIn({ bot_types: admin_of_3 })).toEqual(["worker"]);
|
||||
});
|
||||
|
||||
test("an install-wide type holds everywhere", () => {
|
||||
expect(botTypesIn({ bot_types: coordinator, workspace_id: 9 })).toEqual(["coordinator"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botCan", () => {
|
||||
test("a workspace admin delegates in its workspace only", () => {
|
||||
expect(botCan({ bot_types: admin_of_3, permission: "delegate_task", workspace_id: 3 })).toBe(true);
|
||||
expect(botCan({ bot_types: admin_of_3, permission: "delegate_task", workspace_id: 5 })).toBe(false);
|
||||
expect(botCan({ bot_types: admin_of_3, permission: "delegate_task" })).toBe(false);
|
||||
});
|
||||
|
||||
test("a workspace admin doesn't manage the crew", () => {
|
||||
expect(botCan({ bot_types: admin_of_3, permission: "manage_crew", workspace_id: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
test("the coordinator can do everything", () => {
|
||||
expect(botCan({ bot_types: coordinator, permission: "manage_extensions" })).toBe(true);
|
||||
expect(botCan({ bot_types: coordinator, permission: "delegate_task", workspace_id: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
test("a worker can't delegate", () => {
|
||||
expect(botCan({ bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botCanTarget", () => {
|
||||
test("a workspace admin directs the workers there", () => {
|
||||
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: [], workspace_id: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
test("a workspace admin doesn't direct another admin or the coordinator", () => {
|
||||
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(false);
|
||||
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: coordinator, workspace_id: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
test("an admin elsewhere is a worker here", () => {
|
||||
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: [], workspace_id: 4 })).toBe(false);
|
||||
expect(botCanTarget({
|
||||
actor_bot_types: admin_of_3,
|
||||
target_bot_types: [{ bot_type: "admin", workspace_id: 8 }],
|
||||
workspace_id: 3,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test("the coordinator directs workers and admins, never itself", () => {
|
||||
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: [], workspace_id: 3 })).toBe(true);
|
||||
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true);
|
||||
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: coordinator })).toBe(false);
|
||||
});
|
||||
|
||||
test("a worker directs no one", () => {
|
||||
expect(botCanTarget({ actor_bot_types: [], target_bot_types: [], workspace_id: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botTypeAssignment", () => {
|
||||
const user = (...types: string[]) => types.map((user_type) => ({ user_type }));
|
||||
|
||||
test("super admins and admins grant and remove workspace admin", () => {
|
||||
for (const action of ["assign", "delete"] as const) {
|
||||
expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "admin", action })).toBe(true);
|
||||
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "admin", action })).toBe(true);
|
||||
expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "admin", action })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("only the super admin passes for the coordinator type", () => {
|
||||
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "coordinator", action: "assign" })).toBe(false);
|
||||
});
|
||||
|
||||
test("the coordinator bot grants workspace admin, a workspace admin doesn't", () => {
|
||||
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "admin", action: "assign" })).toBe(true);
|
||||
expect(botTypeAssignment({ actor_bot_types: admin_of_3, target_bot_type: "admin", action: "assign" })).toBe(false);
|
||||
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "coordinator", action: "assign" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { TypeAssignmentMatrixType, TypeAssignmentRules } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The actor's types: user types, bot types, or both.
|
||||
*/
|
||||
actor_types: string[];
|
||||
/**
|
||||
* Whether the actor bypasses the matrix (the super admin).
|
||||
*/
|
||||
pass_through?: boolean;
|
||||
target_type: string;
|
||||
/**
|
||||
* The types the target already holds.
|
||||
*/
|
||||
target_types?: string[];
|
||||
action: "assign" | "delete";
|
||||
assignment_matrix: TypeAssignmentMatrixType<string>;
|
||||
deletion_matrix: TypeAssignmentMatrixType<string>;
|
||||
/**
|
||||
* Prefix for debug lines.
|
||||
*/
|
||||
label?: string;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The type-matrix interpreter, for any set of types (user types through
|
||||
* `userTypeAssignment`, bot types through `botTypeAssignment` and
|
||||
* `botCanTarget`). Whether an actor holding `actor_types` may assign or
|
||||
* remove `target_type` on a target holding `target_types`.
|
||||
*
|
||||
* Matrix forms:
|
||||
* - `allowed_actors` as an array lists actor types allowed to act.
|
||||
* - `allowed_actors` as a per-actor matrix keys actors to rules:
|
||||
* - `allowed_targets`: the target must hold at least one of these types.
|
||||
* - `excluded_targets`: the target must not hold any of these types.
|
||||
* - `all`: anything goes for that actor.
|
||||
* - `same_as`: clones the rules of another actor type.
|
||||
* - Entry-level `excluded_targets` blocks the action when the target holds
|
||||
* any listed type.
|
||||
* - Empty/missing `allowed_actors` means only pass-through actors may act.
|
||||
*/
|
||||
export default function typeAssignment({
|
||||
actor_types,
|
||||
pass_through,
|
||||
target_type,
|
||||
target_types,
|
||||
action,
|
||||
assignment_matrix,
|
||||
deletion_matrix,
|
||||
label = "RBAC",
|
||||
debug,
|
||||
}: Params): boolean {
|
||||
const log = (text: string) => {
|
||||
if (debug) console.log(`[${label}] ${text}`);
|
||||
};
|
||||
|
||||
if (pass_through) {
|
||||
log(`Pass-through actor detected for [${target_type}], access granted.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const matrix = action == "assign" ? assignment_matrix : deletion_matrix;
|
||||
|
||||
const assignable_by = matrix[target_type];
|
||||
|
||||
if (!assignable_by?.allowed_actors) {
|
||||
log(`No allowed actors for [${target_type}] in the ${action} matrix.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const is_target_type_excluded =
|
||||
target_types && assignable_by.excluded_targets
|
||||
? target_types.find((tt) =>
|
||||
assignable_by.excluded_targets?.find((ex) => ex == tt),
|
||||
)
|
||||
: false;
|
||||
|
||||
if (is_target_type_excluded) {
|
||||
log(`Target types [${target_types?.join(",")}] excluded for [${target_type}].`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const actor_type_set = new Set<string>(
|
||||
actor_types.filter((at): at is string => Boolean(at)),
|
||||
);
|
||||
|
||||
if (Array.isArray(assignable_by.allowed_actors)) {
|
||||
if (!assignable_by.allowed_actors.length) {
|
||||
log(`Actor not allowed for [${target_type}], allowed actors list is empty.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return assignable_by.allowed_actors.some((actor_type) =>
|
||||
actor_type_set.has(actor_type),
|
||||
);
|
||||
}
|
||||
|
||||
const allowed_actors_matrix = assignable_by.allowed_actors as Record<
|
||||
string,
|
||||
TypeAssignmentRules | undefined
|
||||
>;
|
||||
|
||||
if (!Object.keys(allowed_actors_matrix).length) {
|
||||
log(`No allowed actors matrix for [${target_type}].`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.from(actor_type_set).some((actor_type) => {
|
||||
let actor_rules = allowed_actors_matrix[actor_type];
|
||||
|
||||
if (
|
||||
actor_rules?.same_as &&
|
||||
allowed_actors_matrix[actor_rules.same_as]
|
||||
) {
|
||||
actor_rules = {
|
||||
...actor_rules,
|
||||
...allowed_actors_matrix[actor_rules.same_as],
|
||||
};
|
||||
}
|
||||
|
||||
if (actor_rules?.all) {
|
||||
log(`Actor [${actor_type}] has all access for [${target_type}].`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!actor_rules?.allowed_targets && !actor_rules?.excluded_targets) {
|
||||
log(`No actor rules for [${actor_type}] on [${target_type}].`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
actor_rules.allowed_targets &&
|
||||
!actor_rules.allowed_targets.find((t) => target_types?.includes(t))
|
||||
) {
|
||||
log(`Target types [${target_types?.join(",")}] not included for [${actor_type}] -> [${target_type}].`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
actor_rules.excluded_targets &&
|
||||
actor_rules.excluded_targets.find((t) => target_types?.includes(t))
|
||||
) {
|
||||
log(`Target types [${target_types?.join(",")}] excluded for [${actor_type}] -> [${target_type}].`);
|
||||
return false;
|
||||
}
|
||||
|
||||
log(`Actor [${actor_type}] granted [${action}] of [${target_type}].`);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import type {
|
||||
UserAssignmentFnParams,
|
||||
UserTypeAssignmentRules,
|
||||
} from "@/src/types";
|
||||
import type { UserAssignmentFnParams } from "@/src/types";
|
||||
import {
|
||||
UserTypeAssignmentMatrix,
|
||||
UserTypeDeletionMatrix,
|
||||
} from "@/src/dict/user-types-dict";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import typeAssignment from "./type-assignment";
|
||||
|
||||
/**
|
||||
* Whether the actor may assign or remove `target_user_type` on a user.
|
||||
* Governed by `UserTypeAssignmentMatrix` and `UserTypeDeletionMatrix` and
|
||||
* applied symmetrically to granting and removing. Pass-through actors
|
||||
* (`super_admin`) can always assign.
|
||||
* (`super_admin`) can always assign. The interpreter is `typeAssignment`.
|
||||
*/
|
||||
export default function userTypeAssignment({
|
||||
actor_user_types,
|
||||
@@ -23,143 +21,16 @@ export default function userTypeAssignment({
|
||||
deletion_matrix = UserTypeDeletionMatrix,
|
||||
debug,
|
||||
}: UserAssignmentFnParams): boolean {
|
||||
if (isPassThroughUser({ user_types: actor_user_types })) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Pass-through actor detected for [${target_user_type}], access granted.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const matrix = action == "assign" ? assignment_matrix : deletion_matrix;
|
||||
|
||||
const assignable_by = matrix[target_user_type];
|
||||
|
||||
if (!assignable_by?.allowed_actors) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] No allowed actors for [${target_user_type}] in the ${action} matrix.`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const is_target_user_type_excluded =
|
||||
target_user_types && assignable_by.excluded_targets
|
||||
? target_user_types.find((tut) =>
|
||||
assignable_by.excluded_targets?.find((ex) => ex == tut),
|
||||
)
|
||||
: false;
|
||||
|
||||
if (is_target_user_type_excluded) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Target user type [${target_user_types?.join(",")}] excluded for [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const actor_type_set = new Set<string>(
|
||||
actor_user_types
|
||||
?.map((ut) => ut.user_type)
|
||||
return typeAssignment({
|
||||
actor_types: (actor_user_types || [])
|
||||
.map((ut) => ut.user_type)
|
||||
.filter((ut): ut is string => Boolean(ut)),
|
||||
);
|
||||
|
||||
if (Array.isArray(assignable_by.allowed_actors)) {
|
||||
if (!assignable_by.allowed_actors.length) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Actor not allowed for [${target_user_type}], allowed actors list is empty.`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return assignable_by.allowed_actors.some((actor_type) =>
|
||||
actor_type_set.has(actor_type),
|
||||
);
|
||||
}
|
||||
|
||||
const allowed_actors_matrix = assignable_by.allowed_actors as Record<
|
||||
string,
|
||||
UserTypeAssignmentRules | undefined
|
||||
>;
|
||||
|
||||
if (!Object.keys(allowed_actors_matrix).length) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] No allowed actors matrix for [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.from(actor_type_set).some((actor_type) => {
|
||||
let actor_rules = allowed_actors_matrix[actor_type];
|
||||
|
||||
if (
|
||||
actor_rules?.same_as &&
|
||||
allowed_actors_matrix[actor_rules.same_as]
|
||||
) {
|
||||
actor_rules = {
|
||||
...actor_rules,
|
||||
...allowed_actors_matrix[actor_rules.same_as],
|
||||
};
|
||||
}
|
||||
|
||||
if (actor_rules?.all) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Actor [${actor_type}] has all access for [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!actor_rules?.allowed_targets && !actor_rules?.excluded_targets) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] No actor rules for [${actor_type}] on [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
actor_rules.allowed_targets &&
|
||||
!actor_rules.allowed_targets.find((t) =>
|
||||
target_user_types?.includes(t),
|
||||
)
|
||||
) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Target user types [${target_user_types?.join(",")}] not included for [${actor_type}] -> [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
actor_rules.excluded_targets &&
|
||||
actor_rules.excluded_targets.find((t) =>
|
||||
target_user_types?.includes(t),
|
||||
)
|
||||
) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Target user types [${target_user_types?.join(",")}] excluded for [${actor_type}] -> [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Actor [${actor_type}] granted [${action}] of [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
pass_through: isPassThroughUser({ user_types: actor_user_types }),
|
||||
target_type: target_user_type,
|
||||
target_types: target_user_types,
|
||||
action,
|
||||
assignment_matrix,
|
||||
deletion_matrix,
|
||||
debug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ type Params = {
|
||||
* bot's own and every workspace's).
|
||||
*/
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* The workspaces the bot is admin of: it also reaches the tasks it added
|
||||
* to their members' own lists.
|
||||
*/
|
||||
admin_workspace_ids?: number[];
|
||||
/**
|
||||
* The table alias the condition reads.
|
||||
*/
|
||||
@@ -16,15 +21,37 @@ type Params = {
|
||||
/**
|
||||
* The tasks a bot's run reaches: its own, plus every workspace it works in
|
||||
* (a workspace's tasks are shared by its bots). The coordinator reaches
|
||||
* every task in the install: every bot's own and every workspace's.
|
||||
* every task in the install: every bot's own and every workspace's. A
|
||||
* workspace admin also reaches the tasks it put on that workspace's
|
||||
* members' own lists.
|
||||
*/
|
||||
export default function botTaskScopesSql({
|
||||
bot_id,
|
||||
is_coordinator,
|
||||
admin_workspace_ids = [],
|
||||
alias = "t",
|
||||
}: Params): SqlCondition {
|
||||
if (is_coordinator) return { sql: `1`, values: [] };
|
||||
|
||||
const admin_ids = admin_workspace_ids.map(Number).filter(Boolean);
|
||||
|
||||
if (admin_ids.length) {
|
||||
const marks = admin_ids.map(() => "?").join(", ");
|
||||
|
||||
return {
|
||||
sql: `(
|
||||
${alias}.bot_id = ?
|
||||
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (
|
||||
SELECT workspace_id FROM workspace_bots WHERE bot_id = ?
|
||||
))
|
||||
OR (${alias}.created_by_bot_id = ? AND ${alias}.bot_id IN (
|
||||
SELECT bot_id FROM workspace_bots WHERE workspace_id IN (${marks})
|
||||
))
|
||||
)`,
|
||||
values: [Number(bot_id), Number(bot_id), Number(bot_id), ...admin_ids],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `(
|
||||
${alias}.bot_id = ?
|
||||
|
||||
@@ -18,10 +18,10 @@ beforeAll(() => {
|
||||
db.run(`INSERT INTO workspace_users VALUES (11, 7)`);
|
||||
db.run(`INSERT INTO workspace_bots VALUES (11, 3), (10, 3)`);
|
||||
|
||||
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`);
|
||||
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
|
||||
db.run(`INSERT INTO tasks VALUES
|
||||
(100, 1, NULL), (101, 2, NULL), (102, 3, NULL),
|
||||
(110, NULL, 10), (111, NULL, 11)`);
|
||||
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
|
||||
(110, NULL, 10, NULL), (111, NULL, 11, NULL)`);
|
||||
});
|
||||
|
||||
function ids({ condition }: { condition: SqlCondition }) {
|
||||
@@ -61,3 +61,16 @@ describe("botTaskScopesSql", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botTaskScopesSql for a workspace admin", () => {
|
||||
test("it also reaches what it put on its workspace members' own lists", () => {
|
||||
db.run(`INSERT INTO workspace_bots VALUES (11, 4)`);
|
||||
db.run(`INSERT INTO tasks VALUES (103, 3, NULL, 4), (104, 2, NULL, 4)`);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
|
||||
103, 111,
|
||||
]);
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]);
|
||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
|
||||
db.run(`DELETE FROM tasks WHERE id IN (103, 104)`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user