Scope workspace tasks to the bot that has them

A workspace's task now carries the bot that has it there (bot_id with
workspace_id), or none while it's shared. A bot reaches its own list and,
in its workspaces, its own tasks plus the shared ones; a workspace admin
every task there. Bots create workspace tasks for themselves by default,
take a shared one by starting it, and hand-overs record the new bot.
Removing or deleting a bot makes its workspace tasks shared again. The
conversation's Tasks modal shows that bot's tasks in the workspace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 19:19:01 +01:00
co-authored by Claude Opus 5.5
parent 184407d76b
commit 864dcce30a
31 changed files with 316 additions and 153 deletions
@@ -44,6 +44,15 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
* 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,27 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
type Params = {
bot_id: number | string;
workspace_id: number | string;
};
/**
* A workspace's task can only be had by a bot working there: a member
* (`workspace_bots`) or the coordinator, which is in every workspace.
*/
export default async function assertBotWorksThere({ bot_id, workspace_id }: Params) {
const res = await BunSQLite.sql<{ id: number }>({
sql: `SELECT b.id FROM bots b
WHERE b.id = ? AND (
b.kind = 'coordinator'
OR EXISTS (SELECT 1 FROM workspace_bots wb WHERE wb.bot_id = b.id AND wb.workspace_id = ?)
)
LIMIT 1`,
values: [Number(bot_id), Number(workspace_id)],
});
if (!res.payload?.[0]?.id) {
throw new AppError(`Bot ${bot_id} doesn't work in workspace ${workspace_id}, so it can't have a task there.`);
}
}
+13 -6
View File
@@ -6,6 +6,7 @@ import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import grabTask from "./grab-task";
import assertBotWorksThere from "./assert-bot-works-there";
import taskScopeValues from "./task-scope-values";
type Params = {
@@ -25,10 +26,12 @@ type Params = {
};
/**
* Adds a task to a bot's own list or a workspace's shared one. 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). Caps a collection at `TaskMaxPerScope` open and finished tasks.
* 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
* 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
* finished tasks.
*/
export default async function createTask({
user_id,
@@ -46,13 +49,17 @@ export default async function createTask({
throw new AppError(`Only an admin can add a task.`);
}
if (scope.bot_id && scope.workspace_id) {
await assertBotWorksThere({ bot_id: scope.bot_id, workspace_id: scope.workspace_id });
}
const count = Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM tasks WHERE ${
scope.bot_id ? `bot_id = ?` : `workspace_id = ?`
scope.workspace_id ? `workspace_id = ?` : `bot_id = ? AND workspace_id IS NULL`
}`,
values: [Number(scope.bot_id || scope.workspace_id)],
values: [Number(scope.workspace_id || scope.bot_id)],
})
).payload?.[0]?.n || 0,
);
@@ -29,4 +29,11 @@ describe("findDuplicateTask", () => {
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })?.id).toBe(5);
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 7 })).toBeNull();
});
test("a bot's task in a workspace isn't on its own list, and counts for the whole workspace", () => {
const tasks = [task({ id: 7, bot_id: 2, workspace_id: 9 })];
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })).toBeNull();
expect(findDuplicateTask({ tasks, title: "Check the invoices", workspace_id: 9, bot_id: 3 })?.id).toBe(7);
});
});
@@ -4,7 +4,7 @@ type Params = {
tasks: BUN_SQLITE_COBBLE_TASKS[];
title: string;
/**
* Same collection: a bot's own list, or a workspace's.
* Same list: a bot's own, or a workspace's (whoever has it there).
*/
bot_id?: number | null;
workspace_id?: number | null;
@@ -23,7 +23,7 @@ export default function findDuplicateTask({ tasks, title, bot_id, workspace_id }
if (task.status == "done" || task.status == "cancelled") return false;
if (workspace_id) {
if (Number(task.workspace_id || 0) != Number(workspace_id)) return false;
} else if (Number(task.bot_id || 0) != Number(bot_id || 0)) {
} else if (task.workspace_id || Number(task.bot_id || 0) != Number(bot_id || 0)) {
return false;
}
+19 -2
View File
@@ -24,10 +24,21 @@ type Params = {
*/
admin_workspace_ids?: number[];
/**
* Only one collection.
* 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.
*/
only_workspace_id?: number | string | null;
/**
* Only bots' own lists, outside any workspace.
*/
own_list?: boolean;
/**
* Only these statuses; none for every one.
*/
@@ -50,7 +61,9 @@ export default async function grabTasks({
is_coordinator,
admin_workspace_ids,
only_bot_id,
with_unassigned,
only_workspace_id,
own_list,
statuses,
open_only,
limit,
@@ -69,10 +82,14 @@ export default async function grabTasks({
}
if (only_bot_id) {
where.push(`t.bot_id = ?`);
where.push(with_unassigned ? `(t.bot_id = ? OR t.bot_id IS NULL)` : `t.bot_id = ?`);
values.push(Number(only_bot_id));
}
if (own_list) {
where.push(`t.workspace_id IS NULL`);
}
if (only_workspace_id) {
where.push(`t.workspace_id = ?`);
values.push(Number(only_workspace_id));
@@ -60,9 +60,9 @@ export type HandedOverTask = {
};
/**
* Hands an open task to another bot and starts the work: a task on a
* workspace's list stays there (the work runs in that workspace), one on
* a bot's own list moves to the new bot's. The task goes to doing, its log
* Hands an open task to another bot and starts the work: a task in a
* workspace stays there and becomes the new bot's there (the work runs in
* that workspace), one on a bot's own list moves to the new bot's. The task goes to doing, its log
* says who handed it over, and a job is queued in the new bot's
* conversation with the task's brief and `params.tracker_task_id`, so the
* bot moves this same task along and the job's end is noted on it.
@@ -115,7 +115,11 @@ export default async function handOverTask({
);
}
const moved = task.workspace_id ? task : await moveTask({ task_id: task.id!, bot_id: bot.id });
/**
* A workspace's task stays in the workspace with the new bot as the one
* who has it; a bot's own moves to the new bot's own list.
*/
const moved = await moveTask({ task_id: task.id!, bot_id: bot.id, workspace_id: task.workspace_id || null });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id: run_workspace_id });
const brief = [
+14 -6
View File
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import assertBotWorksThere from "./assert-bot-works-there";
import findDuplicateTask from "./find-duplicate-task";
import grabTaskRow from "./grab-task-scope";
import taskScopeValues from "./task-scope-values";
@@ -8,16 +9,17 @@ import taskScopeValues from "./task-scope-values";
type Params = {
task_id: number | string;
/**
* The collection it moves to: a bot's own list, or a workspace's.
* Where it goes: a bot's own list (`bot_id` alone), or a workspace,
* with the bot that has it there (none: unassigned).
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
/**
* Moves a task to another collection, with its notes and files. Refused
* when the destination already has an open task with that title, so a
* hand-over never leaves two of the same. The caller checked who is
* Moves a task to another list or bot, with its notes and files. Refused
* when it changes list and the destination already has an open task with
* that title, so a hand-over never leaves two of the same. The caller checked who is
* asking reaches both. Returns the row as it is now.
*/
export default async function moveTask({ task_id, bot_id, workspace_id }: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
@@ -28,11 +30,17 @@ export default async function moveTask({ task_id, bot_id, workspace_id }: Params
return task;
}
const there = (
if (place.bot_id && place.workspace_id) {
await assertBotWorksThere({ bot_id: place.bot_id, workspace_id: place.workspace_id });
}
const same_list = Number(task.workspace_id || 0) == Number(place.workspace_id || 0) && Boolean(place.workspace_id);
const there = same_list ? [] : (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: place.workspace_id
? `SELECT * FROM tasks WHERE workspace_id = ? AND id != ?`
: `SELECT * FROM tasks WHERE bot_id = ? AND id != ?`,
: `SELECT * FROM tasks WHERE bot_id = ? AND workspace_id IS NULL AND id != ?`,
values: [Number(place.workspace_id || place.bot_id), Number(task.id)],
})
).payload || [];
@@ -3,7 +3,7 @@ import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
type Params = {
/**
* A bot's own tasks, or a workspace's.
* A bot's own list (not what it has in workspaces), or a workspace's.
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
@@ -25,7 +25,7 @@ export async function grabTaskMediaIds({ bot_id, workspace_id }: Params): Promis
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT tm.media_id FROM task_media tm
JOIN tasks t ON t.id = tm.task_id
WHERE t.${column} = ? AND tm.bot_file_id IS NULL`,
WHERE t.${column} = ? ${workspace_id ? "" : "AND t.workspace_id IS NULL"} AND tm.bot_file_id IS NULL`,
values: [Number(owner)],
})
).payload || [];
@@ -6,19 +6,15 @@ type Params = {
};
/**
* A task belongs to exactly one collection: a bot's own list, or a
* workspace's shared one. Checked in the helpers rather than the schema,
* since SQLite treats NULL in a unique constraint as always distinct (the
* `envs` precedent).
* 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.
*/
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(`A task belongs to a bot or a workspace, not both.`);
}
if (!bot && !workspace) {
throw new AppError(`Say which bot or workspace the task is for.`);
}
@@ -30,6 +30,14 @@ export default async function removeBotFromWorkspace({
values: [workspace.id, bot_id],
});
/**
* The tasks it had there stay in the workspace, shared again.
*/
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],
});
/**
* Its types there (workspace admin) go with its place.
*/