Give every task a bot: drop shared workspace tasks

A workspace task with no bot was seen by every bot working there. Now a
task can't be saved without a bot, a worker reaches only its own (the
workspace admin and the coordinator still reach all), the workspace page
picks who has a new task, and a bot leaving a workspace or deleted passes
its tasks there to the workspace's admin bot, else the coordinator (also
at boot for rows without a bot).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 06:26:44 +01:00
co-authored by Claude Opus 5.5
parent 3e78d51b56
commit 623112fd19
30 changed files with 239 additions and 106 deletions
+8 -8
View File
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import passOnBotTasks from "@/src/functions/backend/db/tasks/pass-on-bot-tasks";
import AppError from "@/src/utils/app-error";
import { clearEnvCache } from "../envs/grab-all-envs";
import grabUserBot from "./grab-user-bot";
@@ -40,19 +41,18 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
);
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
/**
* Its tasks in workspaces belong to the workspace: they go to each one's
* admin bot (else the coordinator) instead of cascading away with its
* own list.
*/
await passOnBotTasks({ bot_id: bot.id, bot_name: bot.name });
/**
* Explicitly, since foreign keys are off outside the server process.
*/
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
/**
* Its tasks in workspaces belong to the workspace: they stay, shared
* again, instead of cascading away with its own list.
*/
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE bot_id = ? AND workspace_id IS NOT NULL`,
values: [Date.now(), bot.id],
});
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
@@ -0,0 +1,10 @@
import passOnBotTasks from "./pass-on-bot-tasks";
/**
* At boot: every task belongs to a bot. Workspace tasks left without one
* by earlier versions ("shared") go to the workspace's admin bot, else the
* coordinator.
*/
export default async function assignOwnerlessTasks() {
return await passOnBotTasks({});
}
@@ -27,7 +27,7 @@ type Params = {
/**
* Adds a task to a bot's own list or a workspace's, there for one of its
* bots (`bot_id` too) or shared. A user adds one to a bot they chat with or
* bots (`bot_id` too): every task belongs to a bot. A user adds one to a bot they chat with or
* a workspace they're in; a bot's run adds one to its own list or to a
* workspace it works in (its tool checks that). A workspace's task can
* only go to a bot working there. Caps a list at `TaskMaxPerScope` open and
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabWorkspaceAdmins from "../bot-types/grab-workspace-admins";
type Params = {
workspace_id: number | string;
/**
* The bot leaving, never its own heir.
*/
except_bot_id?: number | string | null;
};
/**
* Who takes over a workspace task whose bot is gone: the workspace's admin
* bot, else the coordinator. Every task belongs to a bot, so none is left
* without one.
*/
export default async function grabTaskHeir({ workspace_id, except_bot_id }: Params) {
const admins = await grabWorkspaceAdmins({ workspace_id });
const admin = admins.find((bot) => Number(bot.id) != Number(except_bot_id || 0));
if (admin?.id) return admin;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE kind = 'coordinator' ORDER BY id ASC LIMIT 1`,
});
return res.payload?.[0] || null;
}
+1 -6
View File
@@ -27,10 +27,6 @@ type Params = {
* Only the tasks this bot has (anywhere, unless narrowed).
*/
only_bot_id?: number | string | null;
/**
* With `only_bot_id`: also the shared ones nobody has yet.
*/
with_unassigned?: boolean;
/**
* Only one workspace's tasks.
*/
@@ -61,7 +57,6 @@ export default async function grabTasks({
is_coordinator,
admin_workspace_ids,
only_bot_id,
with_unassigned,
only_workspace_id,
own_list,
statuses,
@@ -82,7 +77,7 @@ export default async function grabTasks({
}
if (only_bot_id) {
where.push(with_unassigned ? `(t.bot_id = ? OR t.bot_id IS NULL)` : `t.bot_id = ?`);
where.push(`t.bot_id = ?`);
values.push(Number(only_bot_id));
}
+1 -1
View File
@@ -10,7 +10,7 @@ type Params = {
task_id: number | string;
/**
* Where it goes: a bot's own list (`bot_id` alone), or a workspace,
* with the bot that has it there (none: unassigned).
* with the bot that has it there (always one).
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
@@ -0,0 +1,65 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import addTaskNote from "./add-task-note";
import grabTaskHeir from "./grab-task-heir";
type Params = {
/**
* The bot leaving; none for tasks that have no bot at all.
*/
bot_id?: number | string | null;
/**
* Only its tasks in this workspace; none for every workspace.
*/
workspace_id?: number | string | null;
/**
* Who is leaving, for the note on each task.
*/
bot_name?: string | null;
};
/**
* Gives a leaving bot's workspace tasks (or ones with no bot) to the
* workspace's admin bot, else the coordinator, with a note on each. Its own
* list is left alone. Returns how many moved.
*/
export default async function passOnBotTasks({ bot_id, workspace_id, bot_name }: Params) {
const where = [`workspace_id IS NOT NULL`, bot_id ? `bot_id = ?` : `bot_id IS NULL`];
const values: number[] = bot_id ? [Number(bot_id)] : [];
if (workspace_id) {
where.push(`workspace_id = ?`);
values.push(Number(workspace_id));
}
const tasks =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT * FROM tasks WHERE ${where.join(" AND ")}`,
values,
})
).payload || [];
let moved = 0;
for (const task of tasks) {
const heir = await grabTaskHeir({ workspace_id: task.workspace_id!, except_bot_id: bot_id });
if (!heir?.id) continue;
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = ?, updated_at = ? WHERE id = ?`,
values: [Number(heir.id), Date.now(), Number(task.id)],
});
await addTaskNote({
task_id: task.id!,
text: bot_name ? `${bot_name} left; this is now ${heir.name}'s.` : `This is now ${heir.name}'s.`,
author: "user",
}).catch(() => {});
moved++;
}
return moved;
}
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import taskScopeValues from "./task-scope-values";
describe("taskScopeValues", () => {
test("a task on a bot's own list or in a workspace has its bot", () => {
expect(taskScopeValues({ bot_id: 3 })).toEqual({ bot_id: 3, workspace_id: null });
expect(taskScopeValues({ bot_id: "3", workspace_id: "11" })).toEqual({ bot_id: 3, workspace_id: 11 });
});
test("a workspace task without a bot is refused", () => {
expect(() => taskScopeValues({ workspace_id: 11 })).toThrow();
expect(() => taskScopeValues({})).toThrow();
});
});
@@ -6,17 +6,17 @@ type Params = {
};
/**
* Where a task lives and who has it. A bot's own list is `bot_id` alone; a
* workspace's task has `workspace_id`, plus `bot_id` when a bot there has
* it (none: shared, not yet anyone's). Checked in the helpers rather than
* the schema.
* Where a task lives and who has it. Every task belongs to a bot: a bot's
* own list is `bot_id` alone, a workspace's task has `workspace_id` plus
* the `bot_id` that has it there. Checked in the helpers rather than the
* schema.
*/
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
const bot = bot_id ? Number(bot_id) : null;
const workspace = workspace_id ? Number(workspace_id) : null;
if (!bot && !workspace) {
throw new AppError(`Say which bot or workspace the task is for.`);
if (!bot) {
throw new AppError(`Say which bot has the task.`);
}
return { bot_id: bot, workspace_id: workspace };
@@ -1,6 +1,8 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
import grabBotById from "../bots/grab-bot-by-id";
import passOnBotTasks from "../tasks/pass-on-bot-tasks";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
@@ -31,12 +33,11 @@ export default async function removeBotFromWorkspace({
});
/**
* The tasks it had there stay in the workspace, shared again.
* The tasks it had there stay in the workspace, passed to its admin bot
* (else the coordinator), read before its admin type goes below.
*/
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
values: [Date.now(), workspace.id, bot_id],
});
const bot = await grabBotById({ bot_id });
await passOnBotTasks({ bot_id, workspace_id: workspace.id, bot_name: bot?.name });
/**
* Its types there (workspace admin) go with its place.