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:
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user