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,164 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
import createTask from "@/src/functions/backend/db/tasks/create-task";
import findDuplicateTask from "@/src/functions/backend/db/tasks/find-duplicate-task";
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
import type { HarnessTool } from "../../../types";
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.
*/
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
* works in.
*/
workers?: BUN_SQLITE_COBBLE_BOTS[];
workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[];
};
type Args = {
title: string;
description: string;
bot_id?: number;
workspace_id?: number;
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; the coordinator to any worker's, any workspace's, or its own.
*/
function resolvePlace({
args,
bot_id,
workspace_id,
is_coordinator,
}: {
args: Args;
bot_id: number | string;
workspace_id?: number | string | null;
is_coordinator: boolean;
}): Place | string {
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 (is_coordinator) {
if (target_workspace) return { workspace_id: target_workspace };
if (target_bot) return { bot_id: target_bot };
return { bot_id: Number(bot_id) };
}
if (target_bot && target_bot != Number(bot_id)) {
return `You can only add tasks to your own list (#${bot_id}).`;
}
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) };
}
/**
* 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.
*/
export default function createTaskTool({
user_id,
bot_id,
workspace_id,
is_coordinator,
workers = [],
workspaces = [],
}: Params): HarnessTool<Args> {
const crew = workers.map((worker) => `${worker.id}: ${worker.name}`).join("; ");
const places = workspaces.map((workspace) => `${workspace.id}: ${workspace.name}`).join("; ");
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 + ")" : "your own (leave the ids out)"}. 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
.string()
.max(AppData.TaskDescriptionMaxLength)
.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.`
: `Your own id (${bot_id}), to keep it in your list rather than the workspace's.`,
),
workspace_id: z
.number()
.int()
.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"}).`,
),
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
}),
run: async ({ args }) => {
const place = resolvePlace({ args, bot_id, workspace_id, is_coordinator });
if (typeof place == "string") return place;
const existing = await grabTasks({
bot_id,
is_coordinator,
only_bot_id: place.bot_id,
only_workspace_id: place.workspace_id,
open_only: true,
});
const twin = findDuplicateTask({
tasks: existing,
title: args.title,
bot_id: place.bot_id || null,
workspace_id: place.workspace_id || null,
});
if (twin) {
return `Task #${twin.id} "${twin.title}" is already open with that title. Update it with update_task instead of adding a second one for the same work.`;
}
const task = await createTask({
user_id,
bot_id: place.bot_id,
workspace_id: place.workspace_id,
title: args.title,
description: args.description,
status: args.status as never,
created_by_bot_id: bot_id,
});
return `Task #${task.id} "${task.title}" added ${place.workspace_id ? "to this workspace's list" : "to your list"}. Check it with list_tasks before you mention it to the user.`;
},
};
}