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
+1 -1
View File
@@ -151,7 +151,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
### Tasks (the to-do tracker) ### Tasks (the to-do tracker)
`tasks` (one collection per bot or per workspace; exactly one of `bot_id`/`workspace_id`, enforced in `taskScopeValues` since SQLite unique treats NULLs as distinct), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot's list (a workspace's stays on its list and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page. `tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
### Envs ### Envs
+3 -3
View File
@@ -785,11 +785,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
{ {
tableName: "tasks", tableName: "tasks",
tableDescription: `The crew's to-do list: work that is tracked until it's done, one collection per bot and one per workspace (exactly one of \`bot_id\` and \`workspace_id\` is set). A workspace's tasks are visible to every bot working there and to the coordinator; the coordinator reaches every task. Not the same thing as \`bot_tasks\` or \`bots_queue\`: those record a queued job, this records the work`, tableDescription: `The crew's to-do list: work that is tracked until it's done. A bot's own list (\`bot_id\` alone), or a workspace's tasks (\`workspace_id\`, with \`bot_id\` naming the bot there that has it, or null while it's shared). A bot reaches its own list and, in each workspace it works in, its tasks there and the shared ones; a workspace admin every task in its workspace; the coordinator every task. Not the same thing as \`bot_tasks\` or \`bots_queue\`: those record a queued job, this records the work`,
fields: [ fields: [
{ {
fieldName: "bot_id", fieldName: "bot_id",
fieldDescription: `The bot the task belongs to; null for a workspace's task`, fieldDescription: `The bot that has the task: its own list's, or with \`workspace_id\` its task in that workspace; null for a workspace's shared task nobody has yet`,
dataType: "INTEGER", dataType: "INTEGER",
foreignKey: { foreignKey: {
cascadeDelete: true, cascadeDelete: true,
@@ -801,7 +801,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
{ {
fieldName: "workspace_id", fieldName: "workspace_id",
fieldDescription: `The workspace the task belongs to (its bots all see it); null for a bot's own`, fieldDescription: `The workspace the task is in; null for a bot's own list`,
dataType: "INTEGER", dataType: "INTEGER",
foreignKey: { foreignKey: {
cascadeDelete: true, cascadeDelete: true,
+4 -5
View File
@@ -11,6 +11,7 @@ import botStoneProps from "@/src/utils/bot-stone-props";
import { TaskStatuses } from "@/src/dict/task-dict"; import { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import taskPlaceLabel from "@/src/utils/task-place-label";
import TaskStatusChip from "./task-status-chip"; import TaskStatusChip from "./task-status-chip";
type Props = { type Props = {
@@ -27,8 +28,8 @@ type Props = {
}; };
/** /**
* One task: whose it is (a bot's stone), its title and status, the * One task: whose it is (a bot's stone), its title and status, where it
* workspace, what it's blocked on, its latest note and how long ago it * is and who has it, what it's blocked on, its latest note and how long ago it
* moved. * moved.
*/ */
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) { export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
@@ -63,9 +64,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
</Row> </Row>
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate"> <Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
{task.workspace_id {taskPlaceLabel({ task })}
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span> </Span>
{task.status == "blocked" && task.blocked_reason ? ( {task.status == "blocked" && task.blocked_reason ? (
<Span className="min-w-0 text-xs leading-4 text-ink truncate"> <Span className="min-w-0 text-xs leading-4 text-ink truncate">
+1 -1
View File
@@ -197,7 +197,7 @@ export const workspaceAdminNote = ({
members: { id?: number | "" }[]; members: { id?: number | "" }[];
}) => `# Leading ${workspace_name} }) => `# Leading ${workspace_name}
You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself (a task already on a list goes over with its task_id, and one of your routines here with hand_over_schedule); they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task can put a task on a member's own list), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`; You are this workspace's admin: you lead the bots working here (${members.map((m) => botRef({ id: m.id || 0 })).join(", ")}). Hand work that fits a member's role to them with delegate_task rather than doing all of it yourself (a task already on a list goes over with its task_id, and one of your routines here with hand_over_schedule); they post the result in their conversation here, and it's reported back to you when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters, follow up on anything left and propose what comes next. Set how a member should work here with set_bot_workspace_instructions, keep the workspace's task list honest (create_task with a member's bot_id gives them a task here), and keep the workspace's shared context current with update_workspace when you have it. When you name a member, write its ref, so the user sees its name linked to its conversation.`;
/** /**
* Added to a workspace member's prompt while the workspace has an admin, * Added to a workspace member's prompt while the workspace has an admin,
@@ -44,6 +44,15 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
* Explicitly, since foreign keys are off outside the server process. * Explicitly, since foreign keys are off outside the server process.
*/ */
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] }); 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 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 secret_requests WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics 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 AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role"; import grabCrewRole from "../access/grab-crew-role";
import grabTask from "./grab-task"; import grabTask from "./grab-task";
import assertBotWorksThere from "./assert-bot-works-there";
import taskScopeValues from "./task-scope-values"; import taskScopeValues from "./task-scope-values";
type Params = { 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 * Adds a task to a bot's own list or a workspace's, there for one of its
* one to a bot they chat with or a workspace they're in; a bot's run adds * bots (`bot_id` too) or shared. A user adds one to a bot they chat with or
* one to its own list or to a workspace it works in (its tool checks * a workspace they're in; a bot's run adds one to its own list or to a
* that). Caps a collection at `TaskMaxPerScope` open and finished tasks. * 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({ export default async function createTask({
user_id, user_id,
@@ -46,13 +49,17 @@ export default async function createTask({
throw new AppError(`Only an admin can add a task.`); 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( const count = Number(
( (
await BunSQLite.sql<{ n: number }>({ await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM tasks WHERE ${ 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, ).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: 2 })?.id).toBe(5);
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 7 })).toBeNull(); 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[]; tasks: BUN_SQLITE_COBBLE_TASKS[];
title: string; 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; bot_id?: number | null;
workspace_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 (task.status == "done" || task.status == "cancelled") return false;
if (workspace_id) { if (workspace_id) {
if (Number(task.workspace_id || 0) != Number(workspace_id)) return false; 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; return false;
} }
+19 -2
View File
@@ -24,10 +24,21 @@ type Params = {
*/ */
admin_workspace_ids?: number[]; admin_workspace_ids?: number[];
/** /**
* Only one collection. * Only the tasks this bot has (anywhere, unless narrowed).
*/ */
only_bot_id?: number | string | null; 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_workspace_id?: number | string | null;
/**
* Only bots' own lists, outside any workspace.
*/
own_list?: boolean;
/** /**
* Only these statuses; none for every one. * Only these statuses; none for every one.
*/ */
@@ -50,7 +61,9 @@ export default async function grabTasks({
is_coordinator, is_coordinator,
admin_workspace_ids, admin_workspace_ids,
only_bot_id, only_bot_id,
with_unassigned,
only_workspace_id, only_workspace_id,
own_list,
statuses, statuses,
open_only, open_only,
limit, limit,
@@ -69,10 +82,14 @@ export default async function grabTasks({
} }
if (only_bot_id) { 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)); values.push(Number(only_bot_id));
} }
if (own_list) {
where.push(`t.workspace_id IS NULL`);
}
if (only_workspace_id) { if (only_workspace_id) {
where.push(`t.workspace_id = ?`); where.push(`t.workspace_id = ?`);
values.push(Number(only_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 * Hands an open task to another bot and starts the work: a task in a
* workspace's list stays there (the work runs in that workspace), one on * workspace stays there and becomes the new bot's there (the work runs in
* a bot's own list moves to the new bot's. The task goes to doing, its log * 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 * 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 * 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. * 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 computers = await grabBotComputers({ bot_id: bot.id, workspace_id: run_workspace_id });
const brief = [ const brief = [
+14 -6
View File
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import assertBotWorksThere from "./assert-bot-works-there";
import findDuplicateTask from "./find-duplicate-task"; import findDuplicateTask from "./find-duplicate-task";
import grabTaskRow from "./grab-task-scope"; import grabTaskRow from "./grab-task-scope";
import taskScopeValues from "./task-scope-values"; import taskScopeValues from "./task-scope-values";
@@ -8,16 +9,17 @@ import taskScopeValues from "./task-scope-values";
type Params = { type Params = {
task_id: number | string; 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; bot_id?: number | string | null;
workspace_id?: number | string | null; workspace_id?: number | string | null;
}; };
/** /**
* Moves a task to another collection, with its notes and files. Refused * Moves a task to another list or bot, with its notes and files. Refused
* when the destination already has an open task with that title, so a * when it changes list and the destination already has an open task with
* hand-over never leaves two of the same. The caller checked who is * 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. * 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> { 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; 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>({ await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: place.workspace_id sql: place.workspace_id
? `SELECT * FROM tasks WHERE workspace_id = ? AND 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)], values: [Number(place.workspace_id || place.bot_id), Number(task.id)],
}) })
).payload || []; ).payload || [];
@@ -3,7 +3,7 @@ import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
type Params = { 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; bot_id?: number | string | null;
workspace_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>({ await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT tm.media_id FROM task_media tm sql: `SELECT tm.media_id FROM task_media tm
JOIN tasks t ON t.id = tm.task_id 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)], values: [Number(owner)],
}) })
).payload || []; ).payload || [];
@@ -6,19 +6,15 @@ type Params = {
}; };
/** /**
* A task belongs to exactly one collection: a bot's own list, or a * Where a task lives and who has it. A bot's own list is `bot_id` alone; a
* workspace's shared one. Checked in the helpers rather than the schema, * workspace's task has `workspace_id`, plus `bot_id` when a bot there has
* since SQLite treats NULL in a unique constraint as always distinct (the * it (none: shared, not yet anyone's). Checked in the helpers rather than
* `envs` precedent). * the schema.
*/ */
export default function taskScopeValues({ bot_id, workspace_id }: Params) { export default function taskScopeValues({ bot_id, workspace_id }: Params) {
const bot = bot_id ? Number(bot_id) : null; const bot = bot_id ? Number(bot_id) : null;
const workspace = workspace_id ? Number(workspace_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) { if (!bot && !workspace) {
throw new AppError(`Say which bot or workspace the task is for.`); 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], 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. * Its types there (workspace admin) go with its place.
*/ */
+62 -36
View File
@@ -11,14 +11,14 @@ type Params = {
user_id: number | string; user_id: number | string;
bot_id: number | string; bot_id: number | string;
/** /**
* The workspace the run is in, if any: a bot adding a task there * The workspace the run is in, if any: a worker's task there stays in
* defaults to the workspace's shared list. * it, its own by default.
*/ */
workspace_id?: number | string | null; workspace_id?: number | string | null;
is_coordinator: boolean; is_coordinator: boolean;
/** /**
* The coordinator's targets: every worker and live workspace. A worker * The coordinator's targets: every worker and live workspace. A worker
* gets neither and may only add to its own list or the workspace it * gets neither and may only add to its own list or in the workspace it
* works in. * works in.
*/ */
workers?: BUN_SQLITE_COBBLE_BOTS[]; workers?: BUN_SQLITE_COBBLE_BOTS[];
@@ -39,16 +39,19 @@ type Args = {
description: string; description: string;
bot_id?: number; bot_id?: number;
workspace_id?: number; workspace_id?: number;
shared?: boolean;
status?: TaskStatus; status?: TaskStatus;
}; };
type Place = { bot_id?: number; workspace_id?: number }; type Place = { bot_id?: number; workspace_id?: number };
/** /**
* Worked out from the arguments and who is asking: where the task goes, or * Worked out from the arguments and who is asking: where the task goes and
* why it can't. A worker may only add to its own list or the workspace it * who has it, or why it can't. Outside a workspace a worker adds only to
* works in; a workspace admin also to its members' own lists there; the * its own list. In one the task stays in that workspace: its own by
* coordinator to any worker's, any workspace's, or its own. * default, a member's as the workspace's admin, or shared (nobody's yet).
* The coordinator adds to any worker's own list or any workspace, for one
* of its bots or shared.
*/ */
function resolvePlace({ function resolvePlace({
args, args,
@@ -66,41 +69,53 @@ function resolvePlace({
const target_bot = args.bot_id ? Number(args.bot_id) : null; const target_bot = args.bot_id ? Number(args.bot_id) : null;
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null; const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
if (target_bot && target_workspace) { if (args.shared && target_bot) {
return `A task belongs to a bot or a workspace, not both. Pick one.`; return `A shared task is nobody's yet: leave bot_id out, or drop shared to give it to that bot.`;
} }
if (is_coordinator) { if (is_coordinator) {
if (target_workspace) return { workspace_id: target_workspace }; const place_workspace = target_workspace || (workspace_id ? Number(workspace_id) : null);
if (target_bot) return { bot_id: target_bot };
if (place_workspace) {
return target_bot ? { workspace_id: place_workspace, bot_id: target_bot } : { workspace_id: place_workspace };
}
return { bot_id: target_bot || Number(bot_id) };
}
if (!workspace_id) {
if (target_workspace || args.shared) {
return `You're not in a workspace here, so a task can only go on your own list (bot_id ${bot_id}).`;
}
if (target_bot && target_bot != Number(bot_id)) {
return `You can only add tasks to your own list (#${bot_id}).`;
}
return { bot_id: Number(bot_id) }; return { bot_id: Number(bot_id) };
} }
if (target_workspace && target_workspace != Number(workspace_id)) {
return `You can only add tasks in this workspace (#${workspace_id}).`;
}
if (args.shared) return { workspace_id: Number(workspace_id) };
const is_member = members.some((member) => member.id == target_bot); const is_member = members.some((member) => member.id == target_bot);
if (target_bot && target_bot != Number(bot_id) && !is_member) { if (target_bot && target_bot != Number(bot_id) && !is_member) {
return members.length return members.length
? `You can only add tasks to your own list (#${bot_id}), this workspace's, or a member's here (${members.map((m) => m.id).join(", ")}).` ? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}), or leave it shared.`
: `You can only add tasks to your own list (#${bot_id}).`; : `You can only give a task here to yourself (#${bot_id}), or leave it shared.`;
} }
if (target_workspace && Number(workspace_id || 0) != target_workspace) { return { workspace_id: Number(workspace_id), bot_id: target_bot || Number(bot_id) };
return `You can only add to this workspace's list (#${workspace_id || "none"}) or your own.`;
}
if (target_workspace) return { workspace_id: target_workspace };
if (target_bot) return { bot_id: target_bot };
/**
* In a workspace, the workspace's list is the right home: work there is
* shared work.
*/
return workspace_id ? { workspace_id: Number(workspace_id) } : { bot_id: Number(bot_id) };
} }
/** /**
* Lets a bot add a task to a list it works on: its own, or a workspace's. * Lets a bot add a task to a list it works on: its own, or in a workspace
* The coordinator can add to any worker's list and any workspace's. * its own there (a member's as its admin) or a shared one. The coordinator
* can add to any worker's list and any workspace.
*/ */
export default function createTaskTool({ export default function createTaskTool({
user_id, user_id,
@@ -119,8 +134,8 @@ export default function createTaskTool({
return { return {
name: "create_task", name: "create_task",
description: is_coordinator description: is_coordinator
? `Add a task to a list: a worker's own (${crew || "no workers yet"}), a workspace's shared one (${places || "no workspaces yet"}), or your own (leave both ids out). Write the description as a brief: what to do, where, and how you'll know it's done. A workspace's tasks are visible to every bot working there.` ? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id) or shared (without), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it a shared task in this workspace" : ""}). Write the description as a brief: what to do, where, and how you'll know it's done.`
: `Add a task to a list: ${workspace_id ? `this workspace's shared one (leave the ids out), or your own (bot_id: ${bot_id})${team ? `, or, as this workspace's admin, a member's own (${team})` : ""}` : `your own, bot_id ${bot_id} (or leave the id out, since it defaults to yours)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`, : `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, a member's as this workspace's admin (bot_id: ${team})` : ""}, or shared (shared: true) for whichever bot here takes it up` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
schema: z.object({ schema: z.object({
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`), title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
description: z description: z
@@ -129,10 +144,10 @@ export default function createTaskTool({
.describe(`The brief, in markdown: what to do, where, and what done looks like`), .describe(`The brief, in markdown: what to do, where, and what done looks like`),
bot_id: z.number().int().optional().describe( bot_id: z.number().int().optional().describe(
is_coordinator is_coordinator
? `Whose own list, when it isn't a workspace's. Leave out for your own.` ? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out for your own list or a shared workspace task.`
: team : team
? `Your own id (${bot_id}) or a member's here (${team}), to put it on that list rather than the workspace's.` ? `Who has it here: you (${bot_id}, the default) or a member (${team}).`
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's; leaving it out has the same effect here.`, : `Your own id (${bot_id}); leaving it out has the same effect.`,
), ),
workspace_id: z workspace_id: z
.number() .number()
@@ -140,9 +155,13 @@ export default function createTaskTool({
.optional() .optional()
.describe( .describe(
is_coordinator is_coordinator
? `A workspace's shared list, when it isn't a bot's own.` ? `The workspace it's in, when it isn't a bot's own list.`
: `This workspace's shared list (${workspace_id || "none"}).`, : `This workspace (${workspace_id || "none"}); leaving it out has the same effect.`,
), ),
shared: z
.boolean()
.optional()
.describe(`In a workspace: nobody's yet, for whichever bot there takes it up. Defaults to false.`),
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`), status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
}), }),
run: async ({ args }) => { run: async ({ args }) => {
@@ -154,8 +173,9 @@ export default function createTaskTool({
bot_id, bot_id,
is_coordinator, is_coordinator,
admin_workspace_ids, admin_workspace_ids,
only_bot_id: place.bot_id, only_bot_id: place.workspace_id ? undefined : place.bot_id,
only_workspace_id: place.workspace_id, only_workspace_id: place.workspace_id,
own_list: !place.workspace_id,
open_only: true, open_only: true,
}); });
@@ -180,7 +200,13 @@ export default function createTaskTool({
created_by_bot_id: bot_id, created_by_bot_id: bot_id,
}); });
return `Task #${task.id} "${task.title}" added ${place.workspace_id ? "to this workspace's list" : place.bot_id == Number(bot_id) ? "to your list" : `to bot ${place.bot_id}'s list`}. Check it with list_tasks before you mention it to the user.`; const where = place.workspace_id
? `in workspace ${place.workspace_id}, ${!place.bot_id ? "shared" : place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
: place.bot_id == Number(bot_id)
? "to your list"
: `to bot ${place.bot_id}'s list`;
return `Task #${task.id} "${task.title}" added ${where}. Check it with list_tasks before you mention it to the user.`;
}, },
}; };
} }
@@ -17,7 +17,7 @@ type Params = {
is_coordinator: boolean; is_coordinator: boolean;
/** /**
* The workspaces the bot is admin of (`grabBotRunPowers`): in one of * The workspaces the bot is admin of (`grabBotRunPowers`): in one of
* them, create_task can also add to the other members' own lists. * them, create_task can also give tasks there to the other members.
*/ */
admin_workspace_ids?: number[]; admin_workspace_ids?: number[];
workspace_id?: number | string | null; workspace_id?: number | string | null;
@@ -25,11 +25,11 @@ type Params = {
/** /**
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the * The task tools every run gets (`src/harness/tools/bot/tasks/`): the
* shared to-do list, one per bot and one per workspace, so a chat reply, a * to-do list (a bot's own, and in each workspace its tasks there and the
* task and a scheduled run all read it, move it along and file what they * shared ones), so a chat reply, a task and a scheduled run all read it,
* produced. The coordinator's create_task may target any worker or * move it along and file what they produced. The coordinator's create_task
* workspace; its update_task may link any file it can reach. A workspace * may target any worker or workspace; its update_task may link any file it
* admin's may add to the other members' own lists there. * can reach. A workspace admin's may give tasks there to the other members.
*/ */
export default async function grabBotTaskTools({ export default async function grabBotTaskTools({
user_id, user_id,
@@ -34,12 +34,12 @@ type Args = {
export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> { export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> {
return { return {
name: "list_tasks", name: "list_tasks",
description: `List tasks: yours, the ones in a workspace you work in, or ${is_coordinator ? "every task in the install" : "every task you can reach"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`, description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and in your workspaces the shared ones nobody has yet)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
schema: z.object({ schema: z.object({
scope: z scope: z
.enum(["mine", "workspace", "all"]) .enum(["mine", "workspace", "all"])
.optional() .optional()
.describe(`"mine" for your own list, "workspace" for ${workspace_id ? "this workspace's" : "a workspace's (none open)"}, "all" for everything you reach. Defaults to "all".`), .describe(`"mine" for the tasks you have (your own list and in workspaces), "workspace" for ${workspace_id ? "this workspace's" : "a workspace's (none open)"}, "all" for everything you reach. Defaults to "all".`),
status: z.enum(TaskStatusValues).optional(), status: z.enum(TaskStatusValues).optional(),
open_only: z open_only: z
.boolean() .boolean()
@@ -74,9 +74,10 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_w
return tasks return tasks
.map((task) => { .map((task) => {
const who = task.bot_id ? task.bot_name || `bot ${task.bot_id}` : "shared, nobody's yet";
const where = task.workspace_id const where = task.workspace_id
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}` ? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}, ${who}`
: `on ${task.bot_name || `bot ${task.bot_id}`}`; : `on ${who}'s list`;
const bits = [ const bits = [
`#${task.id}`, `#${task.id}`,
`[${task.status}]`, `[${task.status}]`,
@@ -4,6 +4,7 @@ import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note"; import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file"; import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task"; import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
import moveTask from "@/src/functions/backend/db/tasks/move-task";
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media"; import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
import updateTask from "@/src/functions/backend/db/tasks/update-task"; import updateTask from "@/src/functions/backend/db/tasks/update-task";
import type { StorageScope } from "@/src/utils/storage-access"; import type { StorageScope } from "@/src/utils/storage-access";
@@ -40,7 +41,7 @@ type Args = {
export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> { export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> {
return { return {
name: "update_task", name: "update_task",
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`, description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Setting a shared workspace task to doing makes it yours. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
schema: z.object({ schema: z.object({
task_id: z.number().int(), task_id: z.number().int(),
status: z.enum(TaskStatusValues).optional(), status: z.enum(TaskStatusValues).optional(),
@@ -67,6 +68,15 @@ export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids }); const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids });
const said: string[] = []; const said: string[] = [];
/**
* A shared task in a workspace is nobody's until a bot starts on
* it: then it's that bot's.
*/
if (args.status == "doing" && task.workspace_id && !task.bot_id && !is_coordinator) {
await moveTask({ task_id: task.id!, bot_id, workspace_id: task.workspace_id });
said.push(`it's yours now`);
}
if (args.status || args.blocked_reason !== undefined) { if (args.status || args.blocked_reason !== undefined) {
if (args.status == "blocked" && !args.blocked_reason?.trim()) { if (args.status == "blocked" && !args.blocked_reason?.trim()) {
return `Blocked needs a reason: say in blocked_reason what the user has to do.`; return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
@@ -14,8 +14,8 @@ type Params = {
}; };
/** /**
* A conversation's tasks: a bot's own list, or in a workspace the * A conversation's tasks: a bot's own list, or in a workspace the ones it
* workspace's shared one. Loaded on open and refreshed after every change, * has there plus the shared ones nobody has yet. Loaded on open and refreshed after every change,
* never part of the page props. * never part of the page props.
*/ */
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) { export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
@@ -29,8 +29,8 @@ export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Pa
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set("bot_id", String(bot_id));
if (workspace_id) query.set("workspace_id", String(workspace_id)); if (workspace_id) query.set("workspace_id", String(workspace_id));
else query.set("bot_id", String(bot_id));
const res = await fetchApi<{}, APIResponseObject<TaskWithNames>>( const res = await fetchApi<{}, APIResponseObject<TaskWithNames>>(
`/api/admin/tasks/list-tasks?${query.toString()}`, `/api/admin/tasks/list-tasks?${query.toString()}`,
@@ -31,7 +31,7 @@ type Props = {
/** /**
* The tasks this conversation works on: a bot's own list, or in a workspace * The tasks this conversation works on: a bot's own list, or in a workspace
* the workspace's shared one. Open it from the header menu; the list loads * the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads
* then, so the page's props stay small. A crew manager adds, moves, edits * then, so the page's props stay small. A crew manager adds, moves, edits
* and deletes; a task opens to show its notes and files. * and deletes; a task opens to show its notes and files.
*/ */
@@ -50,7 +50,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
}); });
const name = bot.name || "This bot"; const name = bot.name || "This bot";
const title = workspace?.id ? `${workspace.name || "Workspace"} tasks` : `${name}'s tasks`; const title = workspace?.id ? `${name}'s tasks in ${workspace.name || "this workspace"}` : `${name}'s tasks`;
useEffect(() => { useEffect(() => {
if (open) on_open?.(); if (open) on_open?.();
@@ -169,7 +169,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
{!open_task ? ( {!open_task ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary"> <Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace?.id {workspace?.id
? `The shared to-do list of ${workspace.name || "this workspace"}. Every bot here sees it, and they keep it up to date as they work.` ? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
: `What ${name} is working through, and what it's waiting on.`} : `What ${name} is working through, and what it's waiting on.`}
</Span> </Span>
) : null} ) : null}
@@ -184,9 +184,9 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
set_status(msg); set_status(msg);
/** /**
* Handed to another bot, it's on that bot's list now. * Handed to another bot, it's that bot's now.
*/ */
if (!workspace?.id && saved.bot_id != bot.id) { if (saved.bot_id && saved.bot_id != bot.id) {
setTasks((prev) => prev.filter((item) => item.id != saved.id)); setTasks((prev) => prev.filter((item) => item.id != saved.id));
set_open_task(null); set_open_task(null);
return; return;
@@ -203,7 +203,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
/> />
) : adding ? ( ) : adding ? (
<TaskForm <TaskForm
bot_id={workspace?.id ? null : Number(bot.id)} bot_id={Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null} workspace_id={workspace?.id ? Number(workspace.id) : null}
onCancel={() => set_adding(false)} onCancel={() => set_adding(false)}
onSaved={(saved, msg) => { onSaved={(saved, msg) => {
@@ -25,8 +25,8 @@ type Props = {
}; };
/** /**
* A workspace's shared to-do list: every bot working there sees these, and * Every task in a workspace, whichever bot has it (or shared, nobody's
* the coordinator sees them all. Add and change one by hand here; the bots * yet). Each bot there sees its own and the shared ones. Add and change one by hand here; the bots
* keep it up to date themselves as they work. * keep it up to date themselves as they work.
*/ */
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) { export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
@@ -82,7 +82,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
<SettingsGroup <SettingsGroup
id="tasks" id="tasks"
title="Tasks" title="Tasks"
description="The workspace's shared to-do list. Every bot here sees it, and they keep it up to date as they work." description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet."
> >
<Stack className="w-full gap-2! items-stretch"> <Stack className="w-full gap-2! items-stretch">
{open_task ? ( {open_task ? (
@@ -153,7 +153,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
open={adding} open={adding}
setOpen={setAdding} setOpen={setAdding}
title="Add a task" title="Add a task"
description="It goes on the workspace's shared list, where every bot here can pick it up." description="It's added shared: whichever bot here takes it up has it. Hand it over to give it to one."
> >
<TaskForm <TaskForm
workspace_id={workspace_id} workspace_id={workspace_id}
+5 -2
View File
@@ -5,8 +5,9 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/** /**
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's shared one * The user's tasks: a bot's own list (`?bot_id=`), a workspace's
* (`?workspace_id=`), or every task they reach. `?status=` narrows to one * (`?workspace_id=`), one bot's there with the unassigned ones
* (`?workspace_id=&bot_id=`), or every task they reach. `?status=` narrows to one
* status, `?open=1` to the open ones. * status, `?open=1` to the open ones.
*/ */
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => { export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
@@ -32,7 +33,9 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> =
payload: await grabTasks({ payload: await grabTasks({
user_id: user.id, user_id: user.id,
only_bot_id: bot_id, only_bot_id: bot_id,
with_unassigned: Boolean(bot_id && workspace_id),
only_workspace_id: workspace_id, only_workspace_id: workspace_id,
own_list: Boolean(bot_id && !workspace_id),
statuses: status ? [status] : undefined, statuses: status ? [status] : undefined,
open_only: open, open_only: open,
}), }),
+2 -2
View File
@@ -11,8 +11,8 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/** /**
* Adds a task to a bot's list or a workspace's (`bot_id` or * Adds a task to a bot's list (`bot_id`), a workspace (`workspace_id`,
* `workspace_id`), or changes one (`task_id`). Returns the task with its * shared) or a bot in a workspace (both), or changes one (`task_id`). Returns the task with its
* notes and files. * notes and files.
*/ */
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => { export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
+3 -2
View File
@@ -6,8 +6,9 @@ const status_values = TaskStatuses.map((status) => status.value) as [string, ...
const media_kind_values = TaskMediaKinds.map((kind) => kind.value) as [string, ...string[]]; const media_kind_values = TaskMediaKinds.map((kind) => kind.value) as [string, ...string[]];
/** /**
* One collection: a bot's own list or a workspace's shared one. The helper * Where it goes: a bot's own list (`bot_id`), a workspace (`workspace_id`,
* refuses both or neither (`taskScopeValues`). * shared), or a bot's task in a workspace (both). The helper refuses
* neither (`taskScopeValues`).
*/ */
const scope = { const scope = {
bot_id: z.number().int().positive().optional(), bot_id: z.number().int().positive().optional(),
+16 -29
View File
@@ -8,8 +8,8 @@ type Params = {
*/ */
is_coordinator: boolean; is_coordinator: boolean;
/** /**
* The workspaces the bot is admin of: it also reaches the tasks it added * The workspaces the bot is admin of: it reaches every task there,
* to their members' own lists. * whoever has it.
*/ */
admin_workspace_ids?: number[]; admin_workspace_ids?: number[];
/** /**
@@ -19,11 +19,11 @@ type Params = {
}; };
/** /**
* The tasks a bot's run reaches: its own, plus every workspace it works in * The tasks a bot's run reaches: its own list, and in every workspace it
* (a workspace's tasks are shared by its bots). The coordinator reaches * works in the tasks it has there plus the shared ones nobody has yet.
* every task in the install: every bot's own and every workspace's. A * Another bot's task in a workspace is that bot's. A workspace admin
* workspace admin also reaches the tasks it put on that workspace's * reaches every task in that workspace; the coordinator every task in the
* members' own lists. * install.
*/ */
export default function botTaskScopesSql({ export default function botTaskScopesSql({
bot_id, bot_id,
@@ -34,31 +34,18 @@ export default function botTaskScopesSql({
if (is_coordinator) return { sql: `1`, values: [] }; if (is_coordinator) return { sql: `1`, values: [] };
const admin_ids = admin_workspace_ids.map(Number).filter(Boolean); const admin_ids = admin_workspace_ids.map(Number).filter(Boolean);
const admin_sql = admin_ids.length
if (admin_ids.length) { ? `OR ${alias}.workspace_id IN (${admin_ids.map(() => "?").join(", ")})`
const marks = admin_ids.map(() => "?").join(", "); : "";
return {
sql: `(
${alias}.bot_id = ?
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (
SELECT workspace_id FROM workspace_bots WHERE bot_id = ?
))
OR (${alias}.created_by_bot_id = ? AND ${alias}.bot_id IN (
SELECT bot_id FROM workspace_bots WHERE workspace_id IN (${marks})
))
)`,
values: [Number(bot_id), Number(bot_id), Number(bot_id), ...admin_ids],
};
}
return { return {
sql: `( sql: `(
${alias}.bot_id = ? (${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN ( OR (${alias}.workspace_id IS NOT NULL
SELECT workspace_id FROM workspace_bots WHERE bot_id = ? AND (${alias}.bot_id IS NULL OR ${alias}.bot_id = ?)
)) AND ${alias}.workspace_id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?))
${admin_sql}
)`, )`,
values: [Number(bot_id), Number(bot_id)], values: [Number(bot_id), Number(bot_id), Number(bot_id), ...admin_ids],
}; };
} }
+5 -5
View File
@@ -3,10 +3,10 @@ import workspacesSql from "../crew-access/workspaces-sql";
import type { SqlCondition, TaskScopeSqlParams } from "./types"; import type { SqlCondition, TaskScopeSqlParams } from "./types";
/** /**
* The tasks a user sees: a bot they chat with in its own chat, and every * The tasks a user sees: the own list of a bot they chat with in its own
* workspace they're in (whose tasks belong to the workspace, not to one * chat, and every task of a workspace they're in, whichever bot there has
* bot). Matches the crew access rules exactly, so a task can't be seen * it. Matches the crew access rules exactly, so a task can't be seen where
* where the bot or workspace cannot. * the bot or workspace cannot.
*/ */
export default function taskScopesSql({ export default function taskScopesSql({
role, role,
@@ -18,7 +18,7 @@ export default function taskScopesSql({
return { return {
sql: `( sql: `(
(${alias}.bot_id IS NOT NULL AND ${alias}.bot_id IN (SELECT tb.id FROM bots tb WHERE ${bots.sql})) (${alias}.workspace_id IS NULL AND ${alias}.bot_id IN (SELECT tb.id FROM bots tb WHERE ${bots.sql}))
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (SELECT tw.id FROM workspaces tw WHERE ${workspaces.sql})) OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (SELECT tw.id FROM workspaces tw WHERE ${workspaces.sql}))
)`, )`,
values: [...bots.values, ...workspaces.values], values: [...bots.values, ...workspaces.values],
+23 -12
View File
@@ -21,7 +21,8 @@ beforeAll(() => {
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`); db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
db.run(`INSERT INTO tasks VALUES db.run(`INSERT INTO tasks VALUES
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL), (100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
(110, NULL, 10, NULL), (111, NULL, 11, NULL)`); (110, NULL, 10, NULL), (111, NULL, 11, NULL),
(112, 3, 11, NULL), (113, 2, 11, NULL)`);
}); });
function ids({ condition }: { condition: SqlCondition }) { function ids({ condition }: { condition: SqlCondition }) {
@@ -34,43 +35,53 @@ function ids({ condition }: { condition: SqlCondition }) {
describe("taskScopesSql", () => { describe("taskScopesSql", () => {
test("the super admin sees every task, the coordinator's and every workspace's", () => { test("the super admin sees every task, the coordinator's and every workspace's", () => {
expect(ids({ condition: taskScopesSql({ role: "super_admin", user_id: 1 }) })).toEqual([ expect(ids({ condition: taskScopesSql({ role: "super_admin", user_id: 1 }) })).toEqual([
100, 101, 102, 110, 111, 100, 101, 102, 110, 111, 112, 113,
]); ]);
}); });
test("an admin sees every worker's own tasks and every workspace's, never the coordinator's", () => { test("an admin sees every worker's own tasks and every workspace's, never the coordinator's", () => {
expect(ids({ condition: taskScopesSql({ role: "admin", user_id: 5 }) })).toEqual([101, 102, 110, 111]); expect(ids({ condition: taskScopesSql({ role: "admin", user_id: 5 }) })).toEqual([101, 102, 110, 111, 112, 113]);
}); });
test("a member sees only their assigned bots' own tasks and their workspaces'", () => { test("a member sees only their assigned bots' own tasks and every task of their workspaces", () => {
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 7 }) })).toEqual([101, 111]); expect(ids({ condition: taskScopesSql({ role: "member", user_id: 7 }) })).toEqual([101, 111, 112, 113]);
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 8 }) })).toEqual([]); expect(ids({ condition: taskScopesSql({ role: "member", user_id: 8 }) })).toEqual([]);
});
test("chatting with a bot doesn't show its tasks in a workspace the user isn't in", () => {
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 9 }) })).toEqual([102]); expect(ids({ condition: taskScopesSql({ role: "member", user_id: 9 }) })).toEqual([102]);
}); });
}); });
describe("botTaskScopesSql", () => { describe("botTaskScopesSql", () => {
test("a worker reaches its own tasks and those of every workspace it works in", () => { test("a worker reaches its own list, its tasks in its workspaces and the shared ones there", () => {
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111]); expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111, 112]);
});
test("another bot's task in a workspace stays that bot's", () => {
db.run(`INSERT INTO workspace_bots VALUES (11, 2)`);
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101, 111, 113]);
db.run(`DELETE FROM workspace_bots WHERE bot_id = 2`);
});
test("a task given to a bot in a workspace it has left is out of its reach", () => {
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101]); expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101]);
}); });
test("the coordinator reaches every task in the install", () => { test("the coordinator reaches every task in the install", () => {
expect(ids({ condition: botTaskScopesSql({ bot_id: 1, is_coordinator: true }) })).toEqual([ expect(ids({ condition: botTaskScopesSql({ bot_id: 1, is_coordinator: true }) })).toEqual([
100, 101, 102, 110, 111, 100, 101, 102, 110, 111, 112, 113,
]); ]);
}); });
}); });
describe("botTaskScopesSql for a workspace admin", () => { describe("botTaskScopesSql for a workspace admin", () => {
test("it also reaches what it put on its workspace members' own lists", () => { test("it reaches every task in that workspace, whoever has it, and no member's own list", () => {
db.run(`INSERT INTO workspace_bots VALUES (11, 4)`); db.run(`INSERT INTO workspace_bots VALUES (11, 4)`);
db.run(`INSERT INTO tasks VALUES (103, 3, NULL, 4), (104, 2, NULL, 4)`);
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([ expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
103, 111, 111, 112, 113,
]); ]);
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]); expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]);
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`); db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
db.run(`DELETE FROM tasks WHERE id IN (103, 104)`);
}); });
}); });
+2 -1
View File
@@ -9,7 +9,8 @@ export type SqlCondition = {
}; };
/** /**
* One collection of tasks: a bot's own, or a workspace's shared one. * Where a task is: a bot's own list (`bot_id` alone), or a workspace
* (`workspace_id`, with the bot that has it, if any).
*/ */
export type TaskScope = { export type TaskScope = {
bot_id?: number | null; bot_id?: number | null;
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import taskPlaceLabel from "./task-place-label";
describe("taskPlaceLabel", () => {
test("names the bot for its own list", () => {
expect(taskPlaceLabel({ task: { bot_id: 2, bot_name: "Ada" } })).toBe("Ada");
});
test("names the bot and the workspace for a bot's task there", () => {
expect(taskPlaceLabel({ task: { bot_id: 2, bot_name: "Ada", workspace_id: 5, workspace_name: "Launch" } })).toBe(
"Ada in Launch",
);
});
test("says shared for a workspace task nobody has yet", () => {
expect(taskPlaceLabel({ task: { workspace_id: 5, workspace_name: "Launch" } })).toBe("Shared in Launch");
});
});
+23
View File
@@ -0,0 +1,23 @@
type Params = {
task: {
bot_id?: number | string | null;
bot_name?: string | null;
workspace_id?: number | string | null;
workspace_name?: string | null;
};
};
/**
* Where a task is and who has it, for a list row: "Ada" for a bot's own
* list, "Ada in Launch" for a bot's task in a workspace, "Shared in
* Launch" for one nobody there has yet.
*/
export default function taskPlaceLabel({ task }: Params) {
const bot = task.bot_id ? task.bot_name || `Bot ${task.bot_id}` : null;
if (!task.workspace_id) return bot || "";
const workspace = task.workspace_name || `Workspace ${task.workspace_id}`;
return `${bot || "Shared"} in ${workspace}`;
}