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
+16 -29
View File
@@ -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],
};
}
+5 -5
View File
@@ -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],
+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(`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)`);
});
});
+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 = {
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}`;
}