From 7cb26c4e36bb1a013c5da1789062c171edd29275 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 05:57:50 +0100 Subject: [PATCH] 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/) - 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. --- AGENTS.md | 3 +- README.md | 86 +++--- db/schema.ts | 189 +++++++++++++ db/types/db.ts | 114 +++++++- src/bots-queue/run-bot-reply.ts | 4 + src/bots-queue/run-bot-task.ts | 11 +- src/components/general/task-detail-panel.tsx | 266 ++++++++++++++++++ src/components/general/task-file-row.tsx | 75 +++++ src/components/general/task-form.tsx | 128 +++++++++ src/components/general/task-list-item.tsx | 107 +++++++ src/components/general/task-status-chip.tsx | 29 ++ src/data/app-data.ts | 16 ++ src/data/harness-prompts.ts | 26 ++ src/dict/media-dict.ts | 4 + src/dict/task-dict.ts | 95 +++++++ src/functions/backend/db/bots/delete-bot.ts | 13 +- .../backend/db/tasks/add-task-note.ts | 59 ++++ .../db/tasks/assert-user-reaches-task.ts | 36 +++ .../backend/db/tasks/attach-task-file.ts | 148 ++++++++++ src/functions/backend/db/tasks/create-task.ts | 87 ++++++ src/functions/backend/db/tasks/delete-task.ts | 56 ++++ .../backend/db/tasks/detach-task-file.ts | 70 +++++ .../db/tasks/find-duplicate-task.test.ts | 32 +++ .../backend/db/tasks/find-duplicate-task.ts | 33 +++ .../backend/db/tasks/grab-run-task.ts | 40 +++ .../backend/db/tasks/grab-task-media-file.ts | 52 ++++ .../backend/db/tasks/grab-task-media.ts | 29 ++ .../backend/db/tasks/grab-task-scope.ts | 80 ++++++ src/functions/backend/db/tasks/grab-task.ts | 53 ++++ src/functions/backend/db/tasks/grab-tasks.ts | 101 +++++++ .../backend/db/tasks/purge-task-media.ts | 34 +++ .../backend/db/tasks/task-scope-values.ts | 27 ++ src/functions/backend/db/tasks/update-task.ts | 95 +++++++ .../backend/db/workspaces/delete-workspace.ts | 12 + .../server/media/handle-media-server.ts | 2 + src/harness/tools/bot/grab-bot-chat-tools.ts | 9 +- src/harness/tools/bot/grab-bot-tools.ts | 17 +- .../tools/bot/tasks/create-task-tool.ts | 164 +++++++++++ .../tools/bot/tasks/grab-bot-task-tools.ts | 61 ++++ .../tools/bot/tasks/list-tasks-tool.ts | 114 ++++++++ .../tools/bot/tasks/update-task-tool.ts | 120 ++++++++ .../bots/[bot_id]/(hooks)/use-bot-tasks.ts | 55 ++++ .../[bot_id]/(partials)/bot-tasks-modal.tsx | 229 +++++++++++++++ .../(partials)/conversation-header-menu.tsx | 7 + .../(partials)/workspace-tasks-group.tsx | 182 ++++++++++++ .../(sections)/workspace-dashboard.tsx | 5 + .../workspaces/[workspace_id]/index.server.ts | 5 +- src/pages/api/admin/tasks/add-note.ts | 41 +++ src/pages/api/admin/tasks/attach-file.ts | 103 +++++++ src/pages/api/admin/tasks/delete-task.ts | 40 +++ src/pages/api/admin/tasks/detach-file.ts | 44 +++ src/pages/api/admin/tasks/get-task.ts | 36 +++ src/pages/api/admin/tasks/list-tasks.ts | 43 +++ src/pages/api/admin/tasks/save-task.ts | 78 +++++ src/schemas/task-schema.ts | 75 +++++ src/types/index.ts | 53 ++++ src/utils/task-access/bot-task-scopes-sql.ts | 37 +++ src/utils/task-access/task-scopes-sql.ts | 26 ++ src/utils/task-access/task-scopes.test.ts | 63 +++++ src/utils/task-access/types.ts | 26 ++ 60 files changed, 3793 insertions(+), 52 deletions(-) create mode 100644 src/components/general/task-detail-panel.tsx create mode 100644 src/components/general/task-file-row.tsx create mode 100644 src/components/general/task-form.tsx create mode 100644 src/components/general/task-list-item.tsx create mode 100644 src/components/general/task-status-chip.tsx create mode 100644 src/dict/task-dict.ts create mode 100644 src/functions/backend/db/tasks/add-task-note.ts create mode 100644 src/functions/backend/db/tasks/assert-user-reaches-task.ts create mode 100644 src/functions/backend/db/tasks/attach-task-file.ts create mode 100644 src/functions/backend/db/tasks/create-task.ts create mode 100644 src/functions/backend/db/tasks/delete-task.ts create mode 100644 src/functions/backend/db/tasks/detach-task-file.ts create mode 100644 src/functions/backend/db/tasks/find-duplicate-task.test.ts create mode 100644 src/functions/backend/db/tasks/find-duplicate-task.ts create mode 100644 src/functions/backend/db/tasks/grab-run-task.ts create mode 100644 src/functions/backend/db/tasks/grab-task-media-file.ts create mode 100644 src/functions/backend/db/tasks/grab-task-media.ts create mode 100644 src/functions/backend/db/tasks/grab-task-scope.ts create mode 100644 src/functions/backend/db/tasks/grab-task.ts create mode 100644 src/functions/backend/db/tasks/grab-tasks.ts create mode 100644 src/functions/backend/db/tasks/purge-task-media.ts create mode 100644 src/functions/backend/db/tasks/task-scope-values.ts create mode 100644 src/functions/backend/db/tasks/update-task.ts create mode 100644 src/harness/tools/bot/tasks/create-task-tool.ts create mode 100644 src/harness/tools/bot/tasks/grab-bot-task-tools.ts create mode 100644 src/harness/tools/bot/tasks/list-tasks-tool.ts create mode 100644 src/harness/tools/bot/tasks/update-task-tool.ts create mode 100644 src/pages/admin/bots/[bot_id]/(hooks)/use-bot-tasks.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/bot-tasks-modal.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-tasks-group.tsx create mode 100644 src/pages/api/admin/tasks/add-note.ts create mode 100644 src/pages/api/admin/tasks/attach-file.ts create mode 100644 src/pages/api/admin/tasks/delete-task.ts create mode 100644 src/pages/api/admin/tasks/detach-file.ts create mode 100644 src/pages/api/admin/tasks/get-task.ts create mode 100644 src/pages/api/admin/tasks/list-tasks.ts create mode 100644 src/pages/api/admin/tasks/save-task.ts create mode 100644 src/schemas/task-schema.ts create mode 100644 src/utils/task-access/bot-task-scopes-sql.ts create mode 100644 src/utils/task-access/task-scopes-sql.ts create mode 100644 src/utils/task-access/task-scopes.test.ts create mode 100644 src/utils/task-access/types.ts diff --git a/AGENTS.md b/AGENTS.md index a355ec3..2fc63b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to ", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. - **Health:** `containers.status` is docker's state with `running` narrowed by the health check (`starting`, `unhealthy`; `inspectDockerContainer`). Only a `running` computer is reached: `grabComputerMcpUrl` returns `ready: false` otherwise (re-reading a stale state from docker first, `refreshComputerContainer`, which `sync-computers` uses too), and callers say why (`computerNotReadyMessage`). - **Schedules (routines in the UI):** `cron_jobs` (per user and bot, `workspace_id` null for its own chat, `title`, `instructions` (the brief), `schedule` (a `Bun.cron` expression, UTC), `computer_id` (not a foreign key; null runs on the bot's current one), `enabled`, `max_runs` (1 for a one-off), `run_count`, `next_run_at`, `last_run_at`, `last_job_id`, `last_error`). Helpers in `src/functions/backend/db/cron-jobs/` (`create-cron-job`, `update-cron-job`, `delete-cron-job`, `grab-cron-jobs`, `grab-user-cron-job`, `fire-cron-job`), all ownership-checked, and with `bot_id` limited to that bot's own. `nextCronRun` (`src/utils/next-cron-run.ts`, `Bun.cron.parse`) validates a schedule and refuses one that runs more often than `AppData.CronJobMinIntervalMs`; a bot has at most `AppData.CronJobMaxPerBot`. Every bot gets `create_schedule`, `list_schedules`, `update_schedule`, `delete_schedule` (`grabBotTools`, every run; descriptions via `describe-cron-job.ts`). The `run-cron-jobs` cron fires due ones (`fireCronJob`: claims the run atomically by moving `next_run_at`, skips it while the last run's job is still open, queues the brief as a task with `enqueueBotJob` in the bot's active session there, turns off after `max_runs` or when there's no next run, keeps why it couldn't in `last_error`, logs each run under `cron`). A run missed while the server was down runs once, then carries on from now. A run acts on its own (`ScheduledRunNote`: the brief is already approved, so it sends rather than drafts); a run that can't do its brief calls `report_run_failed` (scheduled runs only), which throws `ScheduledRunFailedError` so the queue retries it, `AppData.ScheduledRunRetryMs` apart and doubling (`scheduledRunRetryAt`); once out of attempts the user gets one "failed after N tries" message and the schedule's `last_error` keeps why (`recordCronJobFailure`). `/admin/routines` lists them (pause/resume, edit, delete; `api/admin/routines/update-routine`, `delete-routine`); bots create them from chat. -- **Not built yet:** task steps (nothing writes `bot_task_steps` yet). +- **Tasks (the crew's to-do list):** `tasks` is the tracker, one collection per bot (`bot_id`) and one per workspace (`workspace_id`; exactly one is set, enforced in `taskScopeValues`, since SQLite treats NULL in a unique constraint as distinct - the `envs` precedent). It is not `bot_tasks` or `bots_queue`: those record a queued job, this records the work. Fields: `title`, `description` (the brief, markdown), `status` (`TaskStatuses` in `src/dict/task-dict.ts`: `todo|doing|blocked|done|cancelled`), `blocked_reason` (only while blocked), `origin` (`user|bot`), `created_by_bot_id`/`created_by_user_id` (not foreign keys), `completed_at`. `task_notes` is the progress log (author `user|bot`, `bot_id`, `text`); `task_media` links a file to a task as a `deliverable` or a `reference` (`task_id`, `media_id` FK, `bot_file_id` not a FK, `kind`, `name`, `added_by_bot_id`), the bytes being a private `media` row (paradigm `task-media`) served at `/media/private/` through `grabTaskMediaFile` (registered in `handle-media-server.ts` beside `grabBotFileMedia`). Visibility is SQL in `src/utils/task-access/` (`taskScopesSql` for a user: `chatBotsSql` bots plus `workspacesSql` workspaces; `botTaskScopesSql` for a run: its own plus every workspace it works in, everything for the coordinator; tested against SQLite). A workspace's tasks are visible to every bot working there and to the coordinator. Helpers in `src/functions/backend/db/tasks/`: `create-task` (`assertCrewManager` for a manual add, `TaskMaxPerScope` cap), `grab-tasks` (filters, joins the bot's face, the workspace and the latest note), `grab-task`, `update-task` (stamps `completed_at`, clears `blocked_reason` when it leaves blocked), `delete-task`, `add-task-note`, `attach-task-file`/`detach-task-file` (a bot file resolved through `grabBotFile` so a run can't link outside its folders; a chat upload must be the user's own media; `TaskMediaMaxPerTask`), `grab-task-media`, `grab-task-media-file`, `find-duplicate-task`, `purge-task-media` (`grabTaskMediaIds`, read before `deleteBot`/`deleteWorkspace` so a task's own chat uploads aren't stranded; a bot file keeps its folder). Every run gets `list_tasks`, `create_task`, `update_task` (`src/harness/tools/bot/tasks/`, from `grabBotTools`): 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, and `update_task` takes `status`, `blocked_reason`, a `note` and `deliverable_file_ids`/`reference_file_ids` (`[file:ID]` refs) in one call. `TasksNote` (every reply and task) says how to keep the list honest; `TaskReminderNote` (chat only) tells the bot to check `list_tasks` on a greeting or when proposing next steps and mention at most `AppData.TaskReminderDigestCount` open tasks in one line, blocked first, never repeating a reminder and staying quiet mid-task. The user adds and changes tasks by hand in a workspace's dashboard (`(partials)/workspace-tasks-group.tsx`) and from the conversation header's menu (`(partials)/bot-tasks-modal.tsx`, loading its list when it opens so the page props stay small); both compose the shared `TaskForm`, `TaskListItem`, `TaskDetailPanel`, `TaskStatusChip` and `TaskFileRow` in `src/components/general/`. API in `api/admin/tasks/` (`list-tasks`, `get-task`, `save-task`, `delete-task`, `add-note`, `attach-file`, `detach-file`), each `userAuth` → `isReadOnlyUser` → `assertUserReachesTask` → `safeErrorMessage`. Live pushes for task changes are not built yet. +- **Not built yet:** task steps (nothing writes `bot_task_steps` yet), live `server:task-changed` pushes, and a `/admin/tasks` page. ### Installer and release diff --git a/README.md b/README.md index 3842aaf..46b4b47 100644 --- a/README.md +++ b/README.md @@ -44,21 +44,21 @@ your data on your own machine. ## Features -| Area | What you get | -| --- | --- | -| Crew | One coordinator and as many workers as you need, each with a name, a role, a hue and its own core instructions | -| Rail | The coordinator, your recent workers and their latest sessions, then your workspaces, each row carrying its status line | -| Chat | Markdown with @-mentions for bots and #-mentions for workspaces, replies and quotes, reactions, attachments, older messages on scroll, drafts kept locally | -| Header | The bot's model, provider and computer at a glance, one click from either, and its picture, instructions and settings on the avatar | -| Workspaces | A shared home for a group of bots: shared context, per-workspace instructions and memory, a default computer, and a dashboard | -| Memory | Each bot keeps core memory, memory about you, and workspace memory, and rewrites it as it works | -| Routines | Cron schedules per bot, written in chat, that queue a task on its own | -| Computers | Docker sandboxes with browser, terminal, code editor and desktop, opened from the chat and handed back to you mid-task | -| Files | Bots keep files on your data volume, search them by meaning, and attach the ones they make to a reply | -| Models | OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama or any OpenAI-compatible server, per bot and per harness | -| Voice | Dictate into the composer, play any bot reply, with speech-to-text and text-to-speech models of your choice | -| Tools | Install your own MCP servers and custom HTTP, script or prompt tools, and give them to bots or workspaces | -| App | Installable PWA with Web Push, dark mode, and a rail built for a phone | +| Area | What you get | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Crew | One coordinator and as many workers as you need, each with a name, a role, a hue and its own core instructions | +| Rail | The coordinator, your recent workers and their latest sessions, then your workspaces, each row carrying its status line | +| Chat | Markdown with @-mentions for bots and #-mentions for workspaces, replies and quotes, reactions, attachments, older messages on scroll, drafts kept locally | +| Header | The bot's model, provider and computer at a glance, one click from either, and its picture, instructions and settings on the avatar | +| Workspaces | A shared home for a group of bots: shared context, per-workspace instructions and memory, a default computer, and a dashboard | +| Memory | Each bot keeps core memory, memory about you, and workspace memory, and rewrites it as it works | +| Routines | Cron schedules per bot, written in chat, that queue a task on its own | +| Computers | Docker sandboxes with browser, terminal, code editor and desktop, opened from the chat and handed back to you mid-task | +| Files | Bots keep files on your data volume, search them by meaning, and attach the ones they make to a reply | +| Models | OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama or any OpenAI-compatible server, per bot and per harness | +| Voice | Dictate into the composer, play any bot reply, with speech-to-text and text-to-speech models of your choice | +| Tools | Install your own MCP servers and custom HTTP, script or prompt tools, and give them to bots or workspaces | +| App | Installable PWA with Web Push, dark mode, and a rail built for a phone | ## Screenshots @@ -91,7 +91,7 @@ and wait for the model list to sync. This is what the bots think with. - Linux amd64 or arm64, with Docker. The installer installs Docker from get.docker.com when it is missing. -- 2 CPU cores, 8 GB RAM and 20 GB free disk for the app plus one computer. +- 2 CPU cores, 4 GB RAM and 20 GB free disk for the app plus one computer. Each computer is its own container with its own memory limit. - Docker Desktop on macOS works for development and small installs, but Linux is the supported host. @@ -140,22 +140,22 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu ### Options -| Option | Meaning | -| --------------------- | -------------------------------------------------------------- | -| `--home DIR` | Install directory, default `/opt/cobble` | -| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` | -| `--port PORT` | Host port, default `7227` | -| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` | -| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` | -| `--version TAG` | Image tag, default `latest` | -| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy | -| `--force` | Recreate the container even when it is current | -| `--no-pull` | Use an image that is already on the machine | -| `--skip-backup` | Update without a database backup | -| `--no-docker-install` | Fail instead of installing Docker | -| `--yes`, `-y` | Accept the defaults without asking | -| `--uninstall` | Stop and remove the app container | -| `--purge` | With `--uninstall`, also delete the data directory | +| Option | Meaning | +| --------------------- | ---------------------------------------------------------- | +| `--home DIR` | Install directory, default `/opt/cobble` | +| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` | +| `--port PORT` | Host port, default `7227` | +| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` | +| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` | +| `--version TAG` | Image tag, default `latest` | +| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy | +| `--force` | Recreate the container even when it is current | +| `--no-pull` | Use an image that is already on the machine | +| `--skip-backup` | Update without a database backup | +| `--no-docker-install` | Fail instead of installing Docker | +| `--yes`, `-y` | Accept the defaults without asking | +| `--uninstall` | Stop and remove the app container | +| `--purge` | With `--uninstall`, also delete the data directory | ## How it fits together @@ -251,17 +251,17 @@ the app from a phone, run a proxy or tunnel in front of it (see [HTTPS](#https)); the installed app, Web Push and voice need that secure context. -| Command | Purpose | -| --------------------- | ------------------------------------ | -| `bun run dev` | Development server | -| `bun run start` | Production server on the host | -| `bun run typecheck` | Type-check | -| `bun run test` | Run the test suite | -| `bun run check:bundle`| Bundle budget after a build | -| `bun run seed` | Create the super admin from the CLI | -| `bun run seed:bots` | Add a sample crew | -| `bun run db:schema` | Sync the schema and types | -| `bun run gen:favicon` | Regenerate favicons and PWA assets | +| Command | Purpose | +| ---------------------- | ----------------------------------- | +| `bun run dev` | Development server | +| `bun run start` | Production server on the host | +| `bun run typecheck` | Type-check | +| `bun run test` | Run the test suite | +| `bun run check:bundle` | Bundle budget after a build | +| `bun run seed` | Create the super admin from the CLI | +| `bun run seed:bots` | Add a sample crew | +| `bun run db:schema` | Sync the schema and types | +| `bun run gen:favicon` | Regenerate favicons and PWA assets | Plain Node does not work: the app runs on Bun. The stack is Bun, [`@moduletrace/bunext`](https://github.com/Moduletrace/bunext) for diff --git a/db/schema.ts b/db/schema.ts index 157bbdf..50aaa33 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -23,6 +23,7 @@ import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/type import { EnvParadigms } from "@/src/dict/env-dict"; import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict"; import { CustomToolKinds, CustomToolStatuses } from "@/src/dict/custom-tool-dict"; +import { TaskAuthors, TaskMediaKinds, TaskOrigins, TaskStatuses } from "@/src/dict/task-dict"; const schema: BUN_SQLITE_DatabaseSchemaType = { tables: [ @@ -603,6 +604,194 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, ], }, + { + tableName: "tasks", + tableDescription: `The crew's to-do list: work that is tracked until it's done, one collection per bot and one per workspace (exactly one of \`bot_id\` and \`workspace_id\` is set). A workspace's tasks are visible to every bot working there and to the coordinator; the coordinator reaches every task. Not the same thing as \`bot_tasks\` or \`bots_queue\`: those record a queued job, this records the work`, + fields: [ + { + fieldName: "bot_id", + fieldDescription: `The bot the task belongs to; null for a workspace's task`, + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "bots", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "tasks_bot_fk", + }, + }, + { + fieldName: "workspace_id", + fieldDescription: `The workspace the task belongs to (its bots all see it); null for a bot's own`, + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "workspaces", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "tasks_workspace_fk", + }, + }, + { + fieldName: "title", + dataType: "TEXT", + }, + { + fieldName: "description", + fieldDescription: `The brief (markdown): what to do, where, and how you'll know it's done`, + dataType: "TEXT", + }, + { + fieldName: "status", + dataType: "TEXT", + options: TaskStatuses.map((ts) => ts.value), + defaultValue: "todo", + }, + { + fieldName: "blocked_reason", + fieldDescription: `What the user has to do to unblock it; only meaningful while \`status\` is blocked`, + dataType: "TEXT", + }, + { + fieldName: "origin", + fieldDescription: `Whether the user added it by hand or a bot created it`, + dataType: "TEXT", + options: TaskOrigins.map((to) => to.value), + defaultValue: "user", + }, + { + fieldName: "created_by_bot_id", + fieldDescription: `The bot that created it, when a bot did. Not a foreign key: the task stays if the bot goes`, + dataType: "INTEGER", + }, + { + fieldName: "created_by_user_id", + fieldDescription: `The user who created it by hand, or whose run created it. Not a foreign key`, + dataType: "INTEGER", + }, + { + fieldName: "completed_at", + fieldDescription: `Unix ms when it was done or cancelled; null while it's open`, + dataType: "INTEGER", + }, + ], + indexes: [ + { + indexName: "idx_tasks_bot_id_status", + indexTableFields: ["bot_id", "status"], + }, + { + indexName: "idx_tasks_workspace_id_status", + indexTableFields: ["workspace_id", "status"], + }, + ], + }, + { + tableName: "task_media", + tableDescription: `A file linked to a task: the deliverable it produced, or something it needs to do the work. The bytes are a private \`media\` row (paradigm \`task-media\`), served at \`/media/private/\` to whoever reaches the task`, + fields: [ + { + fieldName: "task_id", + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "tasks", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "task_media_task_fk", + }, + }, + { + fieldName: "media_id", + fieldDescription: `The private \`media\` row holding the bytes`, + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "media", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "task_media_media_fk", + }, + }, + { + fieldName: "bot_file_id", + fieldDescription: `The \`bot_files\` row the bytes came from, when they live in a bot's or workspace's folder. Not a foreign key: the file's own cleanup handles its bytes`, + dataType: "INTEGER", + }, + { + fieldName: "kind", + fieldDescription: `What the file is for: a deliverable the task produced, or a reference it works from`, + dataType: "TEXT", + options: TaskMediaKinds.map((tmk) => tmk.value), + defaultValue: "deliverable", + }, + { + fieldName: "name", + fieldDescription: `The name shown to the user`, + dataType: "TEXT", + }, + { + fieldName: "added_by_bot_id", + fieldDescription: `The bot that linked it, when a bot did. Not a foreign key`, + dataType: "INTEGER", + }, + ], + uniqueConstraints: [ + { + alias: "one_link_per_task_media", + constraintName: "A file is linked to a task once", + constraintTableFields: [{ value: "task_id" }, { value: "media_id" }], + }, + ], + indexes: [ + { + indexName: "idx_task_media_task_id", + indexTableFields: ["task_id"], + }, + { + indexName: "idx_task_media_media_id", + indexTableFields: ["media_id"], + }, + ], + }, + { + tableName: "task_notes", + tableDescription: `A task's progress log: what the user or a bot did or found, oldest first. A note is how a bot keeps the user up to date between status changes`, + fields: [ + { + fieldName: "task_id", + dataType: "INTEGER", + foreignKey: { + cascadeDelete: true, + destinationTableName: "tasks", + destinationTableColumnName: "id", + destinationTableColumnType: "INTEGER", + foreignKeyName: "task_note_task_fk", + }, + }, + { + fieldName: "author", + dataType: "TEXT", + options: TaskAuthors.map((ta) => ta.value), + defaultValue: "bot", + }, + { + fieldName: "bot_id", + fieldDescription: `The bot that wrote it, when a bot did. Not a foreign key`, + dataType: "INTEGER", + }, + { + fieldName: "text", + dataType: "TEXT", + }, + ], + indexes: [ + { + indexName: "idx_task_notes_task_id", + indexTableFields: ["task_id"], + }, + ], + }, { tableName: "bots_queue", isQueue: { diff --git a/db/types/db.ts b/db/types/db.ts index 3eeec58..4ba3b53 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -11,6 +11,9 @@ export const BunSQLiteTables = [ "bot_message_reactions", "bot_tasks", "bot_task_steps", + "tasks", + "task_media", + "task_notes", "bots_queue", "bots_queue_updates", "cron_jobs", @@ -161,7 +164,7 @@ export type BUN_SQLITE_COBBLE_MEDIA_PARADIGMS = { */ updated_at?: number | ""; media_id?: number | ""; - media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "bot-image" | "workspace-image" | "bot-file" | ""; + media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "bot-image" | "workspace-image" | "bot-file" | "task-media" | ""; } export type BUN_SQLITE_COBBLE_BOTS = { @@ -375,6 +378,113 @@ export type BUN_SQLITE_COBBLE_BOT_TASK_STEPS = { position?: number | ""; } +export type BUN_SQLITE_COBBLE_TASKS = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + /** + * The bot the task belongs to; null for a workspace's task + */ + bot_id?: number | ""; + /** + * The workspace the task belongs to (its bots all see it); null for a bot's own + */ + workspace_id?: number | ""; + title?: string; + /** + * The brief (markdown): what to do, where, and how you'll know it's done + */ + description?: string; + status?: "todo" | "doing" | "blocked" | "done" | "cancelled" | ""; + /** + * What the user has to do to unblock it; only meaningful while `status` is blocked + */ + blocked_reason?: string; + /** + * Whether the user added it by hand or a bot created it + */ + origin?: "user" | "bot" | ""; + /** + * The bot that created it, when a bot did. Not a foreign key: the task stays if the bot goes + */ + created_by_bot_id?: number | ""; + /** + * The user who created it by hand, or whose run created it. Not a foreign key + */ + created_by_user_id?: number | ""; + /** + * Unix ms when it was done or cancelled; null while it's open + */ + completed_at?: number | ""; +} + +export type BUN_SQLITE_COBBLE_TASK_MEDIA = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + task_id?: number | ""; + /** + * The private `media` row holding the bytes + */ + media_id?: number | ""; + /** + * The `bot_files` row the bytes came from, when they live in a bot's or workspace's folder. Not a foreign key: the file's own cleanup handles its bytes + */ + bot_file_id?: number | ""; + /** + * What the file is for: a deliverable the task produced, or a reference it works from + */ + kind?: "deliverable" | "reference" | ""; + /** + * The name shown to the user + */ + name?: string; + /** + * The bot that linked it, when a bot did. Not a foreign key + */ + added_by_bot_id?: number | ""; +} + +export type BUN_SQLITE_COBBLE_TASK_NOTES = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + task_id?: number | ""; + author?: "user" | "bot" | ""; + /** + * The bot that wrote it, when a bot did. Not a foreign key + */ + bot_id?: number | ""; + text?: string; +} + export type BUN_SQLITE_COBBLE_BOTS_QUEUE = { /** * The unique identifier of the record. @@ -1599,4 +1709,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = { value?: string; } -export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS \ No newline at end of file +export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS \ No newline at end of file diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index c3bd978..4c4b951 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -6,6 +6,8 @@ import { ChatReplyNote, CoordinatorReplyNote, CoordinatorSoloNote, + TasksNote, + TaskReminderNote, WorkspaceLearningNote, } from "@/src/data/harness-prompts"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; @@ -126,6 +128,8 @@ export default async function runBotReply({ carry_over, ChatReplyNote, BotFilesNote, + TasksNote, + TaskReminderNote, ActionCheckNote, workspace_id ? WorkspaceLearningNote : "", composeComputersNote({ diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index 90659e4..b4f5a36 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -3,6 +3,7 @@ import { ActionCheckNote, BotFilesNote, ScheduledRunNote, + TasksNote, TaskRunNote, WorkspaceLearningNote, } from "@/src/data/harness-prompts"; @@ -104,7 +105,14 @@ export default async function runBotTask({ ]; const tools = [ - ...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers, session_id }), + ...(await grabBotTools({ + user_id, + bot_id: bot.id, + workspace_id, + computers, + session_id, + is_coordinator: bot.kind == "coordinator", + })), ...(bot.kind == "coordinator" ? [ createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }), @@ -155,6 +163,7 @@ export default async function runBotTask({ TaskRunNote, scheduled ? ScheduledRunNote : "", BotFilesNote, + TasksNote, ActionCheckNote, workspace_id ? WorkspaceLearningNote : "", bot.kind == "coordinator" diff --git a/src/components/general/task-detail-panel.tsx b/src/components/general/task-detail-panel.tsx new file mode 100644 index 0000000..3fd543c --- /dev/null +++ b/src/components/general/task-detail-panel.tsx @@ -0,0 +1,266 @@ +import { useRef, useState } from "react"; +import Button from "@moduletrace/twui/layout/Button"; +import Row from "@moduletrace/twui/layout/Row"; +import Span from "@moduletrace/twui/layout/Span"; +import Stack from "@moduletrace/twui/layout/Stack"; +import Textarea from "@moduletrace/twui/form/Textarea"; +import type { TaskDetail, TaskFile } from "@/src/types"; +import TaskFileRow from "@/src/components/general/task-file-row"; +import TaskForm from "@/src/components/general/task-form"; +import TaskStatusChip from "@/src/components/general/task-status-chip"; +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; + +type Props = { + task: TaskDetail; + /** + * A crew manager can change it, file onto it and delete it. + */ + can_manage?: boolean; + onChanged: (task: TaskDetail, msg?: string) => void; + onDeleted: () => void; +}; + +/** + * One task opened: what it is, its notes oldest first, its files (each + * opening its bytes), a box to add a note, and edit or delete. A crew + * manager can file a new deliverable straight onto it. + */ +export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) { + const [editing, setEditing] = useState(false); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(); + const file_input = useRef(null); + + const run = async (params: { path: string; body: Record; done: string }) => { + if (busy) return; + + setBusy(true); + setStatus(undefined); + + const res = await fetchApi, APIResponseObject>( + `/api/admin/${params.path}`, + { method: "POST", body: params.body }, + ); + + setBusy(false); + + if (!res.success || !res.singleRes) { + setStatus(res.msg || "That didn't work."); + return; + } + + setStatus(params.done); + onChanged(res.singleRes, params.done); + }; + + const addNote = async () => { + if (!note.trim()) return; + + await run({ path: "tasks/add-note", body: { task_id: Number(task.id), text: note }, done: "Note added." }); + setNote(""); + }; + + const attach = async (file: File) => { + if (busy) return; + + const data_url = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }).catch(() => ""); + + if (!data_url) { + setStatus(`${file.name} couldn't be read.`); + return; + } + + await run({ + path: "tasks/attach-file", + body: { task_id: Number(task.id), data_url, name: file.name, kind: "deliverable" }, + done: `${file.name} added as a deliverable.`, + }); + }; + + const detach = async ({ file }: { file: TaskFile }) => { + if (!window.confirm(`Unlink ${file.name || "this file"} from the task?`)) return; + + await run({ + path: "tasks/detach-file", + body: { task_id: Number(task.id), task_media_id: Number(file.id) }, + done: "File unlinked.", + }); + }; + + if (editing) { + return ( + setEditing(false)} + onSaved={(saved, msg) => { + setEditing(false); + onChanged(saved, msg); + }} + /> + ); + } + + const deliverables = task.files.filter((file) => file.kind != "reference"); + const references = task.files.filter((file) => file.kind == "reference"); + + return ( + + + + + {task.workspace_id + ? task.workspace_name || `Workspace ${task.workspace_id}` + : task.bot_name || `Bot ${task.bot_id}`} + + {can_manage ? ( + + + + + ) : null} + + + {task.status == "blocked" && task.blocked_reason ? ( + {`Waiting on you: ${task.blocked_reason}`} + ) : null} + + {task.description ? ( + {task.description} + ) : null} + + + Files + {deliverables.length || references.length ? ( + + {[...deliverables, ...references].map((file) => ( + detach({ file }) : undefined} + /> + ))} + + ) : ( + Nothing filed yet. + )} + {can_manage ? ( + + { + const file = event.target.files?.[0]; + if (file) attach(file).catch(() => {}); + event.target.value = ""; + }} + /> + + + ) : null} + + + + Notes + {task.notes.length ? ( + task.notes.map((entry) => ( + + + {entry.author == "user" ? "You" : "Bot"} + + + {entry.text} + + + )) + ) : ( + No notes yet. + )} +