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
+2 -1
View File
@@ -118,7 +118,8 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - 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 <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. - **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 <bot>", 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`). - **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. - **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/<id>` 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 ### Installer and release
+4 -4
View File
@@ -45,7 +45,7 @@ your data on your own machine.
## Features ## Features
| Area | What you get | | 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 | | 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 | | 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 | | Chat | Markdown with @-mentions for bots and #-mentions for workspaces, replies and quotes, reactions, attachments, older messages on scroll, drafts kept locally |
@@ -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 - Linux amd64 or arm64, with Docker. The installer installs Docker from
get.docker.com when it is missing. 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. Each computer is its own container with its own memory limit.
- Docker Desktop on macOS works for development and small installs, but - Docker Desktop on macOS works for development and small installs, but
Linux is the supported host. Linux is the supported host.
@@ -141,7 +141,7 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu
### Options ### Options
| Option | Meaning | | Option | Meaning |
| --------------------- | -------------------------------------------------------------- | | --------------------- | ---------------------------------------------------------- |
| `--home DIR` | Install directory, default `/opt/cobble` | | `--home DIR` | Install directory, default `/opt/cobble` |
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` | | `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
| `--port PORT` | Host port, default `7227` | | `--port PORT` | Host port, default `7227` |
@@ -252,7 +252,7 @@ the app from a phone, run a proxy or tunnel in front of it (see
context. context.
| Command | Purpose | | Command | Purpose |
| --------------------- | ------------------------------------ | | ---------------------- | ----------------------------------- |
| `bun run dev` | Development server | | `bun run dev` | Development server |
| `bun run start` | Production server on the host | | `bun run start` | Production server on the host |
| `bun run typecheck` | Type-check | | `bun run typecheck` | Type-check |
+189
View File
@@ -23,6 +23,7 @@ import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/type
import { EnvParadigms } from "@/src/dict/env-dict"; import { EnvParadigms } from "@/src/dict/env-dict";
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict"; import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
import { CustomToolKinds, CustomToolStatuses } from "@/src/dict/custom-tool-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 = { const schema: BUN_SQLITE_DatabaseSchemaType = {
tables: [ 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/<id>\` 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", tableName: "bots_queue",
isQueue: { isQueue: {
+112 -2
View File
@@ -11,6 +11,9 @@ export const BunSQLiteTables = [
"bot_message_reactions", "bot_message_reactions",
"bot_tasks", "bot_tasks",
"bot_task_steps", "bot_task_steps",
"tasks",
"task_media",
"task_notes",
"bots_queue", "bots_queue",
"bots_queue_updates", "bots_queue_updates",
"cron_jobs", "cron_jobs",
@@ -161,7 +164,7 @@ export type BUN_SQLITE_COBBLE_MEDIA_PARADIGMS = {
*/ */
updated_at?: number | ""; updated_at?: number | "";
media_id?: 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 = { export type BUN_SQLITE_COBBLE_BOTS = {
@@ -375,6 +378,113 @@ export type BUN_SQLITE_COBBLE_BOT_TASK_STEPS = {
position?: number | ""; 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 = { export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1599,4 +1709,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string; 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 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
+4
View File
@@ -6,6 +6,8 @@ import {
ChatReplyNote, ChatReplyNote,
CoordinatorReplyNote, CoordinatorReplyNote,
CoordinatorSoloNote, CoordinatorSoloNote,
TasksNote,
TaskReminderNote,
WorkspaceLearningNote, WorkspaceLearningNote,
} from "@/src/data/harness-prompts"; } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
@@ -126,6 +128,8 @@ export default async function runBotReply({
carry_over, carry_over,
ChatReplyNote, ChatReplyNote,
BotFilesNote, BotFilesNote,
TasksNote,
TaskReminderNote,
ActionCheckNote, ActionCheckNote,
workspace_id ? WorkspaceLearningNote : "", workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({ composeComputersNote({
+10 -1
View File
@@ -3,6 +3,7 @@ import {
ActionCheckNote, ActionCheckNote,
BotFilesNote, BotFilesNote,
ScheduledRunNote, ScheduledRunNote,
TasksNote,
TaskRunNote, TaskRunNote,
WorkspaceLearningNote, WorkspaceLearningNote,
} from "@/src/data/harness-prompts"; } from "@/src/data/harness-prompts";
@@ -104,7 +105,14 @@ export default async function runBotTask({
]; ];
const tools = [ 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" ...(bot.kind == "coordinator"
? [ ? [
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }), createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
@@ -155,6 +163,7 @@ export default async function runBotTask({
TaskRunNote, TaskRunNote,
scheduled ? ScheduledRunNote : "", scheduled ? ScheduledRunNote : "",
BotFilesNote, BotFilesNote,
TasksNote,
ActionCheckNote, ActionCheckNote,
workspace_id ? WorkspaceLearningNote : "", workspace_id ? WorkspaceLearningNote : "",
bot.kind == "coordinator" bot.kind == "coordinator"
@@ -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<string>();
const file_input = useRef<HTMLInputElement>(null);
const run = async (params: { path: string; body: Record<string, unknown>; done: string }) => {
if (busy) return;
setBusy(true);
setStatus(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
`/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<string>((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 (
<TaskForm
task={task}
onCancel={() => 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 (
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<TaskStatusChip status={task.status} />
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-secondary truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span>
{can_manage ? (
<Row className="shrink-0 gap-1! flex-nowrap">
<Button
title="Edit the task"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => setEditing(true)}
className="px-2! text-[12px]"
>
Edit
</Button>
<Button
title="Delete the task"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={async () => {
if (!window.confirm(`Delete "${task.title}"? Its notes and links go with it.`)) return;
setBusy(true);
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
"/api/admin/tasks/delete-task",
{ method: "POST", body: { task_id: Number(task.id) } },
);
setBusy(false);
if (!res.success) {
setStatus(res.msg || "That didn't work.");
return;
}
onDeleted();
}}
className="px-2! text-[12px]"
>
Delete
</Button>
</Row>
) : null}
</Row>
{task.status == "blocked" && task.blocked_reason ? (
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
) : null}
{task.description ? (
<Span className="whitespace-pre-wrap text-[13px] leading-[19px] text-ink">{task.description}</Span>
) : null}
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span>
{deliverables.length || references.length ? (
<Stack className="w-full gap-0! items-stretch">
{[...deliverables, ...references].map((file) => (
<TaskFileRow
key={file.id}
file={file}
busy={busy}
onDetach={can_manage ? () => detach({ file }) : undefined}
/>
))}
</Stack>
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">Nothing filed yet.</Span>
)}
{can_manage ? (
<Row className="gap-2! flex-nowrap items-center">
<input
ref={file_input}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) attach(file).catch(() => {});
event.target.value = "";
}}
/>
<Button
title="Add a file as a deliverable"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => file_input.current?.click()}
className="px-2! text-[12px]"
>
Add a file
</Button>
</Row>
) : null}
</Stack>
<Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span>
{task.notes.length ? (
task.notes.map((entry) => (
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start">
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary">
{entry.author == "user" ? "You" : "Bot"}
</Span>
<Span className="grow min-w-0 whitespace-pre-wrap text-[13px] leading-[18px] text-ink">
{entry.text}
</Span>
</Row>
))
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
)}
<Textarea
placeholder="Add a note"
value={note}
changeHandler={setNote}
rows={2}
disabled={busy}
/>
<Row className="justify-end">
<Button
title="Add the note"
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={busy || !note.trim()}
onClick={() => addNote().catch(() => {})}
>
Add note
</Button>
</Row>
</Stack>
{status ? (
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</Stack>
);
}
+75
View File
@@ -0,0 +1,75 @@
import Row from "@moduletrace/twui/layout/Row";
import Link from "@moduletrace/twui/layout/Link";
import { FileText, Image as ImageIcon } from "lucide-react";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskFile } from "@/src/types";
type Props = {
file: TaskFile;
/**
* A crew manager can unlink it.
*/
onDetach?: () => void;
busy?: boolean;
};
/**
* One file on a task: its name, opening the bytes at
* `/media/private/<id>`, and its `[file:ID]` chip when it lives in a bot's
* or workspace's folder (so the file's real home is reachable too).
*/
export default function TaskFileRow({ file, onDetach, busy }: Props) {
if (file.missing) {
return (
<Row className="w-full min-h-10 gap-2! flex-nowrap items-center px-1 py-1">
<FileText size={16} className="shrink-0 text-ink-disabled" aria-hidden />
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-disabled truncate">
{file.name || `File ${file.media_id}`} (gone)
</Span>
</Row>
);
}
return (
<Row className="w-full min-h-10 gap-2! flex-nowrap items-center px-1 py-1">
{file.media_type == "image" ? (
<ImageIcon size={16} className="shrink-0 text-ink-secondary" aria-hidden />
) : (
<FileText size={16} className="shrink-0 text-ink-secondary" aria-hidden />
)}
<Link
href={`/media/private/${file.media_id}`}
target="_blank"
rel="noreferrer"
title={file.name || "Open the file"}
className="grow min-w-0 text-[13px] leading-[18px] text-ink truncate hover:underline"
>
{file.name || `File ${file.media_id}`}
</Link>
{file.kind == "reference" ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">reference</Span>
) : null}
{file.bot_file_id ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{`[file:${file.bot_file_id}]`}
</Span>
) : null}
{onDetach ? (
<Span
role="button"
tabIndex={0}
aria-disabled={busy}
title="Unlink this file"
onClick={() => (busy ? undefined : onDetach())}
onKeyDown={(event) => {
if (busy) return;
if (event.key == "Enter" || event.key == " ") onDetach();
}}
className="shrink-0 px-1 text-[11px] leading-4 text-ink-secondary cursor-pointer hover:text-ink"
>
Unlink
</Span>
) : null}
</Row>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { TaskDetail } from "@/src/types";
type Props = {
/**
* The list it's added to: a bot's own, or a workspace's.
*/
bot_id?: number | null;
workspace_id?: number | null;
/**
* Edits this task; adds one when not given.
*/
task?: TaskDetail | null;
onSaved: (task: TaskDetail, msg: string) => void;
onCancel: () => void;
};
/**
* The one task form: a title, the brief, the status and, while it's
* blocked, what the user has to do. The workspace's tasks group and the
* conversation's tasks modal both add and edit with it.
*/
export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel }: Props) {
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo");
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
if (!title.trim()) {
setError("A task needs a title.");
return;
}
setLoading(true);
setError(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{
method: "POST",
body: {
task_id: task?.id ? Number(task.id) : undefined,
bot_id: task ? undefined : bot_id || undefined,
workspace_id: task ? undefined : workspace_id || undefined,
title,
description,
status,
blocked_reason: status == "blocked" ? blocked_reason || null : null,
},
},
);
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the task.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<Form onSubmit={submit} className="gap-3!">
<Stack className="gap-3! items-stretch">
<Input
label="Title"
value={title}
changeHandler={setTitle}
placeholder="What needs doing?"
maxLength={AppData.TaskTitleMaxLength}
disabled={loading}
/>
<Textarea
label="Brief"
value={description}
changeHandler={setDescription}
placeholder="What to do, where, and how you'll know it's done."
maxLength={AppData.TaskDescriptionMaxLength}
rows={5}
disabled={loading}
/>
<Select
label="Status"
showLabel
options={TaskStatuses.map((it) => ({
title: it.title,
value: it.value,
default: it.value == status,
}))}
changeHandler={(value) => setStatus(String(value) as TaskStatus)}
/>
{status == "blocked" ? (
<Textarea
label="Waiting on the user for"
value={blocked_reason}
changeHandler={setBlockedReason}
placeholder="What the user has to do before this can go on."
maxLength={AppData.TaskNoteMaxLength}
rows={2}
disabled={loading}
/>
) : null}
<AddFormActions
submit_label={task ? "Save" : "Add task"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Stack>
</Form>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useContext } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import type { TaskWithNames } from "@/src/types";
import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time";
import TaskStatusChip from "./task-status-chip";
type Props = {
task: TaskWithNames;
/**
* Opens the task to read its notes and files, and to edit it.
*/
onOpen?: () => void;
/**
* Moves it to one status; only crew managers get the controls.
*/
onStatusChange?: (status: string) => void;
busy?: boolean;
};
/**
* One task: whose it is (a bot's stone), its title and status, the
* workspace, what it's blocked on, its latest note and how long ago it
* moved.
*/
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
const { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled";
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
return (
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
<BotAvatar
image_media_id={task.bot_image_media_id}
hue={(task.bot_kind == "coordinator" ? null : task.bot_hue || null) as BotHue | null}
face={(task.bot_face || "idle") as BotFace}
size={28}
/>
<Stack className="grow min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Button
title={task.title || "Open the task"}
type="button"
variant="ghost"
color="text"
onClick={onOpen}
className={`grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline ${
done ? "text-ink-secondary! line-through" : "text-ink!"
}`}
>
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button>
<TaskStatusChip status={task.status} />
</Row>
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span>
{task.status == "blocked" && task.blocked_reason ? (
<Span className="min-w-0 text-xs leading-4 text-ink truncate">
{`Waiting on you: ${task.blocked_reason}`}
</Span>
) : null}
{task.updated_at ? (
<Span className="ml-auto shrink-0 text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(task.updated_at) })}
</Span>
) : null}
</Row>
{task.latest_note ? (
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
) : null}
{can_manage && onStatusChange ? (
<Row className="gap-1! flex-nowrap items-center pt-0.5">
{moves
.filter((it) => it.value != task.status)
.map((it) => (
<Button
key={it.value}
title={`Mark it ${it.title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => onStatusChange(it.value)}
className="px-1.5! text-[12px]"
>
{it.title}
</Button>
))}
</Row>
) : null}
</Stack>
</Row>
);
}
@@ -0,0 +1,29 @@
import Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
type Props = {
status?: TaskStatus | string | null;
};
/**
* A task's status as one word. The tone follows the state: a blocked task
* is the only one that asks for the user, so it's the only one that reads
* as a call.
*/
export default function TaskStatusChip({ status = "todo" }: Props) {
const value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone =
value == "blocked"
? "text-ink border-ink"
: value == "doing"
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
return (
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
{title}
</Tag>
);
}
+16
View File
@@ -194,6 +194,22 @@ export const AppData = {
* Due schedules queued per tick of the `run-cron-jobs` cron. * Due schedules queued per tick of the `run-cron-jobs` cron.
*/ */
CronJobBatchSize: 50, CronJobBatchSize: 50,
/**
* Tasks (the crew's to-do list, `tasks`): the longest title, brief and
* note, how many one bot or workspace can hold, and how many files one
* task carries.
*/
TaskTitleMaxLength: 120,
TaskDescriptionMaxLength: 8000,
TaskNoteMaxLength: 2000,
TaskMaxPerScope: 200,
TaskMediaMaxPerTask: 20,
/**
* Open tasks a chat reply carries as a digest for its reminder, and how
* many `list_tasks` hands back at once.
*/
TaskReminderDigestCount: 3,
TaskListToolLimit: 20,
/** /**
* Automatic model picks (`rankAutoModels`). A free model from outside * Automatic model picks (`rankAutoModels`). A free model from outside
* `AutoModelVendorPreference` must have at least this much context, and * `AutoModelVendorPreference` must have at least this much context, and
+26
View File
@@ -1,3 +1,4 @@
import { AppData } from "@/src/data/app-data";
import botRef from "@/src/utils/bot-ref"; import botRef from "@/src/utils/bot-ref";
/** /**
@@ -83,6 +84,31 @@ export const TaskRunNote = `# Working on a task
You are working through a task on your own; the user isn't watching. Use your tools until the task is done, then answer with the report for the user: what you did, what you found, and anything left for them. End it with the next step you'd suggest, as a short question the user can say yes to. Keep the report short and in markdown. If you're blocked on something only the user can give you, stop and ask for it in the report. If they need to do it on the computer themselves (log in, a captcha, a code), call ask_user_to_take_over first.`; You are working through a task on your own; the user isn't watching. Use your tools until the task is done, then answer with the report for the user: what you did, what you found, and anything left for them. End it with the next step you'd suggest, as a short question the user can say yes to. Keep the report short and in markdown. If you're blocked on something only the user can give you, stop and ask for it in the report. If they need to do it on the computer themselves (log in, a captcha, a code), call ask_user_to_take_over first.`;
/**
* Added to every run: the shared to-do list, and how a bot keeps it
* honest while it works.
*/
export const TasksNote = `# Tasks
Tasks are the crew's shared to-do list, one per bot and one per workspace. A workspace's tasks are seen by every bot working there, and the coordinator sees everything.
- list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked.
- create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now.
- update_task moves it along. Set doing when you start, blocked with a reason when only the user can unblock it, and done only once the work is verified (see the check note above). Add a note as you go: it's what the user reads on the task, so one short line per real step, not a play-by-play.
- A deliverable is a file: save it first with write_file, make_pdf or generate_image, then pass its [file:ID] ref in deliverable_file_ids, so the task carries what it produced.`;
/**
* Added to a chat reply: when and how to bring pending tasks up, so the bot
* reminds the user without nagging.
*/
export const TaskReminderNote = `# Mentioning pending tasks
You have tasks of your own to keep up with. When the message is an opening ("hello", "what's up"), or after you've answered and propose what's next, call list_tasks and look at what's open.
- If something is blocked on the user, or something open plainly connects to what they're asking, mention it in one short line with its status, at most ${AppData.TaskReminderDigestCount} tasks: "Still open: #12 send the signed form (blocked: you need to sign it), #14 recheck the invoices."
- Don't list everything, don't repeat the same reminder reply after reply, and skip it when the user is mid-task, winding down, or you've just asked them a question. A greeting is a good moment; every greeting is not.
- Never invent a task: name only what list_tasks returned.`;
/** /**
* Added after `TaskRunNote` when the task is a scheduled run (a routine): * Added after `TaskRunNote` when the task is a scheduled run (a routine):
* the user approved its brief when they set it up, so it acts on its own. * the user approved its brief when they set it up, so it acts on its own.
+4
View File
@@ -46,4 +46,8 @@ export const MediaParadigms = [
title: "Bot File", title: "Bot File",
value: "bot-file", value: "bot-file",
}, },
{
title: "Task Media",
value: "task-media",
},
] as const; ] as const;
+95
View File
@@ -0,0 +1,95 @@
/**
* A task's status. Tasks are the crew's shared to-do list, one per bot and
* one per workspace, and every move through this list is a step in the
* work: `blocked` is what a bot (or the user) sets when only the user can
* unblock it, and `done` is only ever set once the work was verified.
*/
export const TaskStatuses = [
{
title: "To do",
value: "todo",
},
{
title: "Doing",
value: "doing",
},
{
title: "Blocked",
value: "blocked",
},
{
title: "Done",
value: "done",
},
{
title: "Cancelled",
value: "cancelled",
},
] as const;
export type TaskStatus = (typeof TaskStatuses)[number]["value"];
/**
* The statuses a task still needs work in: what a reminder may name and
* what a digest shows.
*/
export const OpenTaskStatuses = ["todo", "doing", "blocked"] as const;
/**
* Every status as a zod-friendly tuple of its values.
*/
export const TaskStatusValues = TaskStatuses.map((status) => status.value) as unknown as [
TaskStatus,
...TaskStatus[],
];
export type OpenTaskStatus = (typeof OpenTaskStatuses)[number];
/**
* Who created a task: the user by hand, or a bot with its tool.
*/
export const TaskOrigins = [
{
title: "User",
value: "user",
},
{
title: "Bot",
value: "bot",
},
] as const;
export type TaskOrigin = (typeof TaskOrigins)[number]["value"];
/**
* Who wrote a note on a task. The same values as a message's authors.
*/
export const TaskAuthors = [
{
title: "User",
value: "user",
},
{
title: "Bot",
value: "bot",
},
] as const;
export type TaskAuthor = (typeof TaskAuthors)[number]["value"];
/**
* What a file linked to a task is for: the thing the task produced
* (`deliverable`), or something it needs to do the work (`reference`).
*/
export const TaskMediaKinds = [
{
title: "Deliverable",
value: "deliverable",
},
{
title: "Reference",
value: "reference",
},
] as const;
export type TaskMediaKind = (typeof TaskMediaKinds)[number]["value"];
+12 -1
View File
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media"; import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage"; import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot"; import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager"; import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
@@ -29,11 +30,13 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
} }
/** /**
* Read before the delete: the files' rows go with the bot. * Read before the delete: the files' rows go with the bot, and so do its
* tasks and the links to what they filed.
*/ */
const file_media_ids = await grabStorageMediaIds({ scope: "bot", owner_id: bot.id! }).catch( const file_media_ids = await grabStorageMediaIds({ scope: "bot", owner_id: bot.id! }).catch(
() => [], () => [],
); );
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] }); const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
@@ -45,6 +48,14 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {}); await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
} }
/**
* What the bot's tasks filed and no folder owns (a chat upload): the
* links went with the tasks, so these bytes would be stranded.
*/
await deleteMedia({ ids: task_media_ids, user_id: Number(user_id), can_delete_all_media: true }).catch(
() => {},
);
/** /**
* Its folder goes with it: the bytes, their `media` rows and the search * Its folder goes with it: the bytes, their `media` rows and the search
* vectors. * vectors.
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { TaskAuthor } from "@/src/dict/task-dict";
import AppError from "@/src/utils/app-error";
import grabTaskRow from "./grab-task-scope";
type Params = {
task_id: number | string;
text: string;
author?: TaskAuthor;
/**
* The bot writing it, when a bot does.
*/
bot_id?: number | string | null;
};
/**
* Adds a line to a task's progress log. The caller checked the run or user
* reaches the task. Returns the note read back, so `ActionCheckNote`'s
* verify step has something real.
*/
export default async function addTaskNote({
task_id,
text,
author = "bot",
bot_id,
}: Params): Promise<BUN_SQLITE_COBBLE_TASK_NOTES> {
const task = await grabTaskRow({ task_id });
const clean = String(text).trim().slice(0, AppData.TaskNoteMaxLength);
if (!clean) {
throw new AppError(`A note needs some text.`);
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_TASK_NOTES, (typeof BunSQLiteTables)[number]>({
table: "task_notes",
data: [{ task_id: Number(task.id), author, bot_id: bot_id ? Number(bot_id) : undefined, text: clean }],
});
const note_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !note_id) {
throw new Error(`Couldn't save the note: ${res.msg}`);
}
const saved = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
sql: `SELECT * FROM task_notes WHERE id = ? LIMIT 1`,
values: [note_id],
})
).payload?.[0];
if (!saved) {
throw new Error(`The note didn't save.`);
}
return saved;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
task_id: number | string;
};
/**
* The task, if the user reaches its bot or workspace: the one check every
* user-facing task endpoint makes before reading or changing it. Throws
* "Task not found" rather than saying a task exists where it doesn't, so
* an unreachable id reads the same as a missing one.
*/
export default async function assertUserReachesTask({
user_id,
task_id,
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
values: [Number(task_id), ...access.values],
});
const task = res.payload?.[0];
if (!task?.id) {
throw new AppError(`Task not found.`);
}
return task;
}
@@ -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,
};
}
@@ -0,0 +1,87 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS, BunSQLiteTables } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { TaskStatus } from "@/src/dict/task-dict";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import grabTask from "./grab-task";
import taskScopeValues from "./task-scope-values";
type Params = {
/**
* The user whose run or request creates it.
*/
user_id: number | string;
bot_id?: number | string | null;
workspace_id?: number | string | null;
title: string;
description?: string;
status?: TaskStatus;
/**
* The bot creating it with its tool; none for a user's manual add.
*/
created_by_bot_id?: number | string | null;
};
/**
* Adds a task to a bot's own list or a workspace's shared one. A user adds
* one to a bot they chat with or a workspace they're in; a bot's run adds
* one to its own list or to a workspace it works in (its tool checks
* that). Caps a collection at `TaskMaxPerScope` open and finished tasks.
*/
export default async function createTask({
user_id,
bot_id,
workspace_id,
title,
description,
status,
created_by_bot_id,
}: Params): Promise<TaskDetail> {
const scope = taskScopeValues({ bot_id, workspace_id });
const role = await grabCrewRole({ user_id });
if (!created_by_bot_id && role == "member") {
throw new AppError(`Only an admin can add a task.`);
}
const count = Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM tasks WHERE ${
scope.bot_id ? `bot_id = ?` : `workspace_id = ?`
}`,
values: [Number(scope.bot_id || scope.workspace_id)],
})
).payload?.[0]?.n || 0,
);
if (count >= AppData.TaskMaxPerScope) {
throw new AppError(`That list is full (${AppData.TaskMaxPerScope} tasks). Finish or delete one first.`);
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_TASKS, (typeof BunSQLiteTables)[number]>({
table: "tasks",
data: [
{
bot_id: scope.bot_id || undefined,
workspace_id: scope.workspace_id || undefined,
title: title.trim().slice(0, AppData.TaskTitleMaxLength),
description: description || undefined,
status: status || "todo",
origin: created_by_bot_id ? "bot" : "user",
created_by_bot_id: created_by_bot_id ? Number(created_by_bot_id) : undefined,
created_by_user_id: Number(user_id),
},
],
});
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !task_id) {
throw new Error(`Couldn't save the task: ${res.msg}`);
}
return await grabTask({ user_id, task_id });
}
@@ -0,0 +1,56 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import deleteMedia from "../media/delete-media";
import grabTaskRow from "./grab-task-scope";
type Params = {
user_id: number | string;
task_id: number | string;
/**
* A bot's run deleting its task; none for the user. Either way, the
* caller checked the run reaches it.
*/
deleted_by_bot_id?: number | string | null;
};
/**
* Deletes a task with its notes and file links (cascades). The files
* themselves stay where they are: a `task_media` link is not the bytes'
* owner, so a bot file's folder keeps its file and only the link goes.
*/
export default async function deleteTask({ user_id, task_id, deleted_by_bot_id }: Params) {
const task = await grabTaskRow({ task_id });
const role = await grabCrewRole({ user_id });
if (!deleted_by_bot_id && role == "member") {
throw new AppError(`Only an admin can delete a task.`);
}
/**
* Read before the delete: the links go with the task, and the media
* rows a chat upload left are deleted with them (a bot file's bytes
* belong to its folder, not to the task).
*/
const links = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE task_id = ?`,
values: [Number(task_id)],
})
).payload || [];
const res = await BunSQLite.sql({ sql: `DELETE FROM tasks WHERE id = ?`, values: [Number(task_id)] });
if (!res.success) {
throw new AppError(`Couldn't delete the task: ${res.msg || res.error}`);
}
const uploads = links.filter((link) => !link.bot_file_id).map((link) => Number(link.media_id));
if (uploads.length) {
await deleteMedia({ ids: uploads, user_id, can_delete_all_media: true }).catch(() => {});
}
return task;
}
@@ -0,0 +1,70 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import deleteMedia from "../media/delete-media";
type Params = {
task_id: number | string;
/**
* The link's own id, or the media it points at.
*/
task_media_id?: number | string | null;
media_id?: number | string | null;
user_id: number | string;
};
/**
* Unlinks one file from a task. A chat upload that no other task or
* message uses has its bytes deleted with it; a bot file keeps its bytes
* in its folder, which is the file's real home.
*/
export default async function detachTaskFile({
task_id,
task_media_id,
media_id,
user_id,
}: Params) {
const link = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE task_id = ? AND ${
task_media_id ? "id = ?" : "media_id = ?"
} LIMIT 1`,
values: [Number(task_id), Number(task_media_id || media_id)],
})
).payload?.[0];
if (!link?.id) {
throw new AppError(`That file isn't on this task.`);
}
const res = await BunSQLite.sql({
sql: `DELETE FROM task_media WHERE id = ?`,
values: [Number(link.id)],
});
if (!res.success) {
throw new AppError(`Couldn't unlink the file: ${res.msg || res.error}`);
}
if (!link.bot_file_id && link.media_id) {
const still_used = (
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM task_media WHERE media_id = ?`,
values: [Number(link.media_id)],
})
).payload?.[0]?.n;
const in_messages = (
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM bot_message_media WHERE media_id = ?`,
values: [Number(link.media_id)],
})
).payload?.[0]?.n;
if (!Number(still_used || 0) && !Number(in_messages || 0)) {
await deleteMedia({ id: link.media_id, user_id, can_delete_all_media: true }).catch(() => {});
}
}
return link;
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import findDuplicateTask from "./find-duplicate-task";
function task(fields: Partial<BUN_SQLITE_COBBLE_TASKS>): BUN_SQLITE_COBBLE_TASKS {
return { id: 1, title: "Check the invoices", status: "todo", ...fields };
}
describe("findDuplicateTask", () => {
test("finds the open task with the same title in the same bot's list", () => {
const tasks = [task({ id: 3, bot_id: 2, title: " check the INVOICES " })];
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })?.id).toBe(3);
});
test("a finished or cancelled task is never a duplicate", () => {
const tasks = [
task({ id: 1, bot_id: 2, status: "done" }),
task({ id: 2, bot_id: 2, status: "cancelled" }),
];
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })).toBeNull();
});
test("keeps bot lists and workspace lists apart", () => {
const tasks = [task({ id: 5, bot_id: 2 }), task({ id: 6, workspace_id: 9 })];
expect(findDuplicateTask({ tasks, title: "Check the invoices", workspace_id: 9 })?.id).toBe(6);
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 2 })?.id).toBe(5);
expect(findDuplicateTask({ tasks, title: "Check the invoices", bot_id: 7 })).toBeNull();
});
});
@@ -0,0 +1,33 @@
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
type Params = {
tasks: BUN_SQLITE_COBBLE_TASKS[];
title: string;
/**
* Same collection: a bot's own list, or a workspace's.
*/
bot_id?: number | null;
workspace_id?: number | null;
};
/**
* A task in the same collection with the same title that's still open, so
* `ActionCheckNote` gets real behaviour: a bot updates the existing task
* instead of adding a second one for the same work.
*/
export default function findDuplicateTask({ tasks, title, bot_id, workspace_id }: Params) {
const wanted = title.trim().toLowerCase();
return (
tasks.find((task) => {
if (task.status == "done" || task.status == "cancelled") return false;
if (workspace_id) {
if (Number(task.workspace_id || 0) != Number(workspace_id)) return false;
} else if (Number(task.bot_id || 0) != Number(bot_id || 0)) {
return false;
}
return (task.title || "").trim().toLowerCase() == wanted;
}) || null
);
}
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import botTaskScopesSql from "@/src/utils/task-access/bot-task-scopes-sql";
type Params = {
task_id: number | string;
/**
* The bot whose run is asking.
*/
bot_id: number | string;
is_coordinator: boolean;
};
/**
* The task, if the bot's run reaches it: its own, a workspace it works in,
* or anything at all for the coordinator. The gate every task tool makes
* before reading or changing one, so a tool argument can never widen a
* run's reach.
*/
export default async function grabRunTask({
task_id,
bot_id,
is_coordinator,
}: Params): Promise<BUN_SQLITE_COBBLE_TASKS> {
const access = botTaskScopesSql({ bot_id, is_coordinator });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT t.* FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
values: [Number(task_id), ...access.values],
});
const task = res.payload?.[0];
if (!task?.id) {
throw new AppError(`Task #${task_id} isn't one you can work on.`);
}
return task;
}
@@ -0,0 +1,52 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_TASK_MEDIA,
} from "@/db/types/db";
import grabCrewRole from "../access/grab-crew-role";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
type Params = {
media_id: number | string;
user_id: number | string;
};
/**
* A task file's bytes (its private `media` row) for a user who reaches the
* task's bot or workspace, the same rule the task list uses. Task media
* belongs to the crew, like the crew itself, so whoever sees the task sees
* what it produced.
*/
export default async function grabTaskMediaFile({
media_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_MEDIA | null> {
const link = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT * FROM task_media WHERE media_id = ? LIMIT 1`,
values: [Number(media_id)],
})
).payload?.[0];
if (!link?.task_id) return null;
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
const task = (
await BunSQLite.sql<{ id: number }>({
sql: `SELECT t.id FROM tasks t WHERE t.id = ? AND ${access.sql} LIMIT 1`,
values: [Number(link.task_id), ...access.values],
})
).payload?.[0];
if (!task?.id) return null;
const media = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
sql: `SELECT * FROM media WHERE id = ? LIMIT 1`,
values: [Number(media_id)],
})
).payload?.[0];
return media || null;
}
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TaskFile } from "@/src/types";
type Params = {
task_id: number | string;
};
/**
* A task's files with what a row shows: the `media` row's type, mime and
* size, and whether that row is still there (a failed cleanup or a
* deleted file leaves the link behind, listed as missing rather than
* hiding it).
*/
export default async function grabTaskMedia({ task_id }: Params): Promise<TaskFile[]> {
const res = await BunSQLite.sql<TaskFile>({
sql: `SELECT tm.*, m.media_type, m.mime_type, m.title AS media_title
FROM task_media tm
LEFT JOIN media m ON m.id = tm.media_id
WHERE tm.task_id = ?
ORDER BY tm.kind ASC, tm.id ASC`,
values: [Number(task_id)],
});
return (res.payload || []).map((file) => ({
...file,
name: file.name || file.media_title || `File ${file.media_id}`,
missing: !file.media_type,
}));
}
@@ -0,0 +1,80 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_TASKS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import type { TaskScope } from "@/src/utils/task-access/types";
/**
* Reads a task's row as it is, without an access check: for the helpers
* that already checked who is asking. Throws when it's gone.
*/
export default async function grabTaskRow({ task_id }: { task_id: number | string }) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT * FROM tasks WHERE id = ? LIMIT 1`,
values: [Number(task_id)],
});
const task = res.payload?.[0];
if (!task?.id) {
throw new AppError(`Task not found.`);
}
return task;
}
/**
* A task's collection (`bot_id` or `workspace_id`), for the access checks
* that follow it.
*/
export function taskScopeOf({ task }: { task: BUN_SQLITE_COBBLE_TASKS }): TaskScope {
return {
bot_id: task.bot_id ? Number(task.bot_id) : null,
workspace_id: task.workspace_id ? Number(task.workspace_id) : null,
};
}
/**
* Whether a task is still open (anything but done or cancelled).
*/
export function isOpenTask({ status }: { status?: string | null }) {
return status != "done" && status != "cancelled";
}
export type TaskScopeTarget =
| { kind: "bot"; bot: BUN_SQLITE_COBBLE_BOTS }
| { kind: "workspace"; workspace: BUN_SQLITE_COBBLE_WORKSPACES };
/**
* The bot or workspace a task collection names, read as it is. Throws when
* it's gone, so a create never points at nothing.
*/
export async function grabTaskTarget(params: {
bot_id?: number | null;
workspace_id?: number | null;
}): Promise<TaskScopeTarget> {
if (params.bot_id) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`,
values: [Number(params.bot_id)],
});
const bot = res.payload?.[0];
if (!bot?.id) throw new AppError(`Bot not found.`);
return { kind: "bot", bot };
}
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? LIMIT 1`,
values: [Number(params.workspace_id)],
});
const workspace = res.payload?.[0];
if (!workspace?.id) throw new AppError(`Workspace not found.`);
return { kind: "workspace", workspace };
}
@@ -0,0 +1,53 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import botFaceSql from "@/src/utils/bot-face-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
import grabCrewRole from "../access/grab-crew-role";
import grabTaskMedia from "./grab-task-media";
type Params = {
/**
* The user asking: they must reach the task's bot or workspace.
*/
user_id: number | string;
task_id: number | string;
};
/**
* One task with its notes (oldest first) and files, only if the user
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same
* condition the lists use).
*/
export default async function grabTask({ user_id, task_id }: Params): Promise<TaskDetail> {
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
const face = botFaceSql({ user_id, alias: "b", prefix: "bot_" });
const row_res = await BunSQLite.sql<TaskDetail>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id
WHERE t.id = ? AND ${access.sql}
LIMIT 1`,
values: [...face.values, Number(task_id), ...access.values],
});
const row = row_res.payload?.[0];
if (!row) {
throw new AppError(`Task not found.`);
}
const [notes_res, files] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`,
values: [Number(task_id)],
}),
grabTaskMedia({ task_id }),
]);
return { ...row, notes: notes_res.payload || [], files };
}
@@ -0,0 +1,101 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import { OpenTaskStatuses } from "@/src/dict/task-dict";
import type { TaskWithNames } from "@/src/types";
import botFaceSql from "@/src/utils/bot-face-sql";
import botTaskScopesSql from "@/src/utils/task-access/bot-task-scopes-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
import type { SqlCondition } from "@/src/utils/task-access/types";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
/**
* A user's own view (`taskScopesSql`): their bots and workspaces.
*/
user_id?: number | string;
/**
* Or a bot run's view (`botTaskScopesSql`): its own and its
* workspaces', or every task for the coordinator.
*/
bot_id?: number | string;
is_coordinator?: boolean;
/**
* Only one collection.
*/
only_bot_id?: number | string | null;
only_workspace_id?: number | string | null;
/**
* Only these statuses; none for every one.
*/
statuses?: string[];
/**
* Only the open ones (todo, doing, blocked).
*/
open_only?: boolean;
limit?: number;
};
/**
* Tasks with what a list shows beside them: the bot (its face as this user
* sees it), the workspace and the newest note. Open ones first, blocked
* before doing before to do, then the oldest.
*/
export default async function grabTasks({
user_id,
bot_id,
is_coordinator,
only_bot_id,
only_workspace_id,
statuses,
open_only,
limit,
}: Params): Promise<TaskWithNames[]> {
const where: string[] = [];
const values: (string | number)[] = [];
if (user_id != null) {
const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id });
where.push(access.sql);
values.push(...access.values);
} else if (bot_id != null) {
const access = botTaskScopesSql({ bot_id, is_coordinator: Boolean(is_coordinator) });
where.push(access.sql);
values.push(...access.values);
}
if (only_bot_id) {
where.push(`t.bot_id = ?`);
values.push(Number(only_bot_id));
}
if (only_workspace_id) {
where.push(`t.workspace_id = ?`);
values.push(Number(only_workspace_id));
}
const wanted = open_only ? [...OpenTaskStatuses] : statuses?.length ? statuses : null;
if (wanted) {
where.push(`t.status IN (${wanted.map(() => "?").join(", ")})`);
values.push(...wanted);
}
const face = botFaceSql({ user_id: user_id || 0, alias: "b", prefix: "bot_" });
const res = await BunSQLite.sql<TaskWithNames>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
(SELECT n.text FROM task_notes n WHERE n.task_id = t.id ORDER BY n.id DESC LIMIT 1) AS latest_note,
(SELECT COUNT(*) FROM task_media tm WHERE tm.task_id = t.id) AS media_count
FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'doing' THEN 1 WHEN 'todo' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
t.updated_at DESC, t.id DESC
${limit ? `LIMIT ${Number(limit)}` : ""}`,
values: [...face.values, ...values],
});
return res.payload || [];
}
@@ -0,0 +1,34 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_MEDIA } from "@/db/types/db";
type Params = {
/**
* A bot's own tasks, or a workspace's.
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
/**
* The bytes a bot's or workspace's tasks filed that no folder owns (a chat
* upload linked to a task). Read before the bot or workspace is deleted:
* the tasks and their links cascade, and nothing else would ever clean
* those files up. A bot file is left alone: its folder is its home.
*/
export async function grabTaskMediaIds({ bot_id, workspace_id }: Params): Promise<number[]> {
const column = workspace_id ? "workspace_id" : "bot_id";
const owner = workspace_id || bot_id;
if (!owner) return [];
const links = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_MEDIA>({
sql: `SELECT tm.media_id FROM task_media tm
JOIN tasks t ON t.id = tm.task_id
WHERE t.${column} = ? AND tm.bot_file_id IS NULL`,
values: [Number(owner)],
})
).payload || [];
return links.map((link) => Number(link.media_id)).filter(Boolean);
}
@@ -0,0 +1,27 @@
import AppError from "@/src/utils/app-error";
type Params = {
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
/**
* A task belongs to exactly one collection: a bot's own list, or a
* workspace's shared one. Checked in the helpers rather than the schema,
* since SQLite treats NULL in a unique constraint as always distinct (the
* `envs` precedent).
*/
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
const bot = bot_id ? Number(bot_id) : null;
const workspace = workspace_id ? Number(workspace_id) : null;
if (bot && workspace) {
throw new AppError(`A task belongs to a bot or a workspace, not both.`);
}
if (!bot && !workspace) {
throw new AppError(`Say which bot or workspace the task is for.`);
}
return { bot_id: bot, workspace_id: workspace };
}
@@ -0,0 +1,95 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { TaskStatus } from "@/src/dict/task-dict";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
import grabTask from "./grab-task";
import grabTaskRow, { isOpenTask } from "./grab-task-scope";
type Params = {
user_id: number | string;
task_id: number | string;
title?: string;
description?: string;
status?: TaskStatus;
/**
* What the user has to do to unblock it. Cleared when the status leaves
* `blocked`.
*/
blocked_reason?: string | null;
/**
* The bot's run changing it; none for the user. Either way, the caller
* checked the run or user reaches it.
*/
updated_by_bot_id?: number | string | null;
};
/**
* Changes a task's title, brief or status. Moving it to done or cancelled
* stamps `completed_at`; moving it back to an open status clears it.
* `blocked_reason` only survives while the task is blocked.
*/
export default async function updateTask({
user_id,
task_id,
title,
description,
status,
blocked_reason,
updated_by_bot_id,
}: Params): Promise<TaskDetail> {
/**
* Tasks are crew structure like bots and workspaces: an admin changes
* them by hand, a bot's run changes its own.
*/
if (!updated_by_bot_id && (await grabCrewRole({ user_id })) == "member") {
throw new AppError(`Only an admin can change a task.`);
}
const task = await grabTaskRow({ task_id });
const fields: string[] = [];
const values: (string | number | null)[] = [];
if (title != null) {
fields.push(`title = ?`);
values.push(String(title).trim().slice(0, AppData.TaskTitleMaxLength));
}
if (description != null) {
fields.push(`description = ?`);
values.push(String(description).slice(0, AppData.TaskDescriptionMaxLength));
}
if (status != null) {
fields.push(`status = ?`);
values.push(status);
fields.push(`completed_at = ?`);
values.push(isOpenTask({ status }) ? null : Date.now());
if (status != "blocked") {
fields.push(`blocked_reason = ?`);
values.push(null);
}
}
if (blocked_reason !== undefined && (status ?? task.status) == "blocked") {
fields.push(`blocked_reason = ?`);
values.push(blocked_reason ? String(blocked_reason).slice(0, AppData.TaskNoteMaxLength) : null);
}
if (!fields.length) {
return await grabTask({ user_id, task_id });
}
const res = await BunSQLite.sql({
sql: `UPDATE tasks SET ${fields.join(", ")} WHERE id = ?`,
values: [...values, Number(task_id)],
});
if (!res.success) {
throw new AppError(`Couldn't save the task: ${res.msg || res.error}`);
}
return await grabTask({ user_id, task_id });
}
@@ -1,5 +1,6 @@
import deleteMedia from "@/src/functions/backend/db/media/delete-media"; import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage"; import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace"; import grabUserWorkspace from "./grab-user-workspace";
@@ -30,6 +31,10 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
scope: "workspace", scope: "workspace",
owner_id: workspace.id!, owner_id: workspace.id!,
}).catch(() => []); }).catch(() => []);
/**
* And what its tasks filed outside a folder.
*/
const task_media_ids = await grabTaskMediaIds({ workspace_id: workspace.id! }).catch(() => []);
const res = await BunSQLite.sql({ const res = await BunSQLite.sql({
sql: `DELETE FROM workspaces WHERE id = ?`, sql: `DELETE FROM workspaces WHERE id = ?`,
@@ -44,6 +49,13 @@ export default async function deleteWorkspace({ user_id, workspace_id }: Params)
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {}); await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
} }
/**
* What the workspace's tasks filed and no folder owns (a chat upload).
*/
await deleteMedia({ ids: task_media_ids, user_id: Number(user_id), can_delete_all_media: true }).catch(
() => {},
);
/** /**
* The bots' shared folder there goes with it. * The bots' shared folder there goes with it.
*/ */
@@ -6,6 +6,7 @@ import path from "node:path";
import userAuth from "../../backend/auth/user-auth"; import userAuth from "../../backend/auth/user-auth";
import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media"; import grabSharedPictureMedia from "@/src/functions/backend/db/access/grab-shared-picture-media";
import grabBotFileMedia from "@/src/functions/backend/db/bot-files/grab-bot-file-media"; import grabBotFileMedia from "@/src/functions/backend/db/bot-files/grab-bot-file-media";
import grabTaskMediaFile from "@/src/functions/backend/db/tasks/grab-task-media-file";
const { DATA_DIR } = grabDirNames(); const { DATA_DIR } = grabDirNames();
@@ -54,6 +55,7 @@ export default async function handleMediaServer({
media_res.singleRes || media_res.singleRes ||
(media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) || (media_id ? await grabSharedPictureMedia({ media_id, user_id: user.id! }) : null) ||
(media_id ? await grabBotFileMedia({ media_id, user_id: user.id! }) : null) || (media_id ? await grabBotFileMedia({ media_id, user_id: user.id! }) : null) ||
(media_id ? await grabTaskMediaFile({ media_id, user_id: user.id! }) : null) ||
undefined; undefined;
} else { } else {
const media_res = await BunSQLite.select< const media_res = await BunSQLite.select<
+8 -1
View File
@@ -74,7 +74,14 @@ export default async function grabBotChatTools({
}: Params): Promise<HarnessTool[]> { }: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0; const bot_id = bot.id || 0;
const tools = [ const tools = [
...grabBotTools({ user_id, bot_id, workspace_id, computers, session_id }), ...(await grabBotTools({
user_id,
bot_id,
workspace_id,
computers,
session_id,
is_coordinator: bot.kind == "coordinator",
})),
startTaskTool({ startTaskTool({
user_id, user_id,
bot_id, bot_id,
+15 -2
View File
@@ -1,5 +1,6 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { HarnessTool } from "../../types"; import type { HarnessTool } from "../../types";
import grabBotTaskTools from "./tasks/grab-bot-task-tools";
import createScheduleTool from "./create-schedule-tool"; import createScheduleTool from "./create-schedule-tool";
import deleteScheduleTool from "./delete-schedule-tool"; import deleteScheduleTool from "./delete-schedule-tool";
import listSchedulesTool from "./list-schedules-tool"; import listSchedulesTool from "./list-schedules-tool";
@@ -25,6 +26,11 @@ type Params = {
* The session being answered, so the bot can react to its messages. * The session being answered, so the bot can react to its messages.
*/ */
session_id?: number | string; session_id?: number | string;
/**
* Whether the run is the coordinator, which reaches every task and can
* add to any bot's or workspace's list.
*/
is_coordinator?: boolean;
}; };
/** /**
@@ -32,13 +38,14 @@ type Params = {
* works: its memory and instructions, its schedules, and in a workspace * works: its memory and instructions, its schedules, and in a workspace
* with several computers switching the default one. * with several computers switching the default one.
*/ */
export default function grabBotTools({ export default async function grabBotTools({
user_id, user_id,
bot_id, bot_id,
workspace_id, workspace_id,
computers = [], computers = [],
session_id, session_id,
}: Params): HarnessTool[] { is_coordinator,
}: Params): Promise<HarnessTool[]> {
return [ return [
updateMemoryTool({ user_id, bot_id, workspace_id }), updateMemoryTool({ user_id, bot_id, workspace_id }),
updateInstructionsTool({ user_id, bot_id, workspace_id }), updateInstructionsTool({ user_id, bot_id, workspace_id }),
@@ -50,5 +57,11 @@ export default function grabBotTools({
...(workspace_id && computers.length > 1 ...(workspace_id && computers.length > 1
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })] ? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
: []), : []),
...(await grabBotTaskTools({
user_id,
bot_id,
is_coordinator: Boolean(is_coordinator),
workspace_id,
})),
]; ];
} }
@@ -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.`;
},
};
}
@@ -0,0 +1,61 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import grabCrewWorkers from "@/src/functions/backend/db/bots/grab-crew-workers";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabStorageScopes from "@/src/utils/storage-access";
import type { HarnessTool } from "../../../types";
import createTaskTool from "./create-task-tool";
import listTasksTool from "./list-tasks-tool";
import updateTaskTool from "./update-task-tool";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* Whether the run is the coordinator, which reaches every task, can add
* to any list and reaches every folder.
*/
is_coordinator: boolean;
workspace_id?: number | string | null;
};
/**
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the
* shared to-do list, one per bot and one per workspace, so a chat reply, a
* task and a scheduled run all read it, move it along and file what they
* produced. The coordinator's create_task may target any worker or
* workspace; its update_task may link any file it can reach.
*/
export default async function grabBotTaskTools({
user_id,
bot_id,
is_coordinator,
workspace_id,
}: Params): Promise<HarnessTool[]> {
let workers: BUN_SQLITE_COBBLE_BOTS[] = [];
let workspaces: BUN_SQLITE_COBBLE_WORKSPACES[] = [];
let all_workspace_ids: number[] = [];
if (is_coordinator) {
workers = await grabCrewWorkers({ workspace_id });
workspaces = (await grabUserWorkspaces({ user_id })).filter((workspace) => !workspace.archived);
all_workspace_ids = workspaces.map((workspace) => Number(workspace.id));
}
/**
* `grabStorageScopes` wants a numeric bot id.
*/
const numeric_bot_id = Number(bot_id);
const scopes = grabStorageScopes({
bot_id: numeric_bot_id,
bot_kind: is_coordinator ? "coordinator" : "worker",
workspace_id: workspace_id ? Number(workspace_id) : null,
all_workspace_ids,
});
return [
listTasksTool({ user_id, bot_id, is_coordinator, workspace_id }),
createTaskTool({ user_id, bot_id, workspace_id, is_coordinator, workers, workspaces }),
updateTaskTool({ user_id, bot_id, is_coordinator, scopes }),
];
}
@@ -0,0 +1,114 @@
import { z } from "zod";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { 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 grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
import type { HarnessTool } from "../../../types";
type Params = {
bot_id: number | string;
user_id: number | string;
is_coordinator: boolean;
/**
* The workspace the run is in, if any.
*/
workspace_id?: number | string | null;
};
type Args = {
scope?: "mine" | "workspace" | "all";
status?: TaskStatus;
open_only?: boolean;
};
/**
* Lets a bot read the tasks it can work on: its own list and every
* workspace it works in, or for the coordinator the whole install. Used
* both to plan work and to remind the user of what's pending.
*/
export default function listTasksTool({ bot_id, user_id, is_coordinator, workspace_id }: Params): HarnessTool<Args> {
return {
name: "list_tasks",
description: `List tasks: yours, the ones in a workspace you work in, or ${is_coordinator ? "every task in the install" : "every task you can reach"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
schema: z.object({
scope: z
.enum(["mine", "workspace", "all"])
.optional()
.describe(`"mine" for your own list, "workspace" for ${workspace_id ? "this workspace's" : "a workspace's (none open)"}, "all" for everything you reach. Defaults to "all".`),
status: z.enum(TaskStatusValues).optional(),
open_only: z
.boolean()
.optional()
.describe(`Only the open ones (to do, doing, blocked). Defaults to false.`),
}),
run: async ({ args }) => {
const scope = args.scope || "all";
const only_workspace_id =
scope == "workspace" && workspace_id ? Number(workspace_id) : undefined;
const tasks = await grabTasks({
bot_id,
is_coordinator,
only_bot_id: scope == "mine" ? Number(bot_id) : undefined,
only_workspace_id,
statuses: args.status ? [args.status] : undefined,
open_only: args.open_only || false,
limit: AppData.TaskListToolLimit,
});
if (!tasks.length) {
return args.open_only
? `You have no open tasks${args.status ? ` with that status` : ""}.`
: `No tasks${args.status ? ` with that status` : ""}.`;
}
const names = await grabWorkspaceNames({
workspace_ids: tasks.map((task) => Number(task.workspace_id || 0)).filter(Boolean),
});
return tasks
.map((task) => {
const where = task.workspace_id
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}`
: `on ${task.bot_name || `bot ${task.bot_id}`}`;
const bits = [
`#${task.id}`,
`[${task.status}]`,
task.title,
`(${where})`,
Number(task.media_count || 0) ? `${task.media_count} file(s)` : "",
task.status == "blocked" && task.blocked_reason ? `blocked: ${task.blocked_reason}` : "",
task.latest_note ? `latest: ${oneLine({ text: task.latest_note })}` : "",
];
return `- ${bits.filter(Boolean).join(" ")}`;
})
.join("\n");
},
};
}
/**
* A note or reason on one line, so a list stays a list.
*/
function oneLine({ text }: { text: string }) {
return String(text).replace(/\s+/g, " ").trim().slice(0, 160);
}
async function grabWorkspaceNames({ workspace_ids }: { workspace_ids: number[] }) {
const names = new Map<number, string>();
if (!workspace_ids.length) return names;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT id, name FROM workspaces WHERE id IN (${workspace_ids.map(() => "?").join(", ")})`,
values: workspace_ids,
});
for (const workspace of res.payload || []) {
if (workspace.id) names.set(Number(workspace.id), workspace.name || `workspace ${workspace.id}`);
}
return names;
}
@@ -0,0 +1,120 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
import updateTask from "@/src/functions/backend/db/tasks/update-task";
import type { StorageScope } from "@/src/utils/storage-access";
import type { HarnessTool } from "../../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
is_coordinator: boolean;
/**
* The run's folders, for linking a deliverable by its `[file:ID]` ref.
*/
scopes?: StorageScope[];
};
type Args = {
task_id: number;
status?: TaskStatus;
blocked_reason?: string;
note?: string;
deliverable_file_ids?: number[];
reference_file_ids?: number[];
};
/**
* Lets a bot move a task along: set its status, say what's blocking it, log
* a progress note, and file the deliverables it produced. One call does all
* of them, so a step in the work is a step in the log.
*/
export default function updateTaskTool({ user_id, bot_id, is_coordinator, scopes = [] }: Params): HarnessTool<Args> {
return {
name: "update_task",
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
schema: z.object({
task_id: z.number().int(),
status: z.enum(TaskStatusValues).optional(),
blocked_reason: z
.string()
.max(AppData.TaskNoteMaxLength)
.optional()
.describe(`What the user has to do to unblock it; set with status blocked`),
note: z
.string()
.max(AppData.TaskNoteMaxLength)
.optional()
.describe(`A line of progress for the task's log`),
deliverable_file_ids: z
.array(z.number().int())
.optional()
.describe(`The [file:ID] refs of what the task produced`),
reference_file_ids: z
.array(z.number().int())
.optional()
.describe(`The [file:ID] refs of files it works from`),
}),
run: async ({ args }) => {
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator });
const said: string[] = [];
if (args.status || args.blocked_reason !== undefined) {
if (args.status == "blocked" && !args.blocked_reason?.trim()) {
return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
}
await updateTask({
user_id,
task_id: task.id!,
status: args.status as never,
blocked_reason: args.blocked_reason ?? undefined,
updated_by_bot_id: bot_id,
});
if (args.status) said.push(`status is now ${args.status}`);
}
if (args.note?.trim()) {
await addTaskNote({ task_id: task.id!, text: args.note, author: "bot", bot_id });
said.push(`note logged`);
}
for (const [kind, ids] of [
["deliverable", args.deliverable_file_ids],
["reference", args.reference_file_ids],
] as const) {
for (const file_id of ids || []) {
try {
const { file, existed } = await attachTaskFile({
task_id: task.id!,
file_id,
user_id,
scopes,
kind,
added_by_bot_id: bot_id,
});
said.push(`${existed ? "already linked" : "linked"} ${kind} "${file.name}"`);
} catch (error: unknown) {
said.push(
`couldn't link file ${file_id}: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
}
}
if (!said.length) {
return `Nothing to change on #${task.id}.`;
}
const files = await grabTaskMedia({ task_id: task.id! });
return `Task #${task.id} "${task.title}": ${said.join(", ")}. It now carries ${files.length} file(s). Read it back with list_tasks before you tell the user.`;
},
};
}
@@ -0,0 +1,55 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { TaskWithNames } from "@/src/types";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Load only once the modal opens, so the conversation's page props stay
* small.
*/
enabled?: boolean;
};
/**
* A conversation's tasks: a bot's own list, or in a workspace the
* workspace's shared one. Loaded on open and refreshed after every change,
* never part of the page props.
*/
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
const [tasks, setTasks] = useState<TaskWithNames[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
const query = new URLSearchParams();
if (workspace_id) query.set("workspace_id", String(workspace_id));
else query.set("bot_id", String(bot_id));
const res = await fetchApi<{}, APIResponseObject<TaskWithNames>>(
`/api/admin/tasks/list-tasks?${query.toString()}`,
{ method: "GET" },
).catch(() => null);
setLoading(false);
if (!res?.success) {
setError(res?.msg || `Couldn't load the tasks.`);
return;
}
setTasks(res.payload || []);
}, [bot_id, workspace_id]);
useEffect(() => {
if (enabled) load().catch(() => {});
}, [enabled, load]);
return { tasks, setTasks, loading, error, reload: load };
}
@@ -0,0 +1,229 @@
import { useContext, useEffect, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { ListChecks } from "lucide-react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import useBotTasks from "../(hooks)/use-bot-tasks";
import HeaderMenuItem from "./header-menu-item";
type Props = {
bot: BotWithLastMessage;
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
/**
* A row in the conversation header's menu instead of an icon beside it.
*/
menu?: boolean;
on_open?: () => void;
};
/**
* The tasks this conversation works on: a bot's own list, or in a workspace
* the workspace's shared one. Open it from the header menu; the list loads
* then, so the page's props stay small. A crew manager adds, moves, edits
* and deletes; a task opens to show its notes and files.
*/
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const [adding, set_adding] = useState(false);
const [open_task, set_open_task] = useState<TaskDetail | null>(null);
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<string>();
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const { tasks, setTasks, loading, error, reload } = useBotTasks({
bot_id: Number(bot.id),
workspace_id: workspace?.id ? Number(workspace.id) : null,
enabled: open,
});
const name = bot.name || "This bot";
const title = workspace?.id ? `${workspace.name || "Workspace"} tasks` : `${name}'s tasks`;
useEffect(() => {
if (open) on_open?.();
}, [open]);
const replace = ({ task }: { task: TaskWithNames }) =>
setTasks((prev) => [task, ...prev.filter((item) => item.id != task.id)]);
const move = async ({ task, next }: { task: TaskWithNames; next: string }) => {
if (busy) return;
set_busy(true);
set_status(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{ method: "POST", body: { task_id: Number(task.id), status: next } },
);
set_busy(false);
if (!res.success || !res.singleRes) {
set_status(res.msg || "That didn't work.");
return;
}
replace({ task: res.singleRes });
};
const show = async ({ task }: { task: TaskWithNames }) => {
set_status(undefined);
/**
* The list already has the task; the detail endpoint serves its
* notes and files.
*/
const detail = await fetchApi<{}, APIResponseObject<TaskDetail>>(
`/api/admin/tasks/get-task?task_id=${task.id}`,
{ method: "GET" },
).catch(() => null);
if (!detail?.success || !detail.singleRes) {
set_status(detail?.msg || "Couldn't open the task.");
return;
}
set_open_task(detail.singleRes);
};
return (
<Modal
target={
menu ? (
<HeaderMenuItem
icon={ListChecks}
title="Tasks"
description={title}
loading={loading}
/>
) : (
<Button
title={title}
type="button"
variant="ghost"
color="text"
className="shrink-0 p-2! text-ink-secondary hover:text-ink hover:bg-sunken rounded-default"
>
<ListChecks size={18} aria-hidden />
</Button>
)
}
targetWrapperProps={{ className: menu ? "w-full" : "shrink-0" }}
open={open}
setOpen={(next) => {
setOpen(next);
if (!next) {
set_open_task(null);
set_adding(false);
}
}}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
{open_task ? open_task.title || "Task" : title}
</H2>
{open_task ? (
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => set_open_task(null)}
className="px-2! text-[12px]"
>
Back
</Button>
) : can_manage ? (
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => {
set_adding(true);
set_open_task(null);
}}
>
Add task
</Button>
) : null}
</Row>
{!open_task ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace?.id
? `The shared to-do list of ${workspace.name || "this workspace"}. Every bot here sees it, and they keep it up to date as they work.`
: `What ${name} is working through, and what it's waiting on.`}
</Span>
) : null}
</Stack>
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
{open_task ? (
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_open_task(saved);
replace({ task: saved });
set_status(msg);
}}
onDeleted={() => {
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
set_open_task(null);
set_status("Task deleted.");
}}
/>
) : adding ? (
<TaskForm
bot_id={workspace?.id ? null : Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null}
onCancel={() => set_adding(false)}
onSaved={(saved, msg) => {
replace({ task: saved });
set_adding(false);
set_status(msg);
}}
/>
) : tasks.length ? (
tasks.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
/>
))
) : (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{loading ? "Loading tasks..." : error || "No tasks yet."}
</Span>
)}
</Stack>
{status ? (
<Span role="status" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</Modal>
);
}
@@ -11,6 +11,7 @@ import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotComputersModal from "./bot-computers-modal"; import BotComputersModal from "./bot-computers-modal";
import BotFilesModal from "./bot-files-modal"; import BotFilesModal from "./bot-files-modal";
import BotTasksModal from "./bot-tasks-modal";
import BotModelsModal from "./bot-models-modal"; import BotModelsModal from "./bot-models-modal";
import HeaderMenuItem from "./header-menu-item"; import HeaderMenuItem from "./header-menu-item";
import NewSessionButton from "./new-session-button"; import NewSessionButton from "./new-session-button";
@@ -101,6 +102,12 @@ export default function ConversationHeaderMenu({
workspace={workspace} workspace={workspace}
on_open={on_open} on_open={on_open}
/> />
<BotTasksModal
menu
bot={bot}
workspace={workspace}
on_open={on_open}
/>
<BotComputersModal <BotComputersModal
menu menu
bot={bot} bot={bot}
@@ -0,0 +1,182 @@
import { useContext, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import type { TaskDetail, TaskWithNames } from "@/src/types";
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
import TaskForm from "@/src/components/general/task-form";
import TaskListItem from "@/src/components/general/task-list-item";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
type Props = {
workspace_id: number;
/**
* Its tasks, open first.
*/
tasks: TaskWithNames[];
};
/**
* A workspace's shared to-do list: every bot working there sees these, and
* the coordinator sees them all. Add and change one by hand here; the bots
* keep it up to date themselves as they work.
*/
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
const { pageProps } = useContext(AppContext);
const [items, setItems] = useState(tasks);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
const [adding, setAdding] = useState(false);
const [open_task, setOpenTask] = useState<TaskDetail | null>(null);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const replace = ({ task }: { task: TaskWithNames }) =>
setItems((prev) => [task, ...prev.filter((item) => item.id != task.id)].sort(order));
const move = async ({ task, next }: { task: TaskWithNames; next: string }) => {
if (busy) return;
setBusy(true);
setStatus(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
"/api/admin/tasks/save-task",
{ method: "POST", body: { task_id: Number(task.id), status: next } },
);
setBusy(false);
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ task: res.singleRes });
};
const show = async ({ task }: { task: TaskWithNames }) => {
setStatus(undefined);
const res = await fetchApi<{}, APIResponseObject<TaskDetail>>(
`/api/admin/tasks/get-task?task_id=${task.id}`,
{ method: "GET" },
).catch(() => null);
if (!res?.success || !res.singleRes) {
setStatus({ error: true, msg: res?.msg || "Couldn't open the task." });
return;
}
setOpenTask(res.singleRes);
};
return (
<SettingsGroup
id="tasks"
title="Tasks"
description="The workspace's shared to-do list. Every bot here sees it, and they keep it up to date as they work."
>
<Stack className="w-full gap-2! items-stretch">
{open_task ? (
<Stack className="w-full gap-3! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="grow min-w-0 text-sm font-bold leading-5 text-ink truncate">
{open_task.title || "Task"}
</Span>
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setOpenTask(null)}
className="px-2! text-[12px]"
>
Back
</Button>
</Row>
<TaskDetailPanel
task={open_task}
can_manage={can_manage}
onChanged={(saved, msg) => {
setOpenTask(saved);
replace({ task: saved });
setStatus(msg ? { success: true, msg } : undefined);
}}
onDeleted={() => {
setItems((prev) => prev.filter((item) => item.id != open_task.id));
setOpenTask(null);
setStatus({ success: true, msg: "Task deleted." });
}}
/>
</Stack>
) : (
<>
{can_manage ? (
<Row className="justify-end">
<Button
title="Add a task"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => setAdding(true)}
>
Add task
</Button>
</Row>
) : null}
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
{items.map((task) => (
<TaskListItem
key={task.id}
task={task}
busy={busy}
onOpen={() => show({ task }).catch(() => {})}
onStatusChange={(next) => move({ task, next })}
/>
))}
</SettingsList>
</>
)}
<SettingsStatus status={status} />
</Stack>
<AddModalShell
open={adding}
setOpen={setAdding}
title="Add a task"
description="It goes on the workspace's shared list, where every bot here can pick it up."
>
<TaskForm
workspace_id={workspace_id}
onCancel={() => setAdding(false)}
onSaved={(task, msg) => {
replace({ task });
setAdding(false);
setStatus({ success: true, msg });
}}
/>
</AddModalShell>
</SettingsGroup>
);
}
/**
* Blocked, then doing, then to do, then finished: the order a list reads
* in.
*/
function order(a: TaskWithNames, b: TaskWithNames) {
const rank = (task: TaskWithNames) =>
task.status == "blocked" ? 0 : task.status == "doing" ? 1 : task.status == "todo" ? 2 : 3;
const diff = rank(a) - rank(b);
return diff != 0 ? diff : Number(b.updated_at || b.id || 0) - Number(a.updated_at || a.id || 0);
}
@@ -8,6 +8,7 @@ import WorkspaceAboutGroup from "../(partials)/workspace-about-group";
import WorkspaceActivityGroup from "../(partials)/workspace-activity-group"; import WorkspaceActivityGroup from "../(partials)/workspace-activity-group";
import WorkspaceBotsListGroup from "../(partials)/workspace-bots-list-group"; import WorkspaceBotsListGroup from "../(partials)/workspace-bots-list-group";
import WorkspaceComputersListGroup from "../(partials)/workspace-computers-list-group"; import WorkspaceComputersListGroup from "../(partials)/workspace-computers-list-group";
import WorkspaceTasksGroup from "../(partials)/workspace-tasks-group";
/** /**
* A workspace's dashboard: a read-only summary of its picture and context, * A workspace's dashboard: a read-only summary of its picture and context,
@@ -38,6 +39,10 @@ export default function WorkspaceDashboard() {
} }
> >
<WorkspaceAboutGroup workspace={workspace} /> <WorkspaceAboutGroup workspace={workspace} />
<WorkspaceTasksGroup
workspace_id={workspace_id}
tasks={pageProps?.workspace_tasks || []}
/>
<WorkspaceActivityGroup <WorkspaceActivityGroup
workspace_id={workspace_id} workspace_id={workspace_id}
activity={pageProps?.workspace_activity || []} activity={pageProps?.workspace_activity || []}
@@ -3,6 +3,7 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity"; import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers"; import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
import { AdminHomePath } from "@/src/dict/admin-sections-dict"; import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type { PagePropsType, PageQueryObject } from "@/src/types"; import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -33,11 +34,12 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const workspace_id = Number(workspace.id); const workspace_id = Number(workspace.id);
const [workspace_bots, workspace_computers, workspace_activity] = const [workspace_bots, workspace_computers, workspace_activity, workspace_tasks] =
await Promise.all([ await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }), grabWorkspaceBots({ user_id: user.id, workspace_id }),
grabWorkspaceComputers({ workspace_id }), grabWorkspaceComputers({ workspace_id }),
grabWorkspaceActivity({ user_id: user.id, workspace_id }), grabWorkspaceActivity({ user_id: user.id, workspace_id }),
grabTasks({ user_id: user.id, only_workspace_id: workspace_id }),
]); ]);
const crew = railBots({ bots: workspace_bots }); const crew = railBots({ bots: workspace_bots });
@@ -50,6 +52,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace_bots: crew, workspace_bots: crew,
workspace_computers, workspace_computers,
workspace_activity, workspace_activity,
workspace_tasks,
}, },
}; };
}; };
+41
View File
@@ -0,0 +1,41 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import { TaskNoteRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Adds a note to a task's progress log and returns the task with it.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { task_id, text } = parseWithSchema({ schema: TaskNoteRequestSchema, data: body });
await assertUserReachesTask({ user_id: user.id, task_id });
await addTaskNote({ task_id, text, author: "user" });
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `Note added.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+103
View File
@@ -0,0 +1,103 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import { AppData } from "@/src/data/app-data";
import { TaskFileRequestSchema, UploadTaskFileRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import attachmentFileKind from "@/src/utils/attachment-file-kind";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Links a file to a task: one of the user's own uploads (`media_id`), or a
* new upload (`data_url` + `name`), saved as a private `media` row with
* the `task-media` paradigm. Returns the task with its files.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const data = (body || {}) as Record<string, unknown>;
if (data.data_url) {
const { task_id, data_url, name, kind } = parseWithSchema({
schema: UploadTaskFileRequestSchema,
data,
});
await assertUserReachesTask({ user_id: user.id, task_id });
const mime = data_url.match(/^data:([^;,]*)/)?.[1] || "";
const { media_type, ext } = attachmentFileKind({ mime, name });
const { buffer } = await bufferFromBase64(data_url);
if (!buffer.length) {
throw new AppError(`${name} is empty.`);
}
if (buffer.length > AppData.BotAttachmentMaxBytes) {
throw new AppError(
`${name} is too big. Files can be up to ${Math.round(AppData.BotAttachmentMaxBytes / 1024 / 1024)} MB.`,
);
}
const saved = await uploadAndRecordMedia({
data: buffer,
media_type,
media_mime_type: ext,
media_paradigms: ["task-media"],
media: { title: name },
extract_media_text_content: media_type == "document",
is_private: true,
user: { id: Number(user.id) },
});
if (!saved.singleRes?.id) {
throw new AppError(`Couldn't save ${name}.`);
}
await attachTaskFile({
task_id,
media_id: saved.singleRes.id,
user_id: user.id,
kind: kind as never,
});
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `File linked.` };
}
const { task_id, media_id, file_id, kind } = parseWithSchema({ schema: TaskFileRequestSchema, data });
await assertUserReachesTask({ user_id: user.id, task_id });
await attachTaskFile({
task_id,
media_id,
file_id,
user_id: user.id,
kind: kind as never,
});
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `File linked.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+40
View File
@@ -0,0 +1,40 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import deleteTask from "@/src/functions/backend/db/tasks/delete-task";
import { DeleteTaskRequestSchema } from "@/src/schemas/task-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Deletes one of the user's tasks with its notes and file links. The
* files themselves stay where they live.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { task_id } = parseWithSchema({ schema: DeleteTaskRequestSchema, data: body });
await assertUserReachesTask({ user_id: user.id, task_id });
await deleteTask({ user_id: user.id, task_id });
return { success: true, msg: `Task deleted.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+44
View File
@@ -0,0 +1,44 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import detachTaskFile from "@/src/functions/backend/db/tasks/detach-task-file";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import { DetachTaskFileRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Unlinks one file from a task and returns it with the rest.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { task_id, task_media_id, media_id } = parseWithSchema({
schema: DetachTaskFileRequestSchema,
data: body,
});
await assertUserReachesTask({ user_id: user.id, task_id });
await detachTaskFile({ task_id, task_media_id, media_id, user_id: user.id });
return { success: true, singleRes: await grabTask({ user_id: user.id, task_id }), msg: `File unlinked.` };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+36
View File
@@ -0,0 +1,36 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabTask from "@/src/functions/backend/db/tasks/grab-task";
import type { TaskDetail } from "@/src/types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* One task with its notes and files: what a row's modal loads when it
* opens, so the page props stay small.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const task_id = Number(new URL(req.url).searchParams.get("task_id"));
if (!task_id) {
return { success: false, msg: `No task given.` };
}
return {
success: true,
singleRes: await grabTask({ user_id: user.id, task_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+43
View File
@@ -0,0 +1,43 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabTasks from "@/src/functions/backend/db/tasks/grab-tasks";
import type { TaskWithNames } from "@/src/types";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's shared one
* (`?workspace_id=`), or every task they reach. `?status=` narrows to one
* status, `?open=1` to the open ones.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
try {
if (req.method != "GET") {
return { success: false, msg: `Method not allowed` };
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const url = new URL(req.url);
const bot_id = Number(url.searchParams.get("bot_id")) || undefined;
const workspace_id = Number(url.searchParams.get("workspace_id")) || undefined;
const status = url.searchParams.get("status") || undefined;
const open = url.searchParams.get("open") == "1";
return {
success: true,
payload: await grabTasks({
user_id: user.id,
only_bot_id: bot_id,
only_workspace_id: workspace_id,
statuses: status ? [status] : undefined,
open_only: open,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+78
View File
@@ -0,0 +1,78 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import assertUserReachesTask from "@/src/functions/backend/db/tasks/assert-user-reaches-task";
import createTask from "@/src/functions/backend/db/tasks/create-task";
import updateTask from "@/src/functions/backend/db/tasks/update-task";
import { CreateTaskRequestSchema, UpdateTaskRequestSchema } from "@/src/schemas/task-schema";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
/**
* Adds a task to a bot's list or a workspace's (`bot_id` or
* `workspace_id`), or changes one (`task_id`). Returns the task with its
* notes and files.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const data = (body || {}) as Record<string, unknown>;
if (data.task_id) {
const { task_id, title, description, status, blocked_reason } = parseWithSchema({
schema: UpdateTaskRequestSchema,
data,
});
await assertUserReachesTask({ user_id: user.id, task_id });
return {
success: true,
singleRes: await updateTask({
user_id: user.id,
task_id,
title,
description,
status: status as never,
blocked_reason,
}),
msg: `Saved.`,
};
}
const { bot_id, workspace_id, title, description, status } = parseWithSchema({
schema: CreateTaskRequestSchema,
data,
});
return {
success: true,
singleRes: await createTask({
user_id: user.id,
bot_id,
workspace_id,
title,
description,
status: status as never,
}),
msg: `Task added.`,
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import { TaskMediaKinds, TaskStatuses } from "@/src/dict/task-dict";
const status_values = TaskStatuses.map((status) => status.value) as [string, ...string[]];
const media_kind_values = TaskMediaKinds.map((kind) => kind.value) as [string, ...string[]];
/**
* One collection: a bot's own list or a workspace's shared one. The helper
* refuses both or neither (`taskScopeValues`).
*/
const scope = {
bot_id: z.number().int().positive().optional(),
workspace_id: z.number().int().positive().optional(),
};
export const CreateTaskRequestSchema = z.object({
...scope,
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength),
description: z.string().max(AppData.TaskDescriptionMaxLength).optional(),
status: z.enum(status_values).optional(),
});
export const UpdateTaskRequestSchema = z.object({
task_id: z.number().int().positive(),
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).optional(),
description: z.string().max(AppData.TaskDescriptionMaxLength).optional(),
status: z.enum(status_values).optional(),
blocked_reason: z.string().max(AppData.TaskNoteMaxLength).nullable().optional(),
});
export const DeleteTaskRequestSchema = z.object({
task_id: z.number().int().positive(),
});
export const TaskNoteRequestSchema = z.object({
task_id: z.number().int().positive(),
text: z.string().trim().min(1).max(AppData.TaskNoteMaxLength),
});
/**
* Linking a file: one from the run's folders (or the user's own uploads)
* or a fresh upload, already saved as a private `media` row.
*/
export const TaskFileRequestSchema = z.object({
task_id: z.number().int().positive(),
file_id: z.number().int().positive().optional(),
media_id: z.number().int().positive().optional(),
kind: z.enum(media_kind_values).optional(),
});
export const DetachTaskFileRequestSchema = z
.object({
task_id: z.number().int().positive(),
task_media_id: z.number().int().positive().optional(),
media_id: z.number().int().positive().optional(),
})
.refine((body) => Boolean(body.task_media_id || body.media_id), {
message: "Which file?",
});
/**
* Uploading one deliverable straight onto a task, like a chat attachment.
*/
export const UploadTaskFileRequestSchema = z.object({
task_id: z.number().int().positive(),
data_url: z.string().min(1),
name: z.string().min(1),
kind: z.enum(media_kind_values).optional(),
});
export type CreateTaskRequest = z.infer<typeof CreateTaskRequestSchema>;
export type UpdateTaskRequest = z.infer<typeof UpdateTaskRequestSchema>;
export type TaskNoteRequest = z.infer<typeof TaskNoteRequestSchema>;
export type TaskFileRequest = z.infer<typeof TaskFileRequestSchema>;
+53
View File
@@ -9,6 +9,9 @@ import type {
BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_CRON_JOBS, BUN_SQLITE_COBBLE_CRON_JOBS,
BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_TASK_MEDIA,
BUN_SQLITE_COBBLE_TASK_NOTES,
BUN_SQLITE_COBBLE_TASKS,
BUN_SQLITE_COBBLE_USER_TYPES, BUN_SQLITE_COBBLE_USER_TYPES,
BUN_SQLITE_COBBLE_USERS, BUN_SQLITE_COBBLE_USERS,
BUN_SQLITE_COBBLE_WORKSPACES, BUN_SQLITE_COBBLE_WORKSPACES,
@@ -84,6 +87,52 @@ export type CronJobWithNames = BUN_SQLITE_COBBLE_CRON_JOBS & {
workspace_name?: string | null; workspace_name?: string | null;
}; };
/**
* A task with what a list shows beside it: the bot and workspace it belongs
* to, and its latest note.
*/
export type TaskWithNames = BUN_SQLITE_COBBLE_TASKS & {
bot_name?: string | null;
bot_kind?: string | null;
bot_hue?: string | null;
bot_face?: string | null;
bot_image_media_id?: number | null;
workspace_name?: string | null;
/**
* Its newest note's text, for the row's second line.
*/
latest_note?: string | null;
/**
* How many files it carries.
*/
media_count?: number | null;
};
/**
* A file linked to a task, joined with the `media` row it's served from.
*/
export type TaskFile = BUN_SQLITE_COBBLE_TASK_MEDIA & {
media_type?: string | null;
mime_type?: string | null;
/**
* The `media` row's title, when the link has no name of its own.
*/
media_title?: string | null;
/**
* Whether the `media` row is still there.
*/
missing?: boolean;
};
/**
* A task with its notes and files: what the task modal and the workspace's
* tasks group render.
*/
export type TaskDetail = TaskWithNames & {
notes: BUN_SQLITE_COBBLE_TASK_NOTES[];
files: TaskFile[];
};
/** /**
* A session in a conversation's sessions modal. * A session in a conversation's sessions modal.
*/ */
@@ -810,6 +859,10 @@ export type PagePropsType = {
* A workspace's dashboard: the user's latest sessions there. * A workspace's dashboard: the user's latest sessions there.
*/ */
workspace_activity?: WorkspaceActivityItem[] | null; workspace_activity?: WorkspaceActivityItem[] | null;
/**
* A workspace's dashboard: its shared to-do list.
*/
workspace_tasks?: TaskWithNames[] | null;
/** /**
* The computer a settings page manages, with its access. * The computer a settings page manages, with its access.
*/ */
@@ -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;
};