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:
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user