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,148 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOT_FILES,
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_TASK_MEDIA,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { TaskMediaKind } from "@/src/dict/task-dict";
import AppError from "@/src/utils/app-error";
import type { StorageScope } from "@/src/utils/storage-access";
import grabBotFile from "../bot-files/grab-bot-file";
import grabTaskMedia from "./grab-task-media";
import grabTaskRow from "./grab-task-scope";
type Params = {
task_id: number | string;
/**
* One of the run's folders' catalog ids: the file must reach it
* (`grabBotFile`), so a bot can't link a file outside its storage.
*/
file_id?: number | string | null;
/**
* Or a `media` row the user owns (a chat upload being filed onto a
* task). Ignored when `file_id` is given.
*/
media_id?: number | string | null;
/**
* The user whose own media may be linked.
*/
user_id: number | string;
/**
* The run's storage scopes, for a bot's file.
*/
scopes?: StorageScope[];
kind?: TaskMediaKind;
/**
* The bot linking it, when a bot does.
*/
added_by_bot_id?: number | string | null;
};
export type AttachTaskFileResult = {
file: BUN_SQLITE_COBBLE_TASK_MEDIA;
/**
* Whether the link was already there (the call succeeded either way).
*/
existed: boolean;
};
/**
* Links a file to a task as its deliverable or a reference. A bot file is
* resolved through `grabBotFile`, so only what the run reaches links; a
* chat upload must be one of the user's own `media` rows. Caps a task at
* `TaskMediaMaxPerTask` files and never links the same file twice.
*/
export default async function attachTaskFile({
task_id,
file_id,
media_id,
user_id,
scopes = [],
kind = "deliverable",
added_by_bot_id,
}: Params): Promise<AttachTaskFileResult> {
const task = await grabTaskRow({ task_id });
let file: BUN_SQLITE_COBBLE_BOT_FILES | null = null;
let media: BUN_SQLITE_COBBLE_MEDIA | null = null;
if (file_id) {
file = await grabBotFile({ scopes, file_id });
if (!file?.media_id) {
throw new AppError(`File #${file_id} isn't one you can reach.`);
}
media_id = file.media_id;
}
if (!media_id) {
throw new AppError(`Which file? Give its file id.`);
}
media = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [Number(media_id)],
})
).payload?.[0] || null;
if (!media?.id) {
throw new AppError(`That file's bytes are gone.`);
}
/**
* Without a bot file, the media must be one the user owns: never
* someone else's upload.
*/
if (!file && Number(media.user_id || 0) != Number(user_id)) {
throw new AppError(`That file isn't yours to attach.`);
}
const existing = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE task_id = ? AND media_id = ? LIMIT 1`,
values: [Number(task.id), Number(media.id)],
});
if (existing.payload?.[0]) {
return { file: existing.payload[0], existed: true };
}
const count = (await grabTaskMedia({ task_id: task.id! })).length;
if (count >= AppData.TaskMediaMaxPerTask) {
throw new AppError(`Task #${task.id} already carries ${AppData.TaskMediaMaxPerTask} files. Unlink one first.`);
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_TASK_MEDIA, (typeof BunSQLiteTables)[number]>({
table: "task_media",
data: [
{
task_id: Number(task.id),
media_id: Number(media.id),
bot_file_id: file?.id ? Number(file.id) : undefined,
kind,
name: file?.name || media.title || `File ${media.id}`,
added_by_bot_id: added_by_bot_id ? Number(added_by_bot_id) : undefined,
},
],
});
const link_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !link_id) {
throw new Error(`Couldn't link the file: ${res.msg}`);
}
return {
file: (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE id = ? LIMIT 1`,
values: [link_id],
})
).payload![0]!,
existed: false,
};
}