Add tasks: the crew's shared to-do list

Bots and workspaces each keep a list of work tracked until it's done, and
the bots move it along themselves as they work.

- tasks (one collection per bot or per workspace), task_notes (the
  progress log) and task_media (a deliverable or a reference file, its
  bytes a private media row served at /media/private/<id>)
- statuses todo/doing/blocked/done/cancelled, with blocked_reason while
  only the user can unblock it
- visibility as SQL in src/utils/task-access: a user's own bots and
  workspaces, a run's own list plus its workspaces, everything for the
  coordinator; every tool and endpoint resolves through it
- list_tasks, create_task and update_task on every run, so a chat reply,
  a task and a routine all read the list, log progress and file what they
  produced
- TasksNote on every run and TaskReminderNote in chat, so a greeting or a
  proposed next step brings pending work up in one measured line
- tasks group on the workspace dashboard and a tasks modal in the
  conversation header, sharing one form, row and detail panel
- a bot's or workspace's own chat uploads are read before the delete so
  none are stranded; bot files keep their folder

Also corrects the README's RAM guidance and table alignment.
This commit is contained in:
2026-10-22 05:57:50 +01:00
parent 2d108b05bf
commit 7cb26c4e36
60 changed files with 3793 additions and 52 deletions
@@ -0,0 +1,37 @@
import type { SqlCondition } from "./types";
type Params = {
bot_id: number | string;
/**
* Whether the run is the coordinator, which reaches every task (every
* bot's own and every workspace's).
*/
is_coordinator: boolean;
/**
* The table alias the condition reads.
*/
alias?: string;
};
/**
* 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.
*/
export default function botTaskScopesSql({
bot_id,
is_coordinator,
alias = "t",
}: Params): SqlCondition {
if (is_coordinator) return { sql: `1`, values: [] };
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 = ?
))
)`,
values: [Number(bot_id), Number(bot_id)],
};
}
+26
View File
@@ -0,0 +1,26 @@
import chatBotsSql from "../crew-access/chat-bots-sql";
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.
*/
export default function taskScopesSql({
role,
user_id,
alias = "t",
}: TaskScopeSqlParams): SqlCondition {
const bots = chatBotsSql({ role, user_id, alias: "tb" });
const workspaces = workspacesSql({ role, user_id, alias: "tw" });
return {
sql: `(
(${alias}.bot_id IS NOT 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],
};
}
+63
View File
@@ -0,0 +1,63 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import botTaskScopesSql from "./bot-task-scopes-sql";
import taskScopesSql from "./task-scopes-sql";
import type { SqlCondition } from "./types";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY, kind TEXT)`);
db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY, archived INTEGER DEFAULT 0)`);
db.run(`CREATE TABLE bot_users (bot_id INTEGER, user_id INTEGER)`);
db.run(`CREATE TABLE workspace_users (workspace_id INTEGER, user_id INTEGER)`);
db.run(`CREATE TABLE workspace_bots (workspace_id INTEGER, bot_id INTEGER)`);
db.run(`INSERT INTO bots VALUES (1, 'coordinator'), (2, 'worker'), (3, 'worker')`);
db.run(`INSERT INTO workspaces VALUES (10, 0), (11, 0)`);
db.run(`INSERT INTO bot_users VALUES (2, 7), (1, 7), (3, 9)`);
db.run(`INSERT INTO workspace_users VALUES (11, 7)`);
db.run(`INSERT INTO workspace_bots VALUES (11, 3), (10, 3)`);
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`);
db.run(`INSERT INTO tasks VALUES
(100, 1, NULL), (101, 2, NULL), (102, 3, NULL),
(110, NULL, 10), (111, NULL, 11)`);
});
function ids({ condition }: { condition: SqlCondition }) {
return db
.query(`SELECT t.id FROM tasks t WHERE ${condition.sql} ORDER BY t.id`)
.all(...condition.values)
.map((row) => (row as { id: number }).id);
}
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,
]);
});
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]);
});
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]);
expect(ids({ condition: taskScopesSql({ role: "member", user_id: 8 }) })).toEqual([]);
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]);
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,
]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { CrewRole } from "../rbac/crew-role";
/**
* A SQL condition with its values, to splice into a WHERE.
*/
export type SqlCondition = {
sql: string;
values: (string | number)[];
};
/**
* One collection of tasks: a bot's own, or a workspace's shared one.
*/
export type TaskScope = {
bot_id?: number | null;
workspace_id?: number | null;
};
export type TaskScopeSqlParams = {
role: CrewRole;
user_id: number | string;
/**
* The table alias the condition reads.
*/
alias?: string;
};