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:
2026-10-25 09:04:14 +01:00
co-authored by Claude Opus 5.5
parent 4161863587
commit ea62dd11f0
65 changed files with 1970 additions and 239 deletions
@@ -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;
}
+6 -4
View File
@@ -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`,
+6 -1
View File
@@ -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],
});
}