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:
@@ -83,7 +83,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
|
|||||||
|
|
||||||
### Bot types
|
### 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
|
### Bots, sessions, chat
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -238,7 +238,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "kind",
|
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",
|
dataType: "TEXT",
|
||||||
options: BotKinds.map((bk) => bk.value),
|
options: BotKinds.map((bk) => bk.value),
|
||||||
defaultValue: "worker",
|
defaultValue: "worker",
|
||||||
@@ -1621,7 +1621,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: "bot_types",
|
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: [
|
fields: [
|
||||||
{
|
{
|
||||||
fieldName: "bot_id",
|
fieldName: "bot_id",
|
||||||
@@ -1641,7 +1641,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "workspace_id",
|
fieldName: "workspace_id",
|
||||||
fieldDescription: `The workspace the type holds in; null for install-wide`,
|
fieldDescription: `The workspace the type holds in`,
|
||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
cascadeDelete: true,
|
cascadeDelete: true,
|
||||||
|
|||||||
+3
-3
@@ -198,7 +198,7 @@ export type BUN_SQLITE_COBBLE_BOTS = {
|
|||||||
user_id?: number | "";
|
user_id?: number | "";
|
||||||
name?: string;
|
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" | "";
|
kind?: "worker" | "coordinator" | "";
|
||||||
/**
|
/**
|
||||||
@@ -1036,9 +1036,9 @@ export type BUN_SQLITE_COBBLE_BOT_TYPES = {
|
|||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
bot_id?: 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 | "";
|
workspace_id?: number | "";
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ async function push({
|
|||||||
const admin_workspace_ids = (
|
const admin_workspace_ids = (
|
||||||
(
|
(
|
||||||
await BunSQLite.sql<{ workspace_id: number }>({
|
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],
|
values: [bot_id],
|
||||||
})
|
})
|
||||||
).payload || []
|
).payload || []
|
||||||
|
|||||||
+15
-36
@@ -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
|
* Types a bot holds on top of its kind (`bots.kind`: coordinator or
|
||||||
* install-wide (`scope: "install"`, its row has no workspace) or in one
|
* worker), like `user_types` for users. A bot with none is just its kind.
|
||||||
* workspace (`scope: "workspace"`, its row names it). `bots.kind` stays the
|
* Each `bot_types` row names the workspace the type holds in.
|
||||||
* legacy marker of the one coordinator for who sees it; what a bot can do
|
|
||||||
* comes from these.
|
|
||||||
*/
|
*/
|
||||||
export const BotTypes = [
|
export const BotTypes = [
|
||||||
{
|
{
|
||||||
value: "coordinator",
|
value: "workspace_admin",
|
||||||
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",
|
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.",
|
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;
|
] as const;
|
||||||
|
|
||||||
@@ -75,12 +60,11 @@ export const BotPermissions = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What each bot type may do, where it holds (everywhere for an
|
* What each role (a kind, or a type held where the bot works) may do.
|
||||||
* install-wide type, in its workspace for a workspace one).
|
|
||||||
*/
|
*/
|
||||||
export const BotTypePermissionMatrix: Record<BotType, BotPermission[]> = {
|
export const BotTypePermissionMatrix: Record<BotRole, BotPermission[]> = {
|
||||||
coordinator: BotPermissions.map((bp) => bp.value),
|
coordinator: BotPermissions.map((bp) => bp.value),
|
||||||
admin: [
|
workspace_admin: [
|
||||||
"delegate_task",
|
"delegate_task",
|
||||||
"set_member_instructions",
|
"set_member_instructions",
|
||||||
"manage_workspace_tasks",
|
"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
|
* 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
|
* coordinator passes through. A workspace admin directs workers only, so
|
||||||
* two admins never hand work back and forth.
|
* two admins never hand work back and forth.
|
||||||
*/
|
*/
|
||||||
export const BotTypeTargetMatrix: TypeAssignmentMatrixType<BotType> = {
|
export const BotTypeTargetMatrix: TypeAssignmentMatrixType<BotRole> = {
|
||||||
coordinator: {},
|
coordinator: {},
|
||||||
admin: {
|
workspace_admin: {
|
||||||
allowed_actors: [],
|
allowed_actors: [],
|
||||||
},
|
},
|
||||||
worker: {
|
worker: {
|
||||||
allowed_actors: ["admin"],
|
allowed_actors: ["workspace_admin"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who may grant a bot type: user types (`admin`; the super admin passes
|
* Who may grant a bot type: user types (`admin`; the super admin passes
|
||||||
* through) and bot types (`coordinator`). The coordinator type is never
|
* through) and bot kinds (`coordinator`).
|
||||||
* granted: `ensureCoordinatorBot` writes it.
|
|
||||||
*/
|
*/
|
||||||
export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType<BotType> = {
|
export const BotTypeAssignmentMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
coordinator: {},
|
workspace_admin: {
|
||||||
admin: {
|
|
||||||
allowed_actors: ["admin", "coordinator"],
|
allowed_actors: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
worker: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
|
export const BotTypeDeletionMatrix: TypeAssignmentMatrixType<BotType> = {
|
||||||
coordinator: {},
|
workspace_admin: {
|
||||||
admin: {
|
|
||||||
allowed_actors: ["admin", "coordinator"],
|
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 grabUserBot from "../bots/grab-user-bot";
|
||||||
import grabUserTypes from "../users/grab-user-types";
|
import grabUserTypes from "../users/grab-user-types";
|
||||||
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
import grabUserWorkspace from "../workspaces/grab-user-workspace";
|
||||||
import grabBotTypes from "./grab-bot-types";
|
import grabBotRunPowers from "./grab-bot-run-powers";
|
||||||
|
|
||||||
export type BotTypeChangeParams = {
|
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
|
* 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
|
* acts) may do it (`botTypeAssignment`), the type exists, and the bot
|
||||||
* changed (the coordinator's is written by `ensureCoordinatorBot`), and the
|
* works in the workspace. Returns the bot and the workspace id.
|
||||||
* bot works in the workspace. Returns the bot and the workspace id.
|
|
||||||
*/
|
*/
|
||||||
export default async function assertBotTypeChange({
|
export default async function assertBotTypeChange({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -39,8 +38,8 @@ export default async function assertBotTypeChange({
|
|||||||
}: BotTypeChangeParams & { action: "assign" | "delete" }) {
|
}: BotTypeChangeParams & { action: "assign" | "delete" }) {
|
||||||
const type = BotTypes.find((bt) => bt.value == bot_type);
|
const type = BotTypes.find((bt) => bt.value == bot_type);
|
||||||
|
|
||||||
if (!type || type.scope != "workspace") {
|
if (!type) {
|
||||||
throw new AppError(`Only a workspace type can be given or taken away.`);
|
throw new AppError(`There's no such bot type.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspace_id) {
|
if (!workspace_id) {
|
||||||
@@ -54,9 +53,10 @@ export default async function assertBotTypeChange({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (actor_bot_id) {
|
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.`);
|
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 type { BotPermission } from "@/src/types";
|
||||||
import botCan from "@/src/utils/rbac/bot-can";
|
import botCan from "@/src/utils/rbac/bot-can";
|
||||||
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
import type { BotTypeRow } from "@/src/utils/rbac/bot-types-in";
|
||||||
@@ -14,7 +16,7 @@ type Params = {
|
|||||||
export type BotRunPowers = {
|
export type BotRunPowers = {
|
||||||
bot_types: BotTypeRow[];
|
bot_types: BotTypeRow[];
|
||||||
/**
|
/**
|
||||||
* Holds the install-wide coordinator type.
|
* Its kind is `coordinator`.
|
||||||
*/
|
*/
|
||||||
is_coordinator: boolean;
|
is_coordinator: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -26,27 +28,35 @@ export type BotRunPowers = {
|
|||||||
*/
|
*/
|
||||||
is_workspace_admin: boolean;
|
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.
|
* side only: the run's user must still be allowed.
|
||||||
*/
|
*/
|
||||||
can: (permission: BotPermission) => boolean;
|
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> {
|
export default async function grabBotRunPowers({ bot_id, workspace_id }: Params): Promise<BotRunPowers> {
|
||||||
const bot_types = await grabBotTypes({ bot_id });
|
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
|
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));
|
.map((bt) => Number(bt.workspace_id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bot_types,
|
bot_types,
|
||||||
is_coordinator: bot_types.some((bt) => bt.bot_type == "coordinator" && !bt.workspace_id),
|
is_coordinator: kind == "coordinator",
|
||||||
admin_workspace_ids,
|
admin_workspace_ids,
|
||||||
is_workspace_admin: Boolean(workspace_id && admin_workspace_ids.includes(Number(workspace_id))),
|
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
|
* A bot's types (`bot_types`), each held in one workspace. Its kind
|
||||||
* the legacy `kind = 'coordinator'` bot, so the coordinator keeps its
|
* (`bots.kind`) is read on its own.
|
||||||
* powers before `ensureCoordinatorBot` has written its row.
|
|
||||||
*/
|
*/
|
||||||
export default async function grabBotTypes({ bot_id }: Params): Promise<BotTypeRow[]> {
|
export default async function grabBotTypes({ bot_id }: Params): Promise<BotTypeRow[]> {
|
||||||
const res = await BunSQLite.sql<BotTypeRow>({
|
const res = await BunSQLite.sql<BotTypeRow>({
|
||||||
sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ?
|
sql: `SELECT bot_type, workspace_id FROM bot_types WHERE bot_id = ? AND workspace_id IS NOT NULL`,
|
||||||
UNION SELECT 'coordinator' AS bot_type, NULL AS workspace_id FROM bots WHERE id = ? AND kind = 'coordinator'`,
|
values: [Number(bot_id)],
|
||||||
values: [Number(bot_id), Number(bot_id)],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.payload || [];
|
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
|
* `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[]>> {
|
export default async function grabBotsTypes({ bot_ids }: Params): Promise<Map<number, BotTypeRow[]>> {
|
||||||
const ids = [...new Set(bot_ids.map(Number).filter(Boolean))];
|
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 marks = ids.map(() => "?").join(", ");
|
||||||
const res = await BunSQLite.sql<Row>({
|
const res = await BunSQLite.sql<Row>({
|
||||||
sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks})
|
sql: `SELECT bot_id, bot_type, workspace_id FROM bot_types WHERE bot_id IN (${marks}) AND workspace_id IS NOT NULL`,
|
||||||
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,
|
||||||
values: [...ids, ...ids],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const row of res.payload || []) {
|
for (const row of res.payload || []) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import botCanTarget from "@/src/utils/rbac/bot-can-target";
|
import botCanTarget from "@/src/utils/rbac/bot-can-target";
|
||||||
import grabCrewWorkers from "../bots/grab-crew-workers";
|
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";
|
import grabBotsTypes from "./grab-bots-types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -22,12 +22,14 @@ export default async function grabDirectableBots({ bot_id, workspace_id }: Param
|
|||||||
|
|
||||||
if (!members.length) return [];
|
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) });
|
const types = await grabBotsTypes({ bot_ids: members.map((bot) => bot.id) });
|
||||||
|
|
||||||
return members.filter((bot) =>
|
return members.filter((bot) =>
|
||||||
botCanTarget({
|
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)) || [],
|
target_bot_types: types.get(Number(bot.id)) || [],
|
||||||
workspace_id,
|
workspace_id,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Params = {
|
|||||||
export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
export default async function grabWorkspaceAdmins({ workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_BOTS[]> {
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
sql: `SELECT b.* FROM bots b
|
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
|
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`,
|
GROUP BY b.id ORDER BY b.id ASC`,
|
||||||
values: [Number(workspace_id)],
|
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 { CoordinatorBotName } from "@/src/dict/bot-dict";
|
||||||
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
||||||
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||||
import ensureCoordinatorType from "../bot-types/ensure-coordinator-type";
|
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -17,8 +16,7 @@ type Params = {
|
|||||||
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
|
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
|
||||||
* if there is none. A
|
* if there is none. A
|
||||||
* coordinator without a prompt gets the default; an edited prompt is never
|
* coordinator without a prompt gets the default; an edited prompt is never
|
||||||
* overwritten. Its install-wide `coordinator` bot type is written too
|
* overwritten. Its `kind` is what makes it the coordinator. Safe to call
|
||||||
* (`ensureCoordinatorType`), which backfills older installs. Safe to call
|
|
||||||
* on every visit.
|
* on every visit.
|
||||||
*/
|
*/
|
||||||
export default async function ensureCoordinatorBot({ user_id }: Params) {
|
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;
|
return existing.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +69,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await attachDefaultExtensions({ bot_id });
|
await attachDefaultExtensions({ bot_id });
|
||||||
await ensureCoordinatorType({ bot_id });
|
|
||||||
|
|
||||||
return 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 += ` t.last_activity_at AS last_activity_at,\n`;
|
||||||
sql += ` lm.text AS last_message_text,\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 += ` 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 += `FROM bots b\n`;
|
||||||
sql += `LEFT JOIN (\n`;
|
sql += `LEFT JOIN (\n`;
|
||||||
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\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 }>({
|
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)],
|
values: [Number(bot.id)],
|
||||||
})
|
})
|
||||||
).payload || []
|
).payload || []
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function findBotTool(): HarnessTool<Args> {
|
|||||||
return matches
|
return matches
|
||||||
.map((bot) => {
|
.map((bot) => {
|
||||||
const led = (types.get(Number(bot.id)) || [])
|
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) }));
|
.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}`;
|
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}`;
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ export default function setBotTypeTool({ user_id, bot_id, workers, workspaces, w
|
|||||||
user_id,
|
user_id,
|
||||||
actor_bot_id: bot_id,
|
actor_bot_id: bot_id,
|
||||||
bot_id: args.bot_id,
|
bot_id: args.bot_id,
|
||||||
bot_type: "admin" as const,
|
bot_type: "workspace_admin" as const,
|
||||||
workspace_id: target_workspace_id,
|
workspace_id: target_workspace_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { bot, workspace } = args.admin ? await setBotType(change) : await removeBotType(change);
|
const { bot, workspace } = args.admin ? await setBotType(change) : await removeBotType(change);
|
||||||
|
|
||||||
const saved = (await grabBotTypes({ bot_id: args.bot_id })).some(
|
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) {
|
if (saved != args.admin) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function BotAdminMenuItem({
|
|||||||
|
|
||||||
const res = await postAdminApi({
|
const res = await postAdminApi({
|
||||||
path: "bots/set-bot-type",
|
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);
|
}).catch(() => null);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
|||||||
|
|
||||||
const res = await postAdminApi<WorkspaceBot>({
|
const res = await postAdminApi<WorkspaceBot>({
|
||||||
path: "bots/set-bot-type",
|
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);
|
setAdminBusyId(undefined);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
export const SetBotTypeRequestSchema = z.object({
|
export const SetBotTypeRequestSchema = z.object({
|
||||||
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
||||||
workspace_id: z.coerce.number().int().positive("Pick a workspace"),
|
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(),
|
given: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -9,6 +9,7 @@ import botsQueue from "./bots-queue";
|
|||||||
import fileIngest from "./file-ingest";
|
import fileIngest from "./file-ingest";
|
||||||
import liveRelay from "./live-relay";
|
import liveRelay from "./live-relay";
|
||||||
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
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 handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
|
||||||
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
|
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
|
||||||
import handleInstallIconFile from "./functions/server/pwa/handle-install-icon-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;
|
const port = process.env.PORT || 7227;
|
||||||
|
|
||||||
await enableForeignKeys();
|
await enableForeignKeys();
|
||||||
|
await migrateBotTypes().catch((error) =>
|
||||||
|
logError({ title: "Could not migrate bot types", error }).catch(() => {}),
|
||||||
|
);
|
||||||
await bunext.bunextInit();
|
await bunext.bunextInit();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
-1
@@ -34,7 +34,7 @@ import type { HarnessKind } from "../dict/harness-dict";
|
|||||||
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
||||||
import type { EnvParadigm, KnownEnv, SecretRequestStatus } from "../dict/env-dict";
|
import type { EnvParadigm, KnownEnv, SecretRequestStatus } from "../dict/env-dict";
|
||||||
import type { ContainerStatus } from "../dict/computer-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 { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
|
||||||
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
|
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
|
||||||
import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict";
|
import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict";
|
||||||
@@ -2051,6 +2051,11 @@ export type TypeAssignmentMatrixType<
|
|||||||
|
|
||||||
export type BotType = (typeof BotTypes)[number]["value"];
|
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 BotPermission = (typeof BotPermissions)[number]["value"];
|
||||||
|
|
||||||
export type LoginFormObject = {
|
export type LoginFormObject = {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import type { BotKind } from "@/src/dict/bot-dict";
|
||||||
import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict";
|
import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict";
|
||||||
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
||||||
import typeAssignment from "./type-assignment";
|
import typeAssignment from "./type-assignment";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
|
actor_kind?: BotKind | "" | null;
|
||||||
actor_bot_types?: BotTypeRow[] | null;
|
actor_bot_types?: BotTypeRow[] | null;
|
||||||
|
target_kind?: BotKind | "" | null;
|
||||||
target_bot_types?: BotTypeRow[] | null;
|
target_bot_types?: BotTypeRow[] | null;
|
||||||
/**
|
/**
|
||||||
* Where they're working: types count there.
|
* Where they're working: types count there.
|
||||||
@@ -16,9 +19,15 @@ type Params = {
|
|||||||
* where they're working, through `BotTypeTargetMatrix`: the coordinator
|
* where they're working, through `BotTypeTargetMatrix`: the coordinator
|
||||||
* acts on anyone, a workspace admin on the workers there.
|
* acts on anyone, a workspace admin on the workers there.
|
||||||
*/
|
*/
|
||||||
export default function botCanTarget({ actor_bot_types, target_bot_types, workspace_id }: Params): boolean {
|
export default function botCanTarget({
|
||||||
const actor_types = botTypesIn({ bot_types: actor_bot_types, workspace_id });
|
actor_kind,
|
||||||
const target_types = botTypesIn({ bot_types: target_bot_types, workspace_id });
|
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({
|
return typeAssignment({
|
||||||
actor_types,
|
actor_types,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict";
|
import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict";
|
||||||
|
import type { BotKind } from "@/src/dict/bot-dict";
|
||||||
import type { BotPermission } from "@/src/types";
|
import type { BotPermission } from "@/src/types";
|
||||||
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
|
kind?: BotKind | "" | null;
|
||||||
bot_types?: BotTypeRow[] | null;
|
bot_types?: BotTypeRow[] | null;
|
||||||
permission: BotPermission;
|
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
|
* (`BotTypePermissionMatrix`). This is the bot's side only: the user the
|
||||||
* run works for must still be allowed to do it.
|
* run works for must still be allowed to do it.
|
||||||
*/
|
*/
|
||||||
export default function botCan({ bot_types, permission, workspace_id }: Params): boolean {
|
export default function botCan({ kind, bot_types, permission, workspace_id }: Params): boolean {
|
||||||
return botTypesIn({ bot_types, workspace_id }).some((bt) =>
|
return botTypesIn({ kind, bot_types, workspace_id }).some((bt) =>
|
||||||
BotTypePermissionMatrix[bt].includes(permission),
|
BotTypePermissionMatrix[bt].includes(permission),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
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 { BotTypeAssignmentMatrix, BotTypeDeletionMatrix } from "@/src/dict/bot-types-dict";
|
||||||
import type { BotType } from "@/src/types";
|
import type { BotType } from "@/src/types";
|
||||||
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
|
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;
|
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,
|
* A bot granting it from chat: its kind and types. Only its kind counts
|
||||||
* so a workspace admin never grants types.
|
* (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;
|
target_bot_type: BotType;
|
||||||
action: "assign" | "delete";
|
action: "assign" | "delete";
|
||||||
};
|
};
|
||||||
@@ -27,17 +31,17 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
export default function botTypeAssignment({
|
export default function botTypeAssignment({
|
||||||
actor_user_types,
|
actor_user_types,
|
||||||
actor_bot_types,
|
actor_bot,
|
||||||
target_bot_type,
|
target_bot_type,
|
||||||
action,
|
action,
|
||||||
}: Params): boolean {
|
}: Params): boolean {
|
||||||
const actor_types = actor_bot_types
|
const actor_types = actor_bot
|
||||||
? botTypesIn({ bot_types: actor_bot_types })
|
? 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));
|
: (actor_user_types || []).map((ut) => ut.user_type).filter((ut): ut is string => Boolean(ut));
|
||||||
|
|
||||||
return typeAssignment({
|
return typeAssignment({
|
||||||
actor_types,
|
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,
|
target_type: target_bot_type,
|
||||||
action,
|
action,
|
||||||
assignment_matrix: BotTypeAssignmentMatrix,
|
assignment_matrix: BotTypeAssignmentMatrix,
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_BOT_TYPES } from "@/db/types/db";
|
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"> & {
|
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;
|
workspace_id?: number | "" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The bot's kind (`bots.kind`): a worker when missing.
|
||||||
|
*/
|
||||||
|
kind?: BotKind | "" | null;
|
||||||
bot_types?: BotTypeRow[] | null;
|
bot_types?: BotTypeRow[] | null;
|
||||||
/**
|
/**
|
||||||
* Where the bot is working: its own chat (null) or a workspace.
|
* 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
|
* Strongest first, so a bot holding several is judged by the one that
|
||||||
* reaches furthest.
|
* 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
|
* The roles a bot holds where it's working: the coordinator kind
|
||||||
* everywhere, workspace rows only in their workspace. Strongest first,
|
* everywhere, a type only in its workspace. Strongest first, `worker` when
|
||||||
* `worker` when nothing else holds.
|
* nothing else holds.
|
||||||
*/
|
*/
|
||||||
export default function botTypesIn({ bot_types, workspace_id }: Params): BotType[] {
|
export default function botTypesIn({ kind, bot_types, workspace_id }: Params): BotRole[] {
|
||||||
const held = new Set<BotType>();
|
const held = new Set<BotRole>();
|
||||||
|
|
||||||
|
if (kind == "coordinator") held.add("coordinator");
|
||||||
|
|
||||||
for (const row of bot_types || []) {
|
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 (row.workspace_id == Number(workspace_id)) held.add(row.bot_type);
|
||||||
|
|
||||||
if (applies) held.add(row.bot_type);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!held.size) held.add("worker");
|
if (!held.size) held.add("worker");
|
||||||
|
|
||||||
return BotTypeRank.filter((bt) => held.has(bt));
|
return BotRoleRank.filter((role) => held.has(role));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,29 @@ import botCanTarget from "./bot-can-target";
|
|||||||
import botTypeAssignment from "./bot-type-assignment";
|
import botTypeAssignment from "./bot-type-assignment";
|
||||||
import botTypesIn from "./bot-types-in";
|
import botTypesIn from "./bot-types-in";
|
||||||
|
|
||||||
const coordinator = [{ bot_type: "coordinator" as const, workspace_id: null }];
|
const admin_of_3 = [{ bot_type: "workspace_admin" as const, workspace_id: 3 }];
|
||||||
const admin_of_3 = [{ bot_type: "admin" as const, workspace_id: 3 }];
|
|
||||||
|
|
||||||
describe("botTypesIn", () => {
|
describe("botTypesIn", () => {
|
||||||
test("a bot with no rows is a worker", () => {
|
test("a bot with no rows is a worker", () => {
|
||||||
expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]);
|
expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]);
|
||||||
|
expect(botTypesIn({ kind: "worker", bot_types: [] })).toEqual(["worker"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a workspace type only holds in its workspace", () => {
|
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, workspace_id: 4 })).toEqual(["worker"]);
|
||||||
expect(botTypesIn({ bot_types: admin_of_3 })).toEqual(["worker"]);
|
expect(botTypesIn({ bot_types: admin_of_3 })).toEqual(["worker"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an install-wide type holds everywhere", () => {
|
test("a type row without a workspace holds nowhere", () => {
|
||||||
expect(botTypesIn({ bot_types: coordinator, workspace_id: 9 })).toEqual(["coordinator"]);
|
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", () => {
|
test("the coordinator can do everything", () => {
|
||||||
expect(botCan({ bot_types: coordinator, permission: "manage_extensions" })).toBe(true);
|
expect(botCan({ kind: "coordinator", permission: "manage_extensions" })).toBe(true);
|
||||||
expect(botCan({ bot_types: coordinator, permission: "delegate_task", workspace_id: 3 })).toBe(true);
|
expect(botCan({ kind: "coordinator", permission: "delegate_task", workspace_id: 3 })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a worker can't delegate", () => {
|
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", () => {
|
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: 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", () => {
|
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: [], workspace_id: 4 })).toBe(false);
|
||||||
expect(botCanTarget({
|
expect(botCanTarget({
|
||||||
actor_bot_types: admin_of_3,
|
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,
|
workspace_id: 3,
|
||||||
})).toBe(true);
|
})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the coordinator directs workers and admins, never itself", () => {
|
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_kind: "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_kind: "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_kind: "coordinator" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a worker directs no one", () => {
|
test("a worker directs no one", () => {
|
||||||
@@ -79,19 +86,14 @@ describe("botTypeAssignment", () => {
|
|||||||
|
|
||||||
test("super admins and admins grant and remove workspace admin", () => {
|
test("super admins and admins grant and remove workspace admin", () => {
|
||||||
for (const action of ["assign", "delete"] as const) {
|
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("super_admin"), target_bot_type: "workspace_admin", action })).toBe(true);
|
||||||
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "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: "admin", action })).toBe(false);
|
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", () => {
|
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: { kind: "coordinator" }, target_bot_type: "workspace_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: { kind: "worker", bot_types: admin_of_3 }, target_bot_type: "workspace_admin", action: "assign" })).toBe(false);
|
||||||
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "coordinator", action: "assign" })).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user