Make bot types an extension of the bot's kind

Coordinator and worker come from bots.kind; bot_types now only holds
workspace_admin (was admin), each row naming its workspace, with no scope
field. Rows from v0.2.8 and earlier are fixed at boot by migrateBotTypes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-26 06:05:18 +01:00
co-authored by Claude Opus 5.5
parent 657a76d868
commit 926bad6bea
29 changed files with 168 additions and 175 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Bot types
`bot_types` is to bots what `user_types` is to users (`src/dict/bot-types-dict.ts`): `coordinator` (install-wide, written only by `ensureCoordinatorBot`), `admin` (scoped to one workspace), `worker` (no row). `BotTypePermissionMatrix`, `BotTypeTargetMatrix`, `BotTypeAssignmentMatrix`/`DeletionMatrix` are read by the generic interpreter `src/utils/rbac/type-assignment.ts`. Every run reads `grabBotRunPowers` once and gates tools on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, **never on `bots.kind`** (`kind` is only the legacy marker of the coordinator for visibility SQL). A run's powers are its type's AND its user's. A workspace admin gets `grabWorkspaceAdminTools` (delegate to members, set their workspace instructions, edit that workspace). Set via `api/admin/bots/set-bot-type`. Admins are marked with `AdminShield` (green, beside the name, never on the stone).
`bot_types` extends `bots.kind` (worker | coordinator) the way `user_types` extends users (`src/dict/bot-types-dict.ts`): today only `workspace_admin`, each row naming its workspace (no row = just the kind; the coordinator is its kind, written only by `ensureCoordinatorBot`). A bot's role where it works is its kind plus the types it holds there (`botTypesIn`). `BotTypePermissionMatrix`, `BotTypeTargetMatrix` (keyed by role), `BotTypeAssignmentMatrix`/`DeletionMatrix` (types only) are read by the generic interpreter `src/utils/rbac/type-assignment.ts`. Every run reads `grabBotRunPowers` once and gates tools on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, never on its own check of `kind`. A run's powers are its role's AND its user's. Rows from v0.2.8 and earlier (`admin`, `coordinator`) are fixed at boot by `migrateBotTypes`. A workspace admin gets `grabWorkspaceAdminTools` (delegate to members, set their workspace instructions, edit that workspace). Set via `api/admin/bots/set-bot-type`. Admins are marked with `AdminShield` (green, beside the name, never on the stone).
### Bots, sessions, chat
+3 -3
View File
@@ -238,7 +238,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
{
fieldName: "kind",
fieldDescription: `Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from \`bot_types\``,
fieldDescription: `Worker or the one coordinator (which only the super admin sees and reaches). \`bot_types\` add to it`,
dataType: "TEXT",
options: BotKinds.map((bk) => bk.value),
defaultValue: "worker",
@@ -1621,7 +1621,7 @@ 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`,
tableDescription: `Types a bot holds on top of its \`kind\`, like \`user_types\` for users (\`BotTypePermissionMatrix\`): each row holds in one workspace (a workspace admin). A bot with no row is just its kind. One per bot, type and workspace, enforced in \`setBotType\``,
fields: [
{
fieldName: "bot_id",
@@ -1641,7 +1641,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
{
fieldName: "workspace_id",
fieldDescription: `The workspace the type holds in; null for install-wide`,
fieldDescription: `The workspace the type holds in`,
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
+3 -3
View File
@@ -198,7 +198,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
user_id?: number | "";
name?: string;
/**
* Legacy: marks the one coordinator, which only the super admin sees and reaches. What a bot can do comes from `bot_types`
* Worker or the one coordinator (which only the super admin sees and reaches). `bot_types` add to it
*/
kind?: "worker" | "coordinator" | "";
/**
@@ -1036,9 +1036,9 @@ export type BUN_SQLITE_COBBLE_BOT_TYPES = {
*/
updated_at?: number | "";
bot_id?: number | "";
bot_type?: "coordinator" | "admin" | "worker" | "";
bot_type?: "workspace_admin" | "";
/**
* The workspace the type holds in; null for install-wide
* The workspace the type holds in
*/
workspace_id?: number | "";
/**
+1 -1
View File
@@ -80,7 +80,7 @@ async function push({
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`,
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'workspace_admin' AND workspace_id IS NOT NULL`,
values: [bot_id],
})
).payload || []
+15 -36
View File
@@ -1,30 +1,15 @@
import type { BotPermission, BotType, TypeAssignmentMatrixType } from "../types";
import type { BotPermission, BotRole, 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.
* Types a bot holds on top of its kind (`bots.kind`: coordinator or
* worker), like `user_types` for users. A bot with none is just its kind.
* Each `bot_types` row names the workspace the type holds in.
*/
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",
value: "workspace_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;
@@ -75,12 +60,11 @@ export const BotPermissions = [
] as const;
/**
* What each bot type may do, where it holds (everywhere for an
* install-wide type, in its workspace for a workspace one).
* What each role (a kind, or a type held where the bot works) may do.
*/
export const BotTypePermissionMatrix: Record<BotType, BotPermission[]> = {
export const BotTypePermissionMatrix: Record<BotRole, BotPermission[]> = {
coordinator: BotPermissions.map((bp) => bp.value),
admin: [
workspace_admin: [
"delegate_task",
"set_member_instructions",
"manage_workspace_tasks",
@@ -91,37 +75,32 @@ export const BotTypePermissionMatrix: Record<BotType, BotPermission[]> = {
/**
* 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
* by the target's role. 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> = {
export const BotTypeTargetMatrix: TypeAssignmentMatrixType<BotRole> = {
coordinator: {},
admin: {
workspace_admin: {
allowed_actors: [],
},
worker: {
allowed_actors: ["admin"],
allowed_actors: ["workspace_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.
* through) and bot kinds (`coordinator`).
*/
export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType<BotType> = {
coordinator: {},
admin: {
workspace_admin: {
allowed_actors: ["admin", "coordinator"],
},
worker: {},
};
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
coordinator: {},
admin: {
workspace_admin: {
allowed_actors: ["admin", "coordinator"],
},
worker: {},
};
@@ -6,7 +6,7 @@ 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";
import grabBotRunPowers from "./grab-bot-run-powers";
export type BotTypeChangeParams = {
/**
@@ -25,9 +25,8 @@ export type BotTypeChangeParams = {
/**
* 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.
* acts) may do it (`botTypeAssignment`), the type exists, and the bot
* works in the workspace. Returns the bot and the workspace id.
*/
export default async function assertBotTypeChange({
user_id,
@@ -39,8 +38,8 @@ export default async function assertBotTypeChange({
}: 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 (!type) {
throw new AppError(`There's no such bot type.`);
}
if (!workspace_id) {
@@ -54,9 +53,10 @@ export default async function assertBotTypeChange({
}
if (actor_bot_id) {
const actor_bot_types = await grabBotTypes({ bot_id: actor_bot_id });
const actor = await grabBotRunPowers({ bot_id: actor_bot_id });
const actor_bot = { kind: actor.is_coordinator ? ("coordinator" as const) : ("worker" as const), bot_types: actor.bot_types };
if (!botTypeAssignment({ actor_bot_types, target_bot_type: bot_type, action })) {
if (!botTypeAssignment({ actor_bot, target_bot_type: bot_type, action })) {
throw new AppError(`Only the coordinator can do that.`);
}
}
@@ -1,19 +0,0 @@
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)],
});
}
@@ -1,3 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotKind } from "@/src/dict/bot-dict";
import type { BotPermission } from "@/src/types";
import botCan from "@/src/utils/rbac/bot-can";
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
@@ -14,7 +16,7 @@ type Params = {
export type BotRunPowers = {
bot_types: BotTypeRow[];
/**
* Holds the install-wide coordinator type.
* Its kind is `coordinator`.
*/
is_coordinator: boolean;
/**
@@ -26,27 +28,35 @@ export type BotRunPowers = {
*/
is_workspace_admin: boolean;
/**
* Whether its types allow `permission` where the run works. The bot's
* Whether its kind and 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`).
* What a bot's kind and 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 bot = await BunSQLite.sql<{ kind: BotKind | null }>({
sql: `SELECT kind FROM bots WHERE id = ? LIMIT 1`,
values: [Number(bot_id)],
});
const kind = bot.payload?.[0]?.kind || "worker";
const admin_workspace_ids = bot_types
.filter((bt) => bt.bot_type == "admin" && bt.workspace_id)
.filter((bt) => bt.bot_type == "workspace_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),
is_coordinator: kind == "coordinator",
admin_workspace_ids,
is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))),
can: (permission) => botCan({ bot_types, permission, workspace_id }),
can: (permission) => botCan({ kind, bot_types, permission, workspace_id }),
};
}
@@ -6,15 +6,13 @@ type Params = {
};
/**
* 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.
* A bot's types (`bot_types`), each held in one workspace. Its kind
* (`bots.kind`) is read on its own.
*/
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)],
sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ? AND workspace_id IS NOT NULL`,
values: [Number(bot_id)],
});
return res.payload || [];
@@ -9,7 +9,7 @@ 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).
* row is missing from the map (just its kind).
*/
export default async function grabBotsTypes({ bot_ids }: Params): Promise<Map<number, BotTypeRow[]>> {
const ids = [...new Set(bot_ids.map(Number).filter(Boolean))];
@@ -19,9 +19,8 @@ export default async function grabBotsTypes({ bot_ids }: Params): Promise<Map<nu
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],
sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks}) AND workspace_id IS NOT NULL`,
values: ids,
});
for (const row of res.payload || []) {
@@ -1,7 +1,7 @@
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 grabBotRunPowers from "./grab-bot-run-powers";
import grabBotsTypes from "./grab-bots-types";
type Params = {
@@ -22,12 +22,14 @@ export default async function grabDirectableBots({ bot_id, workspace_id }: Param
if (!members.length) return [];
const actor_bot_types = await grabBotTypes({ bot_id });
const actor = await grabBotRunPowers({ bot_id, workspace_id });
const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) });
return members.filter((bot) =>
botCanTarget({
actor_bot_types,
actor_kind: actor.is_coordinator ? "coordinator" : "worker",
actor_bot_types: actor.bot_types,
target_kind: bot.kind,
target_bot_types: types.get(Number(bot.id)) || [],
workspace_id,
}),
@@ -11,7 +11,7 @@ type Params = {
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 bot_types bt ON bt.bot_id = b.id AND bt.bot_type = 'workspace_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)],
@@ -0,0 +1,18 @@
import BunSQLite from "@moduletrace/bun-sqlite";
/**
* Brings `bot_types` rows from v0.2.8 and earlier up to date, safe to run
* on every start: the coordinator is its `bots.kind` now, so its old
* `coordinator` row (and any row without a workspace) goes, and `admin`
* became `workspace_admin`.
*/
export default async function migrateBotTypes() {
await BunSQLite.sql({
sql: `DELETE FROM bot_types WHERE bot_type = 'coordinator' OR bot_type = 'worker' OR workspace_id IS NULL`,
});
await BunSQLite.sql({
sql: `UPDATE bot_types SET bot_type = 'workspace_admin', updated_at = ? WHERE bot_type = 'admin'`,
values: [Date.now()],
});
}
@@ -3,7 +3,6 @@ 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 = {
/**
@@ -17,8 +16,7 @@ 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. Its install-wide `coordinator` bot type is written too
* (`ensureCoordinatorType`), which backfills older installs. Safe to call
* overwritten. Its `kind` is what makes it the coordinator. Safe to call
* on every visit.
*/
export default async function ensureCoordinatorBot({ user_id }: Params) {
@@ -46,8 +44,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
});
}
await ensureCoordinatorType({ bot_id: existing.id });
return existing.id;
}
@@ -73,7 +69,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
}
await attachDefaultExtensions({ bot_id });
await ensureCoordinatorType({ bot_id });
return bot_id;
}
+1 -1
View File
@@ -42,7 +42,7 @@ export default async function grabBots({
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 += ` 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 += ` EXISTS (SELECT 1 FROM bot_types bt WHERE bt.bot_id = b.id AND bt.bot_type = 'workspace_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`;
@@ -41,7 +41,7 @@ export default async function grabBotWorkspaces({
(
(
await BunSQLite.sql<{ workspace_id: number }>({
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'admin'`,
sql: `SELECT workspace_id FROM bot_types WHERE bot_id = ? AND bot_type = 'workspace_admin'`,
values: [Number(bot.id)],
})
).payload || []
+1 -1
View File
@@ -60,7 +60,7 @@ export default function findBotTool(): HarnessTool<Args> {
return matches
.map((bot) => {
const led = (types.get(Number(bot.id)) || [])
.filter((bt) => bt.bot_type == "admin" && bt.workspace_id)
.filter((bt) => bt.bot_type == "workspace_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}`;
+2 -2
View File
@@ -68,14 +68,14 @@ export default function setBotTypeTool({ user_id, bot_id, workers, workspaces, w
user_id,
actor_bot_id: bot_id,
bot_id: args.bot_id,
bot_type: "admin" as const,
bot_type: "workspace_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,
(bt) => bt.bot_type == "workspace_admin" && bt.workspace_id == target_workspace_id,
);
if (saved != args.admin) {
@@ -54,7 +54,7 @@ export default function BotAdminMenuItem({
const res = await postAdminApi({
path: "bots/set-bot-type",
body: { bot_id, workspace_id, bot_type: "admin", given },
body: { bot_id, workspace_id, bot_type: "workspace_admin", given },
}).catch(() => null);
setLoading(false);
@@ -81,7 +81,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
const res = await postAdminApi<WorkspaceBot>({
path: "bots/set-bot-type",
body: { workspace_id, bot_id: bot.id, bot_type: "admin", given },
body: { workspace_id, bot_id: bot.id, bot_type: "workspace_admin", given },
});
setAdminBusyId(undefined);
+1 -1
View File
@@ -7,7 +7,7 @@ import { z } from "zod";
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"),
bot_type: z.enum(["workspace_admin"]).default("workspace_admin"),
given: z.boolean(),
});
-20
View File
@@ -1,20 +0,0 @@
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);
+4
View File
@@ -9,6 +9,7 @@ import botsQueue from "./bots-queue";
import fileIngest from "./file-ingest";
import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import migrateBotTypes from "./functions/backend/db/bot-types/migrate-bot-types";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
import handleInstallIconFile from "./functions/server/pwa/handle-install-icon-file";
@@ -30,6 +31,9 @@ const development = process.env.NODE_ENV == "development";
const port = process.env.PORT || 7227;
await enableForeignKeys();
await migrateBotTypes().catch((error) =>
logError({ title: "Could not migrate bot types", error }).catch(() => {}),
);
await bunext.bunextInit();
/**
+6 -1
View File
@@ -34,7 +34,7 @@ import type { HarnessKind } from "../dict/harness-dict";
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
import type { EnvParadigm, KnownEnv, SecretRequestStatus } from "../dict/env-dict";
import type { ContainerStatus } from "../dict/computer-dict";
import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
import type { BotFace, BotKind, BotMessageReactionEmoji } from "../dict/bot-dict";
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict";
@@ -2051,6 +2051,11 @@ export type TypeAssignmentMatrixType<
export type BotType = (typeof BotTypes)[number]["value"];
/**
* What a bot is where it works: its kind, or a type it holds there.
*/
export type BotRole = BotKind | BotType;
export type BotPermission = (typeof BotPermissions)[number]["value"];
export type LoginFormObject = {
+12 -3
View File
@@ -1,9 +1,12 @@
import type { BotKind } from "@/src/dict/bot-dict";
import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
import typeAssignment from "./type-assignment";
type Params = {
actor_kind?: BotKind | "" | null;
actor_bot_types?: BotTypeRow[] | null;
target_kind?: BotKind | "" | null;
target_bot_types?: BotTypeRow[] | null;
/**
* Where they're working: types count there.
@@ -16,9 +19,15 @@ type Params = {
* 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 });
export default function botCanTarget({
actor_kind,
actor_bot_types,
target_kind,
target_bot_types,
workspace_id,
}: Params): boolean {
const actor_types = botTypesIn({ kind: actor_kind, bot_types: actor_bot_types, workspace_id });
const target_types = botTypesIn({ kind: target_kind, bot_types: target_bot_types, workspace_id });
return typeAssignment({
actor_types,
+5 -3
View File
@@ -1,8 +1,10 @@
import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict";
import type { BotKind } from "@/src/dict/bot-dict";
import type { BotPermission } from "@/src/types";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
type Params = {
kind?: BotKind | "" | null;
bot_types?: BotTypeRow[] | null;
permission: BotPermission;
/**
@@ -13,12 +15,12 @@ type Params = {
};
/**
* Whether a bot's types let it do `permission` where it's working
* Whether a bot's kind and 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) =>
export default function botCan({ kind, bot_types, permission, workspace_id }: Params): boolean {
return botTypesIn({ kind, bot_types, workspace_id }).some((bt) =>
BotTypePermissionMatrix[bt].includes(permission),
);
}
+11 -7
View File
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { BotKind } from "@/src/dict/bot-dict";
import { BotTypeAssignmentMatrix, BotTypeDeletionMatrix } from "@/src/dict/bot-types-dict";
import type { BotType } from "@/src/types";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
@@ -12,10 +13,13 @@ type Params = {
*/
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.
* A bot granting it from chat: its kind and types. Only its kind counts
* (no workspace), so a workspace admin never grants types.
*/
actor_bot_types?: BotTypeRow[] | null;
actor_bot?: {
kind?: BotKind | "" | null;
bot_types?: BotTypeRow[] | null;
} | null;
target_bot_type: BotType;
action: "assign" | "delete";
};
@@ -27,17 +31,17 @@ type Params = {
*/
export default function botTypeAssignment({
actor_user_types,
actor_bot_types,
actor_bot,
target_bot_type,
action,
}: Params): boolean {
const actor_types = actor_bot_types
? botTypesIn({ bot_types: actor_bot_types })
const actor_types = actor_bot
? botTypesIn({ kind: actor_bot.kind, bot_types: actor_bot.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 }),
pass_through: actor_bot ? false : isPassThroughUser({ user_types: actor_user_types as never }),
target_type: target_bot_type,
action,
assignment_matrix: BotTypeAssignmentMatrix,
+18 -13
View File
@@ -1,14 +1,19 @@
import type { BUN_SQLITE_COBBLE_BOT_TYPES } from "@/db/types/db";
import type { BotType } from "@/src/types";
import type { BotKind } from "@/src/dict/bot-dict";
import type { BotRole } from "@/src/types";
export type BotTypeRow = Pick<BUN_SQLITE_COBBLE_BOT_TYPES, "bot_type"> & {
/**
* Null (or empty) for an install-wide type.
* The workspace the type holds in.
*/
workspace_id?: number | "" | null;
};
type Params = {
/**
* The bot's kind (`bots.kind`): a worker when missing.
*/
kind?: BotKind | "" | null;
bot_types?: BotTypeRow[] | null;
/**
* Where the bot is working: its own chat (null) or a workspace.
@@ -20,25 +25,25 @@ type Params = {
* Strongest first, so a bot holding several is judged by the one that
* reaches furthest.
*/
const BotTypeRank: BotType[] = ["coordinator", "admin", "worker"];
const BotRoleRank: BotRole[] = ["coordinator", "workspace_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.
* The roles a bot holds where it's working: the coordinator kind
* everywhere, a type only in its workspace. Strongest first, `worker` when
* nothing else holds.
*/
export default function botTypesIn({ bot_types, workspace_id }: Params): BotType[] {
const held = new Set<BotType>();
export default function botTypesIn({ kind, bot_types, workspace_id }: Params): BotRole[] {
const held = new Set<BotRole>();
if (kind == "coordinator") held.add("coordinator");
for (const row of bot_types || []) {
if (!row.bot_type) continue;
if (!row.bot_type || !row.workspace_id || !workspace_id) continue;
const applies = !row.workspace_id || (workspace_id && row.workspace_id == Number(workspace_id));
if (applies) held.add(row.bot_type);
if (row.workspace_id == Number(workspace_id)) held.add(row.bot_type);
}
if (!held.size) held.add("worker");
return BotTypeRank.filter((bt) => held.has(bt));
return BotRoleRank.filter((role) => held.has(role));
}
+25 -23
View File
@@ -4,22 +4,29 @@ 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 }];
const admin_of_3 = [{ bot_type: "workspace_admin" as const, workspace_id: 3 }];
describe("botTypesIn", () => {
test("a bot with no rows is a worker", () => {
expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]);
expect(botTypesIn({ kind: "worker", 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: 3 })).toEqual(["workspace_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"]);
test("a type row without a workspace holds nowhere", () => {
expect(botTypesIn({ bot_types: [{ bot_type: "workspace_admin", workspace_id: null }], workspace_id: 3 })).toEqual([
"worker",
]);
});
test("the coordinator kind holds everywhere", () => {
expect(botTypesIn({ kind: "coordinator", workspace_id: 9 })).toEqual(["coordinator"]);
expect(botTypesIn({ kind: "coordinator" })).toEqual(["coordinator"]);
});
});
@@ -35,12 +42,12 @@ describe("botCan", () => {
});
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);
expect(botCan({ kind: "coordinator", permission: "manage_extensions" })).toBe(true);
expect(botCan({ kind: "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);
expect(botCan({ kind: "worker", bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false);
});
});
@@ -51,22 +58,22 @@ describe("botCanTarget", () => {
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);
expect(botCanTarget({ actor_bot_types: admin_of_3, target_kind: "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 }],
target_bot_types: [{ bot_type: "workspace_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);
expect(botCanTarget({ actor_kind: "coordinator", target_bot_types: [], workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_kind: "coordinator", target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_kind: "coordinator", target_kind: "coordinator" })).toBe(false);
});
test("a worker directs no one", () => {
@@ -79,19 +86,14 @@ describe("botTypeAssignment", () => {
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);
expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "workspace_admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "workspace_admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "workspace_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);
expect(botTypeAssignment({ actor_bot: { kind: "coordinator" }, target_bot_type: "workspace_admin", action: "assign" })).toBe(true);
expect(botTypeAssignment({ actor_bot: { kind: "worker", bot_types: admin_of_3 }, target_bot_type: "workspace_admin", action: "assign" })).toBe(false);
});
});