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
@@ -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 || []