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:
@@ -151,7 +151,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+3
-3
@@ -785,11 +785,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
{
|
||||
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: [
|
||||
{
|
||||
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",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
@@ -801,7 +801,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
{
|
||||
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",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import { TaskStatuses } from "@/src/dict/task-dict";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import taskPlaceLabel from "@/src/utils/task-place-label";
|
||||
import TaskStatusChip from "./task-status-chip";
|
||||
|
||||
type Props = {
|
||||
@@ -27,8 +28,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One task: whose it is (a bot's stone), its title and status, the
|
||||
* workspace, what it's blocked on, its latest note and how long ago it
|
||||
* One task: whose it is (a bot's stone), its title and status, where it
|
||||
* is and who has it, what it's blocked on, its latest note and how long ago it
|
||||
* moved.
|
||||
*/
|
||||
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
|
||||
@@ -63,9 +64,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
||||
</Row>
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
|
||||
{task.workspace_id
|
||||
? task.workspace_name || `Workspace ${task.workspace_id}`
|
||||
: task.bot_name || `Bot ${task.bot_id}`}
|
||||
{taskPlaceLabel({ task })}
|
||||
</Span>
|
||||
{task.status == "blocked" && task.blocked_reason ? (
|
||||
<Span className="min-w-0 text-xs leading-4 text-ink truncate">
|
||||
|
||||
@@ -197,7 +197,7 @@ export const workspaceAdminNote = ({
|
||||
members: { id?: number | "" }[];
|
||||
}) => `# 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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -11,14 +11,14 @@ type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in, if any: a bot adding a task there
|
||||
* defaults to the workspace's shared list.
|
||||
* The workspace the run is in, if any: a worker's task there stays in
|
||||
* it, its own by default.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
workers?: BUN_SQLITE_COBBLE_BOTS[];
|
||||
@@ -39,16 +39,19 @@ type Args = {
|
||||
description: string;
|
||||
bot_id?: number;
|
||||
workspace_id?: number;
|
||||
shared?: boolean;
|
||||
status?: TaskStatus;
|
||||
};
|
||||
|
||||
type Place = { bot_id?: number; workspace_id?: number };
|
||||
|
||||
/**
|
||||
* Worked out from the arguments and who is asking: where the task goes, or
|
||||
* why it can't. A worker may only add to its own list or the workspace it
|
||||
* works in; a workspace admin also to its members' own lists there; the
|
||||
* coordinator to any worker's, any workspace's, or its own.
|
||||
* Worked out from the arguments and who is asking: where the task goes and
|
||||
* who has it, or why it can't. Outside a workspace a worker adds only to
|
||||
* its own list. In one the task stays in that workspace: its own by
|
||||
* 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({
|
||||
args,
|
||||
@@ -66,41 +69,53 @@ function resolvePlace({
|
||||
const target_bot = args.bot_id ? Number(args.bot_id) : null;
|
||||
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
|
||||
|
||||
if (target_bot && target_workspace) {
|
||||
return `A task belongs to a bot or a workspace, not both. Pick one.`;
|
||||
if (args.shared && target_bot) {
|
||||
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 (target_workspace) return { workspace_id: target_workspace };
|
||||
if (target_bot) return { bot_id: target_bot };
|
||||
const place_workspace = target_workspace || (workspace_id ? Number(workspace_id) : null);
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (target_bot && target_bot != Number(bot_id) && !is_member) {
|
||||
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 add tasks to your own list (#${bot_id}).`;
|
||||
? `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 give a task here to yourself (#${bot_id}), or leave it shared.`;
|
||||
}
|
||||
|
||||
if (target_workspace && Number(workspace_id || 0) != target_workspace) {
|
||||
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) };
|
||||
return { workspace_id: Number(workspace_id), bot_id: target_bot || Number(bot_id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a bot add a task to a list it works on: its own, or a workspace's.
|
||||
* The coordinator can add to any worker's list and any workspace's.
|
||||
* Lets a bot add a task to a list it works on: its own, or in a workspace
|
||||
* 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({
|
||||
user_id,
|
||||
@@ -119,8 +134,8 @@ export default function createTaskTool({
|
||||
return {
|
||||
name: "create_task",
|
||||
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 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: 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: ${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({
|
||||
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
|
||||
description: z
|
||||
@@ -129,10 +144,10 @@ export default function createTaskTool({
|
||||
.describe(`The brief, in markdown: what to do, where, and what done looks like`),
|
||||
bot_id: z.number().int().optional().describe(
|
||||
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
|
||||
? `Your own id (${bot_id}) or a member's here (${team}), to put it on that list rather than the workspace's.`
|
||||
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's; leaving it out has the same effect here.`,
|
||||
? `Who has it here: you (${bot_id}, the default) or a member (${team}).`
|
||||
: `Your own id (${bot_id}); leaving it out has the same effect.`,
|
||||
),
|
||||
workspace_id: z
|
||||
.number()
|
||||
@@ -140,9 +155,13 @@ export default function createTaskTool({
|
||||
.optional()
|
||||
.describe(
|
||||
is_coordinator
|
||||
? `A workspace's shared list, when it isn't a bot's own.`
|
||||
: `This workspace's shared list (${workspace_id || "none"}).`,
|
||||
? `The workspace it's in, when it isn't a bot's own list.`
|
||||
: `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`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
@@ -154,8 +173,9 @@ export default function createTaskTool({
|
||||
bot_id,
|
||||
is_coordinator,
|
||||
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,
|
||||
own_list: !place.workspace_id,
|
||||
open_only: true,
|
||||
});
|
||||
|
||||
@@ -180,7 +200,13 @@ export default function createTaskTool({
|
||||
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;
|
||||
/**
|
||||
* 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[];
|
||||
workspace_id?: number | string | null;
|
||||
@@ -25,11 +25,11 @@ type Params = {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* task and a scheduled run all read it, move it along and file what they
|
||||
* produced. The coordinator's create_task may target any worker or
|
||||
* workspace; its update_task may link any file it can reach. A workspace
|
||||
* admin's may add to the other members' own lists there.
|
||||
* to-do list (a bot's own, and in each workspace its tasks there and the
|
||||
* shared ones), so a chat reply, a task and a scheduled run all read it,
|
||||
* move it along and file what they produced. The coordinator's create_task
|
||||
* may target any worker or workspace; its update_task may link any file it
|
||||
* can reach. A workspace admin's may give tasks there to the other members.
|
||||
*/
|
||||
export default async function grabBotTaskTools({
|
||||
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> {
|
||||
return {
|
||||
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({
|
||||
scope: z
|
||||
.enum(["mine", "workspace", "all"])
|
||||
.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(),
|
||||
open_only: z
|
||||
.boolean()
|
||||
@@ -74,9 +74,10 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_w
|
||||
|
||||
return tasks
|
||||
.map((task) => {
|
||||
const who = task.bot_id ? task.bot_name || `bot ${task.bot_id}` : "shared, nobody's yet";
|
||||
const where = task.workspace_id
|
||||
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}`
|
||||
: `on ${task.bot_name || `bot ${task.bot_id}`}`;
|
||||
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}, ${who}`
|
||||
: `on ${who}'s list`;
|
||||
const bits = [
|
||||
`#${task.id}`,
|
||||
`[${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 attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
|
||||
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 updateTask from "@/src/functions/backend/db/tasks/update-task";
|
||||
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> {
|
||||
return {
|
||||
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({
|
||||
task_id: z.number().int(),
|
||||
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 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 == "blocked" && !args.blocked_reason?.trim()) {
|
||||
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
|
||||
* workspace's shared one. Loaded on open and refreshed after every change,
|
||||
* A conversation's tasks: a bot's own list, or in a workspace the ones it
|
||||
* has there plus the shared ones nobody has yet. Loaded on open and refreshed after every change,
|
||||
* never part of the page props.
|
||||
*/
|
||||
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();
|
||||
|
||||
query.set("bot_id", String(bot_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>>(
|
||||
`/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 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
|
||||
* 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 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(() => {
|
||||
if (open) on_open?.();
|
||||
@@ -169,7 +169,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
{!open_task ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{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.`}
|
||||
</Span>
|
||||
) : null}
|
||||
@@ -184,9 +184,9 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
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));
|
||||
set_open_task(null);
|
||||
return;
|
||||
@@ -203,7 +203,7 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
/>
|
||||
) : adding ? (
|
||||
<TaskForm
|
||||
bot_id={workspace?.id ? null : Number(bot.id)}
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id ? Number(workspace.id) : null}
|
||||
onCancel={() => set_adding(false)}
|
||||
onSaved={(saved, msg) => {
|
||||
|
||||
@@ -25,8 +25,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace's shared to-do list: every bot working there sees these, and
|
||||
* the coordinator sees them all. Add and change one by hand here; the bots
|
||||
* Every task in a workspace, whichever bot has it (or shared, nobody's
|
||||
* 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.
|
||||
*/
|
||||
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
@@ -82,7 +82,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
<SettingsGroup
|
||||
id="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">
|
||||
{open_task ? (
|
||||
@@ -153,7 +153,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
open={adding}
|
||||
setOpen={setAdding}
|
||||
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
|
||||
workspace_id={workspace_id}
|
||||
|
||||
@@ -5,8 +5,9 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's shared one
|
||||
* (`?workspace_id=`), or every task they reach. `?status=` narrows to one
|
||||
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's
|
||||
* (`?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.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
|
||||
@@ -32,7 +33,9 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> =
|
||||
payload: await grabTasks({
|
||||
user_id: user.id,
|
||||
only_bot_id: bot_id,
|
||||
with_unassigned: Boolean(bot_id && workspace_id),
|
||||
only_workspace_id: workspace_id,
|
||||
own_list: Boolean(bot_id && !workspace_id),
|
||||
statuses: status ? [status] : undefined,
|
||||
open_only: open,
|
||||
}),
|
||||
|
||||
@@ -11,8 +11,8 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Adds a task to a bot's list or a workspace's (`bot_id` or
|
||||
* `workspace_id`), or changes one (`task_id`). Returns the task with its
|
||||
* Adds a task to a bot's list (`bot_id`), a workspace (`workspace_id`,
|
||||
* shared) or a bot in a workspace (both), or changes one (`task_id`). Returns the task with its
|
||||
* notes and files.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
|
||||
|
||||
@@ -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[]];
|
||||
|
||||
/**
|
||||
* One collection: a bot's own list or a workspace's shared one. The helper
|
||||
* refuses both or neither (`taskScopeValues`).
|
||||
* Where it goes: a bot's own list (`bot_id`), a workspace (`workspace_id`,
|
||||
* shared), or a bot's task in a workspace (both). The helper refuses
|
||||
* neither (`taskScopeValues`).
|
||||
*/
|
||||
const scope = {
|
||||
bot_id: z.number().int().positive().optional(),
|
||||
|
||||
@@ -8,8 +8,8 @@ type Params = {
|
||||
*/
|
||||
is_coordinator: boolean;
|
||||
/**
|
||||
* The workspaces the bot is admin of: it also reaches the tasks it added
|
||||
* to their members' own lists.
|
||||
* The workspaces the bot is admin of: it reaches every task there,
|
||||
* whoever has it.
|
||||
*/
|
||||
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
|
||||
* (a workspace's tasks are shared by its bots). The coordinator reaches
|
||||
* every task in the install: every bot's own and every workspace's. A
|
||||
* workspace admin also reaches the tasks it put on that workspace's
|
||||
* members' own lists.
|
||||
* The tasks a bot's run reaches: its own list, and in every workspace it
|
||||
* works in the tasks it has there plus the shared ones nobody has yet.
|
||||
* Another bot's task in a workspace is that bot's. A workspace admin
|
||||
* reaches every task in that workspace; the coordinator every task in the
|
||||
* install.
|
||||
*/
|
||||
export default function botTaskScopesSql({
|
||||
bot_id,
|
||||
@@ -34,31 +34,18 @@ export default function botTaskScopesSql({
|
||||
if (is_coordinator) return { sql: `1`, values: [] };
|
||||
|
||||
const admin_ids = admin_workspace_ids.map(Number).filter(Boolean);
|
||||
|
||||
if (admin_ids.length) {
|
||||
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],
|
||||
};
|
||||
}
|
||||
const admin_sql = admin_ids.length
|
||||
? `OR ${alias}.workspace_id IN (${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 = ?
|
||||
))
|
||||
(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)
|
||||
OR (${alias}.workspace_id IS NOT NULL
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import workspacesSql from "../crew-access/workspaces-sql";
|
||||
import type { SqlCondition, TaskScopeSqlParams } from "./types";
|
||||
|
||||
/**
|
||||
* The tasks a user sees: a bot they chat with in its own chat, and every
|
||||
* workspace they're in (whose tasks belong to the workspace, not to one
|
||||
* bot). Matches the crew access rules exactly, so a task can't be seen
|
||||
* where the bot or workspace cannot.
|
||||
* The tasks a user sees: the own list of a bot they chat with in its own
|
||||
* chat, and every task of a workspace they're in, whichever bot there has
|
||||
* it. Matches the crew access rules exactly, so a task can't be seen where
|
||||
* the bot or workspace cannot.
|
||||
*/
|
||||
export default function taskScopesSql({
|
||||
role,
|
||||
@@ -18,7 +18,7 @@ export default function taskScopesSql({
|
||||
|
||||
return {
|
||||
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}))
|
||||
)`,
|
||||
values: [...bots.values, ...workspaces.values],
|
||||
|
||||
@@ -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(`INSERT INTO tasks VALUES
|
||||
(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 }) {
|
||||
@@ -34,43 +35,53 @@ function ids({ condition }: { condition: SqlCondition }) {
|
||||
describe("taskScopesSql", () => {
|
||||
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([
|
||||
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", () => {
|
||||
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'", () => {
|
||||
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 7 }) })).toEqual([101, 111]);
|
||||
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, 112, 113]);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botTaskScopesSql", () => {
|
||||
test("a worker reaches its own tasks and those of every workspace it works in", () => {
|
||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111]);
|
||||
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, 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]);
|
||||
});
|
||||
|
||||
test("the coordinator reaches every task in the install", () => {
|
||||
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", () => {
|
||||
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 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([
|
||||
103, 111,
|
||||
111, 112, 113,
|
||||
]);
|
||||
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 tasks WHERE id IN (103, 104)`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
bot_id?: number | null;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user