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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
Reference in New Issue
Block a user