diff --git a/TODO b/TODO new file mode 100644 index 000000000..d0c959599 --- /dev/null +++ b/TODO @@ -0,0 +1,31 @@ +Things to Fix: + ☐ Add behaviour settings per channel + ☐ Force branching before response + +Cortex Loops: + ☐ Check tasks + elevate todos + ☐ Improve bulletin + ☐ Better cortex loop context + +Improvements: + ☐ Improve cron + ☐ Named adapters + ☐ Thread names should be better, match channel name style + +Live Data Sources (maybe not needed): + ☐ Repos + ☐ Documentation + +Features: + ☐ Agent creation flow / onboarding UI + ☐ Cortex chat context inspection — should be able to read the full channel context at any time + ☐ Add streaming support + +Urgent: + ☐ Send customer emails ASAP + +Settings: + ☐ Disable sandboxing / sandbox settings + +Notes: + Testing inter-agent comms: send a message to the Spacebot Tech Lead, who forwards to the Community Manager, who picks a random word relating to fruit, technology, or space exploration, sending it back up the chain. diff --git a/docs/.node-version b/docs/.node-version new file mode 100644 index 000000000..209e3ef4b --- /dev/null +++ b/docs/.node-version @@ -0,0 +1 @@ +20 diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index f098b6d73..46309f1d1 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -493,7 +493,7 @@ Dispatch readiness is derived from warmup runtime state: - warmup state must be `warm` - embedding must be ready -- bulletin age must be fresh (<= `max(60s, refresh_secs * 2)`) +- bulletin age must be fresh (`<= max(60s, refresh_secs * 2)`) When branch/worker/cron dispatch happens before readiness is satisfied, Spacebot still dispatches, increments cold-dispatch metrics, and queues a forced warmup pass in the background. diff --git a/docs/content/docs/(core)/architecture.mdx b/docs/content/docs/(core)/architecture.mdx new file mode 100644 index 000000000..d99dec336 --- /dev/null +++ b/docs/content/docs/(core)/architecture.mdx @@ -0,0 +1,407 @@ +--- +title: Architecture +description: System-level overview of how Spacebot's processes, databases, and messaging layer fit together. +--- + +# Architecture + +Spacebot is a single binary that runs multiple concurrent AI processes, each with a dedicated role. There's no server to install, no message broker, no external database. Everything is embedded -- the LLM orchestration, the databases, the messaging adapters, the control API, and the web UI all run inside one process. + +This page is the system-level view. It explains how the pieces connect. For deep dives into individual subsystems, see the linked pages throughout. + +## The Problem + +Most AI agent systems use a single LLM session for everything -- conversation, thinking, tool execution, memory retrieval, and context management all happen in one thread. This creates fundamental bottlenecks: + +- **Blocking:** When the agent is running a tool or compacting context, the user waits. +- **Context pollution:** Tool outputs, internal reasoning, and raw search results fill the context window alongside conversation. +- **No specialization:** The same prompt and model handle tasks that have very different requirements. +- **No concurrency:** One thing happens at a time. + +Spacebot's architecture is designed around one principle: **delegation is the only way work gets done.** + +## Process Model + +Five process types, each implemented as a Rig `Agent`. They differ in system prompt, available tools, history management, and hooks. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Channel │ +│ User-facing conversation. Has personality and soul. │ +│ Never blocks. Delegates everything. │ +│ │ +│ Tools: reply, branch, spawn_worker, route, cancel, │ +│ skip, react, cron, send_file, send_message │ +├────────────┬────────────────────────┬───────────────────┤ +│ │ │ │ +│ ┌──────▼──────┐ ┌──────▼──────┐ │ +│ │ Branch │ │ Worker │ │ +│ │ │ │ │ │ +│ │ Fork of │ │ Independent │ │ +│ │ channel │ │ task. No │ │ +│ │ context. │ │ channel │ │ +│ │ Thinks, │ │ context. │ │ +│ │ recalls, │ │ Executes. │ │ +│ │ returns a │ │ │ │ +│ │ conclusion. │ │ Shell, file,│ │ +│ │ │ │ exec, browse│ │ +│ │ Memory │ │ │ │ +│ │ tools only. │ │ Fire-and- │ │ +│ └─────────────┘ │ forget or │ │ +│ │ interactive.│ │ +│ └─────────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ Compactor │ +│ Programmatic monitor. NOT an LLM process. │ +│ Watches context size, triggers compaction workers. │ +│ 80% → background, 85% → aggressive, 95% → emergency │ +├─────────────────────────────────────────────────────────┤ +│ Cortex │ +│ System-level observer. Sees across all channels. │ +│ Generates the memory bulletin — an LLM-curated │ +│ briefing injected into every channel's prompt. │ +└─────────────────────────────────────────────────────────┘ +``` + +### How Delegation Works + +The channel never searches memories, executes shell commands, or does heavy work. When it needs to think, it creates a **branch** -- a fork of its conversation context that goes off to reason, recall memories, and return a conclusion. When it needs work done, it spawns a **worker** -- an independent process with task tools and no conversation context. + +The channel is always responsive. Branches and workers run concurrently in `tokio::spawn`. Multiple branches can run simultaneously (configurable limit). Multiple workers can run simultaneously. The channel continues accepting messages while they work. + +``` +User message arrives + → Channel LLM turn + → Decides it needs to think → spawns Branch + → Decides it needs code written → spawns Worker + → Replies to user immediately + → Branch finishes → result injected into channel history → channel retriggered + → Worker finishes → status update injected → channel retriggered +``` + +For detailed coverage of each process type, see [Agents](/docs/agents), [Compaction](/docs/compaction), and [Cortex](/docs/cortex). + +## Inter-Process Communication + +All processes within an agent communicate through a `broadcast::channel` -- a multi-producer, multi-consumer event bus. The channel, all branches, and all workers share the same bus. + +### Event Types + +| Event | Producer | Consumer | Purpose | +|-------|----------|----------|---------| +| `BranchStarted` | Channel | Status block | Branch is running | +| `BranchResult` | Branch | Channel | Conclusion ready, retrigger | +| `WorkerStarted` | Channel | Status block | Worker is running | +| `WorkerStatus` | Worker | Channel, Status block | Progress update via `set_status` | +| `WorkerComplete` | Worker | Channel | Task done, retrigger | +| `ToolStarted` | Hook | Channel, UI | Tool call in progress | +| `ToolCompleted` | Hook | Channel, UI | Tool call finished | +| `MemorySaved` | Branch, Cortex | UI | New memory created | +| `CompactionTriggered` | Compactor | Channel | Context compacted | +| `StatusUpdate` | Various | UI (SSE) | Typing indicators, lifecycle | +| `TaskUpdated` | Branch, Worker | UI | Task board change | +| `AgentMessageSent` | Channel | Link routing | Inter-agent message | +| `AgentMessageReceived` | Link routing | Channel | Inbound inter-agent message | + +### Retriggering + +When a branch or worker completes, the channel doesn't poll for results. The completion event **retriggers** the channel -- it runs another LLM turn with the result injected into its history. This keeps the channel reactive without polling loops. + +Retrigger events are debounced. If multiple branches complete within a short window, the channel batches them into a single turn. A retrigger limit (default: 3 per turn) prevents infinite cascades where a branch result triggers a new branch that triggers another retrigger. + +### Status Block + +Every turn, the channel receives a live snapshot of all active processes: + +```markdown +## Currently Active + +### Workers +- **[code-review]** (running, 45s) — "Reviewing changes in src/memory/store.rs" +- **[test-runner]** (waiting for input, 2m) — "Tests passed. Awaiting further instructions." + +### Recently Completed +- **[search]** completed 30s ago — "Found 3 relevant files for the query." +``` + +Workers set their own status via the `set_status` tool. Short branches (< 3 seconds) are invisible in the status block to avoid noise. The status block is injected into the system prompt, giving the LLM awareness of concurrent activity. + +## Data Layer + +Three embedded databases, each purpose-built. No server processes, no network connections. Everything lives in the agent's data directory. + +``` +~/.spacebot/agents/{agent_id}/data/ +├── spacebot.db # SQLite — relational data +├── lancedb/ # LanceDB — vector embeddings, full-text search +└── config.redb # redb — key-value settings, encrypted secrets +``` + +### SQLite (via sqlx) + +The primary database. Stores everything that benefits from relational queries: + +| Table | Purpose | +|-------|---------| +| `memories` | Memory content, types, importance scores, timestamps | +| `associations` | Graph edges between memories (weighted, typed) | +| `conversation_messages` | Persistent conversation history per channel | +| `channels` | Active channel registry with platform metadata | +| `cron_jobs` | Scheduled task definitions | +| `cron_executions` | Execution history for cron jobs | +| `worker_runs` | Worker execution history with transcripts | +| `branch_runs` | Branch execution history | +| `cortex_events` | Cortex action log (bulletin generations, maintenance) | +| `cortex_chat_messages` | Persistent admin chat with cortex | +| `tasks` | Structured task board (backlog → in_progress → done) | +| `ingestion_progress` | Chunk-level progress for file ingestion | +| `agent_profile` | Cortex-generated personality data | + +Migrations are in `migrations/` and are **immutable once committed**. Schema changes always go in new migration files. See [Memory](/docs/memory) for the memory graph schema. + +### LanceDB + +Vector storage and search. Paired with SQLite on memory ID. + +- **Embeddings** stored in Lance columnar format with HNSW indexing +- **Full-text search** via built-in Tantivy integration +- **Hybrid search** combines vector similarity and keyword matching via Reciprocal Rank Fusion (RRF) + +The embedding model runs locally via FastEmbed -- no external API calls for embeddings. See [Memory](/docs/memory) for search details. + +### redb + +Embedded key-value store for configuration and secrets. + +- **Settings** — runtime key-value pairs (e.g., UI preferences, feature flags) +- **Encrypted secrets** — API keys and tokens encrypted with AES-256-GCM before storage + +Separated from SQLite so credentials can be managed and backed up independently. + +## Messaging Layer + +Spacebot connects to multiple messaging platforms simultaneously. All adapters implement the same `Messaging` trait and feed into a unified inbound message stream. + +``` +Discord ─┐ +Slack ───┤ +Telegram ┼──→ MessagingManager ──→ InboundMessage stream ──→ main.rs event loop +Twitch ──┤ │ +Webhook ─┤ ▼ +WebChat ─┘ Channel.handle_message() + │ + ▼ + OutboundResponse + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + Discord Slack Telegram +``` + +### Inbound Flow + +1. Platform adapter receives a message (Discord event, Slack webhook, Telegram update, etc.) +2. Adapter converts to `InboundMessage` — a unified type with text, media, sender info, conversation ID, and platform metadata +3. `MessagingManager` fans all adapters into a single `mpsc::channel` +4. `main.rs` event loop receives the message, resolves the target agent via message bindings, and routes to the appropriate `Channel` +5. If no `Channel` exists for this conversation ID, one is created and its event loop spawned + +### Outbound Flow + +1. Channel tools (reply, react, send_file) produce `OutboundResponse` values +2. Each channel has an outbound routing task that receives responses via `mpsc::channel` +3. The routing task determines the platform from the channel ID prefix (`discord:`, `slack:`, `telegram:`, etc.) +4. `MessagingManager::broadcast()` delivers the response to the correct platform adapter +5. Responses are also forwarded to SSE clients (WebChat, dashboard) for real-time UI updates + +### Message Bindings + +Each agent declares which messaging channels route to it: + +```toml +[[agents]] +id = "main" + +[[agents.bindings.discord]] +guild_id = "1323900500600422472" +channel_ids = ["1471388652562284626"] + +[[agents.bindings.telegram]] +chat_ids = [551234, -1001234567890] + +[[agents.bindings.webhook]] +endpoints = ["github-ci", "monitoring"] +``` + +When a message arrives, the binding resolver matches the conversation ID against all agent bindings. If no specific binding matches, the message goes to the default agent (if one is configured). See [Messaging](/docs/messaging) and the individual platform setup guides for configuration details. + +## LLM Integration + +Spacebot uses [Rig](https://github.com/0xPlaygrounds/rig) as the agentic loop framework. Every process is a Rig `Agent` with a custom `CompletionModel` implementation that routes through Spacebot's `LlmManager`. + +### Custom Model Layer + +Spacebot doesn't use Rig's built-in provider clients. Instead, `SpacebotModel` implements `CompletionModel` and delegates to `LlmManager`, which handles: + +- **Provider routing** — resolving model names to provider clients (Anthropic, OpenAI, Google, etc.) +- **Process-type defaults** — different models for channels, branches, workers, compactor, cortex +- **Task-type overrides** — specific models for coding, summarization, deep reasoning tasks +- **Fallback chains** — automatic fallback to alternative models on failure + +``` +Channel LLM call + → SpacebotModel.completion(messages, tools) + → LlmManager.resolve_model("anthropic/claude-sonnet-4-20250514") + → Anthropic client + → API call with prompt caching, custom parameters +``` + +See [Routing](/docs/routing) for the full routing configuration. + +### Agent Construction + +```rust +let agent = AgentBuilder::new(model.clone()) + .preamble(&system_prompt) + .hook(SpacebotHook::new(process_id, process_type, event_tx.clone())) + .tool_server_handle(tools.clone()) + .default_max_turns(50) + .build(); +``` + +### Hooks + +Two hook implementations control process behavior: + +**`SpacebotHook`** (channels, branches, workers) — sends `ProcessEvent`s for real-time status, tracks token usage, enforces cancellation signals, implements tool nudging (prompts the LLM to use tools if it responds with text instead of tool calls in early iterations), and runs leak detection on tool outputs. + +**`CortexHook`** (cortex only) — lighter implementation for system observation, no tool nudging. + +Hooks return `Continue`, `Terminate`, or `Skip` after each LLM turn, giving the system fine-grained control over process lifecycle. + +### Max Turns + +Rig defaults to 0 (single call). Spacebot sets explicit limits per process type: + +| Process | Max Turns | Rationale | +|---------|-----------|-----------| +| Channel | 5 | Typically 1-3 turns. Prevents runaway conversations. | +| Branch | 10 | A few iterations to think, recall, and conclude. | +| Worker | 50 | Many iterations for complex tasks. Segmented into 25-turn blocks. | +| Compactor | 10 | Summarize and extract memories. Bounded. | +| Cortex | 10 | Bulletin generation. Single-pass with tool calls. | + +## Control API + +An embedded Axum HTTP server provides the control API for the dashboard and external integrations. Default port: `19898`. + +### Key Endpoint Groups + +| Group | Prefix | Purpose | +|-------|--------|---------| +| Agents | `/api/agents` | CRUD for agent definitions | +| Channels | `/api/channels` | Channel listing, history, deletion | +| Workers | `/api/workers` | Worker status, history, timeline | +| Cortex | `/api/cortex` | Bulletin, profile, cortex chat | +| Memory | `/api/memories` | Memory CRUD, graph queries | +| Config | `/api/config` | Runtime configuration read/write | +| Providers | `/api/providers` | LLM provider key management | +| Links | `/api/links` | Communication graph management | +| Tasks | `/api/tasks` | Task board CRUD | +| Cron | `/api/cron` | Scheduled task management | +| System | `/api/system` | Health, version, metrics | +| WebChat | `/api/webchat` | Embedded chat interface | +| Models | `/api/models` | Available model listing | +| Topology | `/api/topology` | Full communication graph | + +The dashboard UI is a React SPA embedded in the binary via `rust-embed` and served at the root path. It communicates with these API endpoints for all operations. + +### Real-Time Updates + +The API supports Server-Sent Events (SSE) for real-time streaming to connected clients. Status updates, tool call progress, worker lifecycle events, and memory changes are all pushed via SSE, giving the dashboard and WebChat live visibility into agent activity. + +## Startup Sequence + +``` +CLI (clap) → parse args + → Load config.toml + → Optionally daemonize (Unix socket for IPC) + → Build tokio runtime + → Initialize tracing + OpenTelemetry (optional) + → run() + → Start IPC server (stop/status commands) + → Start Axum API server + → Initialize shared resources: + LlmManager, EmbeddingModel, PromptEngine, agent links + → For each agent: + → Run SQLite migrations + → Initialize MemoryStore, LanceDB tables + → Initialize MessagingManager (start all platform adapters) + → Initialize CronScheduler + → Start Cortex (warmup → first bulletin) + → Register agent in active agents map + → Enter main event loop (tokio::select!) + → Inbound messages → route to Channel instances + → Agent registration/removal + → Provider setup events + → Shutdown signal → graceful shutdown +``` + +All long-running loops respect a shutdown signal via `broadcast::channel`. On shutdown, active workers are cancelled, channels are flushed, and database connections are closed cleanly. + +## Module Structure + +The crate uses the sibling file module pattern -- `src/memory.rs` is the module root for `src/memory/`, never `mod.rs`. + +``` +src/ +├── main.rs — CLI entry, config, startup, event loop +├── lib.rs — module declarations, shared types +├── config.rs — configuration loading and validation +├── error.rs — top-level Error enum +├── db.rs — database connection bundle +│ +├── agent/ — process implementations +│ ├── channel.rs — user-facing conversation +│ ├── branch.rs — forked thinking process +│ ├── worker.rs — task execution +│ ├── compactor.rs — context monitor +│ ├── cortex.rs — system observer +│ └── status.rs — live status snapshot +│ +├── tools/ — 27 tool implementations (one per file) +├── memory/ — memory graph, search, embeddings +├── llm/ — model routing, provider clients +├── messaging/ — platform adapters +├── conversation/ — history persistence, context assembly +├── prompts/ — template engine +├── hooks/ — PromptHook implementations +├── cron/ — scheduled tasks +├── api/ — 21 Axum endpoint modules +├── identity/ — identity file loading +├── secrets/ — encrypted credential storage +├── settings/ — key-value settings +├── tasks/ — task board +├── links/ — communication graph types +├── skills/ — skill management +├── opencode/ — OpenCode worker integration +├── sandbox/ — command sandboxing +├── telemetry/ — metrics (feature-gated) +└── update/ — self-update checker +``` + +## Design Principles + +**Never block the channel.** The channel never waits on branches, workers, or compaction. If something takes time, it runs concurrently and retriggers the channel when done. + +**Raw data never reaches the channel.** Memory recall goes through a branch, which curates. The channel gets clean conclusions, not raw database rows. + +**Workers have no channel context.** A worker gets a task description and tools. If something needs conversation context, it's a branch, not a worker. + +**The compactor is not an LLM.** It's a programmatic monitor that watches a number and spawns workers. The LLM work happens in the workers it spawns. + +**Prompts are files.** System prompts live in `prompts/` as Jinja2 templates, not as string constants in Rust code. Identity files (SOUL.md, IDENTITY.md, USER.md, ROLE.md) are loaded from the agent's workspace directory. + +**Three databases, three purposes.** SQLite for relational queries, LanceDB for vector search, redb for key-value config. Each doing what it's best at. + +**Graceful everything.** All loops respect shutdown signals. Errors are propagated, not silenced. The only exception is `.ok()` on channel sends where the receiver may already be dropped. diff --git a/docs/content/docs/(core)/cortex.mdx b/docs/content/docs/(core)/cortex.mdx index e349bfc6e..e2f682403 100644 --- a/docs/content/docs/(core)/cortex.mdx +++ b/docs/content/docs/(core)/cortex.mdx @@ -223,7 +223,7 @@ Branch, worker, and cron dispatch paths consult a derived `ready_for_work` signa - warmup state is `warm` - embedding model is ready -- bulletin age is fresh (<= `max(60s, refresh_secs * 2)`) +- bulletin age is fresh (`<= max(60s, refresh_secs * 2)`) If dispatch arrives while not ready, Spacebot does **not** block the channel or scheduler: diff --git a/docs/content/docs/(core)/meta.json b/docs/content/docs/(core)/meta.json index 53cfc2a04..0e07391ec 100644 --- a/docs/content/docs/(core)/meta.json +++ b/docs/content/docs/(core)/meta.json @@ -2,6 +2,7 @@ "title": "Core Concepts", "pages": [ "philosophy", + "architecture", "agents", "memory", "routing", diff --git a/docs/content/docs/(features)/meta.json b/docs/content/docs/(features)/meta.json index 0c4bb20b8..a9903832a 100644 --- a/docs/content/docs/(features)/meta.json +++ b/docs/content/docs/(features)/meta.json @@ -1,4 +1,4 @@ { "title": "Features", - "pages": ["workers", "opencode", "tools", "mcp", "browser", "cron", "skills", "ingestion"] + "pages": ["workers", "tasks", "opencode", "tools", "mcp", "browser", "cron", "skills", "ingestion"] } diff --git a/docs/content/docs/(features)/tasks.mdx b/docs/content/docs/(features)/tasks.mdx new file mode 100644 index 000000000..cf48bb645 --- /dev/null +++ b/docs/content/docs/(features)/tasks.mdx @@ -0,0 +1,343 @@ +--- +title: Tasks +description: Kanban-style task board with structured tracking, cortex pickup, and worker execution. +--- + +# Tasks + +A kanban-style task board built into every agent. Tasks are spec-driven documents — the description is a full markdown spec that evolves through conversation, with pre-filled subtasks as an execution plan. Each task has a short title, rich description, status, priority, subtasks, and a numeric reference (`#42`). Each agent has its own independent task store backed by its own SQLite database. + +Tasks are not tickets. They're living specs written for workers who have no conversation context. A good task description includes requirements, constraints, file paths, examples, and acceptance criteria. The branch refines the spec as the user clarifies scope, and moves it to `ready` when it's complete. The cortex picks it up and spawns a worker that executes against the spec. + +## Creation Paths + +Tasks enter the system three ways: + +### 1. Conversational (via branch tools) + +The primary path. A user manages tasks through natural conversation — creating, listing, updating, approving, and closing tasks by talking to the agent. The channel delegates to a branch, and the branch uses `task_create`, `task_list`, and `task_update` tools. + +``` +User: "Create a task to refactor the auth module, high priority" + → Channel branches + → Branch calls task_create( + title: "Refactor auth module", + priority: "high", + description: "## Goal\nExtract auth logic from ...\n\n## Requirements\n- ...\n## Constraints\n- ...", + subtasks: ["Audit current auth endpoints", "Extract shared middleware", "Update tests", "Verify CI passes"] + ) + → Branch returns: "Created task #7 with 4 subtasks" + +User: "Actually, we also need to migrate the session store to Redis" + → Channel branches + → Branch calls task_update(task_number: 7, description: "") + → Branch returns: "Updated #7 — added Redis migration to the spec" + +User: "Looks good, run it" + → Channel branches + → Branch calls task_update(task_number: 7, status: "ready") + → Cortex ready-task loop picks it up → Worker executes against the spec → Done +``` + +Tasks created conversationally default to `backlog` status. The branch writes a rich markdown description and pre-fills subtasks as an execution plan. The user refines scope through conversation, and the branch updates the spec accordingly. When the spec is complete, moving to `ready` triggers automatic execution. + +### 2. Cortex promotion (from Todo memories) + +The cortex bridges the gap between quick captures and structured work. A cortex loop scans recent `Todo` memories, evaluates whether they're actionable, and promotes them to tasks in `pending_approval` status. The human reviews and approves before execution begins. + +``` +Branch saves a Todo memory during conversation + → Cortex evaluates the todo (promotion loop) + → Cortex creates a Task in "pending_approval" + → Human approves on the kanban board + → Cortex ready-task loop picks it up + → Worker executes → Done +``` + +This path is for things the agent noticed were actionable but the user didn't explicitly ask to track — the cortex catches what falls through the cracks. + +### 3. UI / API + +Tasks can be created directly from the kanban board UI or via the REST API. These default to `backlog` status with `created_by: "human"`. + +## Status (Kanban Columns) + +Five columns on the board: + +| Status | Description | +|--------|-------------| +| `pending_approval` | Created by cortex, awaiting human sign-off | +| `backlog` | Captured but not ready for work. Default for conversational and UI-created tasks | +| `ready` | Approved and waiting for the cortex to pick up | +| `in_progress` | A worker is actively executing this task | +| `done` | Completed | + +### Status Transitions + +Transitions are validated. You can't skip steps or go backwards (except to `backlog`, which is always allowed as a "re-shelve" action). + +``` +pending_approval → ready (approval) +pending_approval → backlog (shelve) +backlog → ready (manual promotion) +ready → in_progress (cortex pickup) +in_progress → done (worker success) +in_progress → ready (worker failure, re-queued) +done → backlog (reopen) +``` + +Attempting an invalid transition (e.g., `pending_approval → in_progress`, `ready → done`) returns an error. + +## Priority + +Four levels, ordered by urgency: + +``` +critical > high > medium > low +``` + +Default: `medium`. The cortex ready-task loop respects priority — a critical task is picked up before a low-priority one, regardless of creation order. + +## Subtasks + +Simple checklist items stored as a JSON array. One level deep, no nesting. + +```json +[ + {"title": "Research existing API endpoints", "completed": false}, + {"title": "Draft schema changes", "completed": true}, + {"title": "Implement migration", "completed": false} +] +``` + +Subtasks are included in the worker's prompt as an execution plan. Workers can mark subtasks complete via the `task_update` tool as they progress. + +## Metadata + +Arbitrary key-value pairs stored as a JSON object. Used for linking to external resources. + +```json +{ + "github_issue": "https://github.com/org/repo/issues/123", + "estimated_effort": "small", + "worker_type": "opencode", + "skill": "rust-dev", + "notes": "Depends on the auth refactor landing first" +} +``` + +No enforced schema. The UI renders known keys with special formatting (e.g., GitHub links become clickable) and displays unknown keys as plain key-value pairs. + +## Task Numbering + +Per-agent, monotonically increasing. The next number is `MAX(task_number) + 1` within the agent's task table. Tasks are referenced as `#1`, `#42`, etc. Numbers are never reused — deleting task `#5` doesn't free up the number. + +## Execution + +### Cortex Ready-Task Loop + +The primary execution path. A background loop runs every `cortex.tick_interval_secs` (default 30 seconds): + +1. **Claim** — Atomically finds the oldest `ready` task with the highest priority and moves it to `in_progress` +2. **Build prompt** — Renders the worker system prompt with the task title, description, and subtask checklist +3. **Spawn worker** — Creates a new worker with full tool access (shell, file, exec, browser) +4. **Bind** — Sets `worker_id` on the task, linking it to the executing worker +5. **Execute** — The worker runs its loop, using subtasks as an execution plan +6. **Complete** — On success, the task moves to `done` with `completed_at` set. On failure, the task moves back to `ready` with `worker_id` cleared, so it gets re-queued for another attempt + +Worker success/failure is determined by whether `worker.run()` returns `Ok` or `Err`. The cortex doesn't evaluate the quality of the work — a worker that completes without errors is considered successful. + +### API Execute Endpoint + +The `/api/agents/tasks/:number/execute` endpoint moves a task to `ready` (if it's in `backlog` or `pending_approval`), letting the cortex loop pick it up. Tasks already in `ready` or `in_progress` are returned as-is. + +This means execution always flows through the cortex — the API doesn't spawn workers directly. + +### Worker Scope + +Workers executing a task get a restricted version of the `task_update` tool. They can only: + +- Update subtasks (mark complete, replace the checklist) +- Update metadata + +They cannot change the task's status, priority, title, description, or worker binding. These fields are managed by the cortex and the API. This prevents a worker from marking its own task as `done` — only the cortex does that based on whether the worker succeeded or failed. + +## Bulletin Integration + +Active tasks (non-done) are included in the cortex memory bulletin under an "Active Tasks" section. Each task is listed with its number, status, priority, title, and subtask progress: + +``` +### Active Tasks + +- #3 [in_progress] (high) Implement auth refactor [2/5] +- #7 [ready] (medium) Update deployment docs +- #12 [backlog] (low) Clean up unused dependencies +``` + +This gives every channel and branch awareness of the agent's current task board without querying the task store directly. + +## LLM Tools + +Three tools available to branches and cortex chat sessions: + +### task_create + +Creates a new task. + +| Argument | Type | Required | Default | +|----------|------|----------|---------| +| `title` | string | yes | - | +| `description` | string | no | - | +| `priority` | string | no | `"medium"` | +| `subtasks` | string[] | no | `[]` | +| `metadata` | object | no | `{}` | +| `status` | string | no | `"backlog"` | + +Returns the created task number and status. + +### task_list + +Lists tasks with optional filters. + +| Argument | Type | Required | Default | +|----------|------|----------|---------| +| `status` | string | no | all | +| `priority` | string | no | all | +| `limit` | integer | no | 20 | + +### task_update + +Updates an existing task. Available to branches (unrestricted) and workers (restricted to subtasks and metadata only). + +| Argument | Type | Required | Notes | +|----------|------|----------|-------| +| `task_number` | integer | yes | The `#N` reference | +| `title` | string | no | Branch only | +| `description` | string | no | Branch only | +| `status` | string | no | Branch only | +| `priority` | string | no | Branch only | +| `subtasks` | object[] | no | Full replacement | +| `metadata` | object | no | Merged with existing | +| `complete_subtask` | integer | no | Index to mark complete | + +## API Endpoints + +All endpoints require `agent_id` as a query parameter or in the request body. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/agents/tasks` | List tasks (filterable by status, priority) | +| `GET` | `/api/agents/tasks/:number` | Get single task by number | +| `POST` | `/api/agents/tasks` | Create task | +| `PUT` | `/api/agents/tasks/:number` | Update task | +| `DELETE` | `/api/agents/tasks/:number` | Delete task | +| `POST` | `/api/agents/tasks/:number/approve` | Approve (moves to `ready`) | +| `POST` | `/api/agents/tasks/:number/execute` | Execute (moves to `ready` for cortex pickup) | + +### SSE Events + +Task state changes emit `task_updated` SSE events to connected clients: + +```json +{ + "type": "task_updated", + "agent_id": "main", + "task_number": 42, + "status": "in_progress", + "action": "updated" +} +``` + +The `action` field is one of `"created"`, `"updated"`, or `"deleted"`. The kanban board UI uses these events for real-time updates. + +## Interface + +### Kanban Board + +The **Tasks** tab on the agent page renders a five-column kanban board. Each column corresponds to a task status. Task cards show: + +- `#N` task number and title +- Priority badge (color-coded: red for critical, amber for high, default for medium, outline for low) +- Subtask progress bar (if subtasks exist) +- Worker badge (if a worker is bound) +- Quick action buttons (Approve, Execute, Mark Done) +- Creation timestamp and author + +Clicking a card opens a detail dialog with the full description, subtask checklist, metadata, timestamps, and status action buttons. + +### Create Task + +The "Create Task" button opens a dialog with fields for title, description, priority, and initial status. Tasks created from the UI default to `backlog` status and `"human"` as the creator. + +## Storage + +One SQLite table in the agent's database. + +```sql +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + task_number INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT NOT NULL DEFAULT 'medium', + subtasks TEXT, -- JSON array + metadata TEXT, -- JSON object + source_memory_id TEXT, + worker_id TEXT, + created_by TEXT NOT NULL, + approved_at TIMESTAMP, + approved_by TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + UNIQUE(agent_id, task_number) +); +``` + +Indexes on `agent_id`, `status`, `(agent_id, task_number)`, `source_memory_id`, and `worker_id`. + +## Module Layout + +``` +src/ +├── tasks.rs → tasks/ +│ └── store.rs — TaskStore: CRUD, status transitions, claim_next_ready +│ +├── tools/ +│ ├── task_create.rs — task_create LLM tool (branches + cortex chat) +│ ├── task_list.rs — task_list LLM tool (branches + cortex chat) +│ └── task_update.rs — task_update LLM tool (branches + workers, scoped) +│ +├── api/ +│ └── tasks.rs — REST endpoints (list, get, create, update, delete, +│ approve, execute) with SSE event emission +│ +├── agent/ +│ └── cortex.rs — spawn_ready_task_loop, pickup_one_ready_task, +│ gather_active_tasks (bulletin integration) +│ +└── migrations/ + └── 20260219000001_tasks.sql +``` + +## Prompt Integration + +The channel, branch, and cortex chat prompts are all task-aware: + +- **Channel prompt** (`channel.md.j2`) — has a dedicated "Task Board" section explaining spec-driven tasks and the kanban board. The Delegation section tells the channel to branch for task management. Active tasks appear in the Memory Context via the bulletin. +- **Branch prompt** (`branch.md.j2`) — documents all three task tools (`task_create`, `task_list`, `task_update`) with spec-driven guidance. `task_create` emphasizes rich markdown descriptions and pre-filled subtasks. `task_update` is framed as iterative spec refinement. Moving to `ready` triggers cortex auto-pickup. +- **Cortex chat prompt** (`cortex_chat.md.j2`) — lists task board management as a core capability with spec-driven language. The cortex chat has all three task tools. +- **Tool descriptions** — each task tool has a description template in `prompts/en/tools/` that reinforces the spec-driven philosophy: `task_create` tells the LLM to write full markdown specs with subtask execution plans, `task_update` tells it to refine specs as scope evolves. + +The channel itself has no task tools — it always branches to manage tasks. This keeps the channel responsive and ensures task operations go through a thinking process. + +## What's Not Implemented Yet + +- **Cortex todo-promotion loop** — the cortex loop that scans `Todo` memories and promotes them to `pending_approval` tasks. The data model and execution path are ready; the promotion evaluation prompt and loop are not yet built. +- **Drag-and-drop** — the kanban board has quick action buttons but no drag-and-drop between columns yet. +- **Activity timeline** — the detail dialog doesn't show a history of status changes, approvals, and worker events. +- **Task count badge** — the Tasks tab doesn't show a badge with the pending approval count yet. +- **Task archival** — done tasks accumulate indefinitely. Options: auto-archive after N days, a separate `archived` status, or UI filtering (currently, done tasks are shown in the Done column). +- **Rejection feedback** — when a human deletes a pending task, that signal isn't fed back to the cortex. Saving it as a memory would help the cortex learn what's not actionable. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index ca5bba05e..c924dc58f 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -27,7 +27,7 @@ Spacebot runs as a single binary with no server dependencies. All data lives in - + diff --git a/docs/design-docs/link-channels-task-delegation.md b/docs/design-docs/link-channels-task-delegation.md new file mode 100644 index 000000000..3d85230c0 --- /dev/null +++ b/docs/design-docs/link-channels-task-delegation.md @@ -0,0 +1,274 @@ +# Link Channels as Task Delegation (v3) + +Replaces the LLM-to-LLM conversational model from link-channels-v2 with deterministic task-based delegation. Agents don't talk to each other — they assign tasks. Link channels become audit logs of delegated work, not conversation threads. + +## Why + +The v2 design had agents exchange messages through mirrored link channels, with each side running its own LLM to process and respond. This was fundamentally brittle: + +- **Recursive loops**: Agents ping-pong conclusions, replies, or re-delegations endlessly. +- **Context loss**: When a link channel re-opens after concluding, the LLM has no memory of prior work and re-sends the original task. +- **Turn count gaming**: Safety caps fire, force-conclude, then the next message resets the budget. +- **Conclusion non-compliance**: Agents ignore `conclude_link` instructions and chat until the safety cap. +- **Result routing corruption**: `initiated_from` metadata gets overwritten by subsequent messages, causing results to bridge to the wrong channel. + +Every fix added more special-case logic (drop guards, peer-initiated flags, history seeding, mechanical passthroughs). The system was getting more complex with each bug, not simpler. The core problem is irreducible: two LLMs having a conversation is non-deterministic and uncontrollable. + +## The New Model + +Instead of conversations, agents delegate through **tasks**. The existing task tracking system (Phase 1 already implemented on `task-tracking` branch) provides the structured, deterministic substrate. + +``` +User asks Agent A to do something + -> Agent A decides it should be delegated to Agent B + -> Agent A calls send_agent_message (modified) + -> A task is created in Agent B's task store + -> A record is logged in the link channel between A and B + -> Agent A's turn ends (skip flag) + -> Agent B's cortex ready-task loop picks up the task + -> Worker executes the task + -> Task moves to done + -> Completion record logged in link channel + -> Agent A is notified (retrigger on originating channel) +``` + +No LLM-to-LLM conversation. No reply relay. No conclusion handshake. No turn counting. The delegation is a database write; the execution is a worker; the result is a task status change. + +## What Link Channels Become + +Link channels shift from conversation threads to **audit logs**. They record: + +1. **Task created**: "Agent A assigned task #42 to Agent B: [title]" +2. **Task completed**: "Agent B completed task #42: [summary]" +3. **Task failed**: "Agent B's worker failed on task #42: [error]" +4. **Task requeued**: "Task #42 returned to ready after worker failure" + +These are **system messages** — not LLM-generated text. The link channel is a historical record of delegation activity between two agents. When a human opens a link channel in the dashboard, they see a timeline of tasks assigned and results returned. + +Link channels are no longer processed by the LLM. There is no `handle_message()` call, no branching, no worker spawning from link channels. They are write-only logs read by humans through the UI. + +### Channel ID Convention + +Keep `link:{agent_a}:{agent_b}` as the channel ID format. The `ChannelStore` records these for the UI to discover. Messages are persisted via `ConversationLogger` with `source: "system"` so they're never fed into an LLM context window. + +## Modified `send_agent_message` Tool + +The tool's external interface stays the same — the LLM calls it with a target agent and a message. The implementation changes completely: + +``` +Before (v2): + 1. Construct InboundMessage + 2. Inject into target agent's message pipeline + 3. Target agent's link channel processes it with LLM + 4. Reply routed back through outbound handler + 5. Source agent processes reply with LLM + 6. Back and forth until conclude_link + +After (v3): + 1. Validate link exists and permits this direction + 2. Create task in target agent's task store + - title: extracted from message (first sentence or explicit title) + - description: full message content + - status: ready (skip pending_approval for agent-delegated tasks) + - priority: inferred or default medium + - created_by: "agent:{source_agent_id}" + - metadata: { delegated_by, originating_channel, link_id } + 3. Log delegation record in link channel (system message) + 4. Set skip flag (end source agent's turn) + 5. Return { success: true, task_number } +``` + +The tool needs access to the **target agent's** `TaskStore`, not just the source agent's. This means `send_agent_message` needs a way to resolve task stores across agents. + +### Cross-Agent Task Store Access + +Currently, `TaskStore` instances are per-agent and stored in `ApiState::task_stores`. The tool runs inside a specific agent's process and only has access to that agent's `AgentDeps`. + +**Decision: Per-agent task stores.** Each agent owns its own `TaskStore` backed by its own SQLite database. A superior agent instructs task creation via the link channel system and can query/read a subordinate agent's tasks (read-only cross-agent access). The task store registry (`HashMap>`) is passed to tools that need cross-agent visibility, but task *creation* on another agent goes through the link channel mechanism, not direct writes. + +This preserves agent isolation — each agent's task board is its own — while giving the hierarchy the ability to observe and manage work across the org. + +## Task Completion Notification + +When a delegated task completes, the delegating agent needs to know. Two mechanisms: + +### 1. Link Channel Record + +When a worker completes a task that has `metadata.delegated_by`, the cortex (or the task completion handler in `cortex.rs`) logs a completion message in the link channel: + +``` +[System] Task #42 completed by community-manager: "Published 3 posts to Discord announcements channel. Links: [...]" +``` + +This is a passive record — it doesn't trigger the delegating agent's LLM. + +### 2. Originating Channel Retrigger + +The task metadata includes `originating_channel` (the channel where the user originally asked for the work). When the task completes, a system message is injected into that channel: + +``` +[System] Delegated task completed by community-manager: "Published 3 posts..." +``` + +This **does** retrigger the channel's LLM, which can then relay the result to the user naturally. The message has `source: "system"` and no `formatted_author`, so it renders as a plain system notification. + +This replaces the old `bridge_to_initiator` mechanism but is much simpler — it's a single message injection on task completion, not a recursive conclusion chain. + +### 3. Task Status Polling (Optional, Future) + +The delegating agent's cortex could periodically check on delegated tasks via `task_list` filtered by `metadata.delegated_by`. This is a pull model that doesn't require any special routing — the cortex just queries its own task store for tasks it created on other agents. + +Not needed for v1 since the push notification (retrigger) handles the common case. + +## What Gets Removed + +### Files to Delete + +| File | Reason | +|------|--------| +| `src/tools/conclude_link.rs` | Conversational conclusion mechanism | +| `prompts/en/fragments/link_context.md.j2` | "You're in a conversation with agent X" prompt | +| `prompts/en/tools/conclude_link_description.md.j2` | Tool description for deleted tool | + +### Code to Remove from `src/agent/channel.rs` + +All link-conversation handling logic: + +- `link_concluded` field and all checks against it +- `link_turn_count` field and safety cap logic +- `peer_initiated_conclusion` field +- `initiated_from` field and capture logic +- `originating_channel` / `originating_source` fields +- `build_link_context()` method (~40 lines) +- `route_link_conclusion()` / `handle_link_conclusion()` / `bridge_to_initiator()` methods (~160 lines) +- History seeding for link channels (original_sent_message replay) +- Coalesce bypass for link channels +- Drop guard for concluded link channels +- `conclude_link` tool registration in `run_agent_turn()` +- `ConcludeLinkFlag` / `ConcludeLinkSummary` return values from `run_agent_turn()` +- `is_link_channel` checks throughout + +### Code to Remove from `src/main.rs` + +- Outbound reply relay for `source == "internal"` channels (~90 lines, ~lines 981-1072) +- This is the code that intercepts Agent B's reply on `link:B:A` and injects it into `link:A:B` + +### Code to Remove from `src/tools.rs` + +- `pub mod conclude_link` and re-exports +- `conclude_link` parameter in `add_channel_tools()` / `remove_channel_tools()` +- `link_counterparty_for_agent()` helper +- `has_other_delegation_targets` complexity (simplify to basic "has any link targets") + +### Prompt Changes + +- Remove `conclude_link` text entry from `src/prompts/text.rs` +- Update `org_context.md.j2` wording — replace conversation language with task delegation language +- Update `send_agent_message` tool description — "assigns a task" not "sends a message" + +## What Gets Kept + +### Link Infrastructure (Unchanged) + +- `src/links.rs` + `src/links/types.rs` — `AgentLink`, `LinkDirection`, `LinkKind`, store utilities +- `src/config.rs` — `[[links]]` TOML parsing, `LinkDef` +- `src/main.rs` — Link initialization, `ArcSwap` plumbing, `AgentDeps.links` +- `ProcessEvent::AgentMessageSent` / `AgentMessageReceived` +- `src/api/links.rs` — Link CRUD API +- Topology API + +### Link Prompt Context (Modified) + +- `org_context.md.j2` — Keep the hierarchy rendering (superiors/subordinates/peers). Update the instruction text from "send a message" to "assign a task". +- `build_org_context()` in `channel.rs` — Keep as-is. It reads link topology and renders the org hierarchy. + +### Task System (From `task-tracking` Branch) + +- `migrations/20260219000001_tasks.sql` — Schema +- `src/tasks.rs` + `src/tasks/store.rs` — `TaskStore`, CRUD, status transitions +- `src/api/tasks.rs` — REST API +- `src/tools/task_create.rs`, `task_list.rs`, `task_update.rs` — LLM tools +- `src/agent/cortex.rs` — `spawn_ready_task_loop`, `pickup_one_ready_task` + +## New Code Needed + +### 1. Cross-Agent Task Creation in `send_agent_message.rs` + +Rewrite the tool's `call()` method to create a task instead of injecting a message. Needs a `task_stores: Arc>>` field. + +### 2. Task Completion Callback in `cortex.rs` + +After `pickup_one_ready_task` marks a task as `Done`, check if `metadata.delegated_by` exists. If so: + +1. Log completion in the link channel via `ConversationLogger` +2. Inject a system message into `metadata.originating_channel` to retrigger the delegating agent + +This replaces the entire `bridge_to_initiator` mechanism with ~20 lines of straightforward code. + +### 3. Link Channel System Message Logging + +A small helper that writes system messages to link channels: + +```rust +fn log_link_event( + conversation_logger: &ConversationLogger, + link_channel_id: &str, + message: &str, +) { + // Persist as a system message (source: "system", role: "system") + // Not fed to any LLM — purely for UI display +} +``` + +Called from `send_agent_message` (task created) and from the cortex completion handler (task done/failed). + +### 4. `send_agent_message` Tool Description Update + +Rewrite `prompts/en/tools/send_agent_message_description.md.j2` to describe task delegation: + +> Assign a task to another agent. The target agent's cortex will pick it up and execute it autonomously. Use this when work falls outside your scope or belongs to a subordinate. Your turn ends after delegation — the result will be delivered when the task completes. + +## Implementation Order + +### Phase 1: Tear Out LLM Conversations + +1. Delete `conclude_link.rs`, `link_context.md.j2`, conclude_link prompt text +2. Remove all link-conversation logic from `channel.rs` (fields, methods, guards) +3. Remove outbound reply relay from `main.rs` +4. Remove conclude_link from `tools.rs` registration +5. Simplify `add_channel_tools()` — drop conclude_link param, simplify delegation target logic +6. Verify compilation, run tests + +### Phase 2: Wire Task Delegation into `send_agent_message` + +1. Add `task_stores` registry to `SendAgentMessageTool` +2. Rewrite `call()` to create a task in the target agent's store +3. Add link channel system message logging on task creation +4. Update tool description prompt +5. Update `org_context.md.j2` wording + +### Phase 3: Task Completion Notifications + +1. Add `delegated_by` / `originating_channel` metadata checks to cortex task completion handler +2. Log completion in link channel +3. Inject retrigger system message into originating channel +4. Test full delegation round-trip + +### Phase 4: UI + Polish + +1. Link channel UI shows task timeline instead of conversation +2. Task board shows delegated tasks with source agent badge +3. SSE events for delegation activity +4. Dashboard topology graph shows task flow between agents + +## Open Questions + +**Per-agent vs instance-level task store**: Should delegated tasks live in the target agent's per-agent SQLite database, or should all tasks move to `instance.db`? Per-agent is cleaner for isolation but requires cross-agent store access. Instance-level is simpler but changes the storage model. + +**Task approval for delegated tasks**: Should agent-delegated tasks skip `pending_approval` and go straight to `ready`? The design assumes yes — if Agent A trusts Agent B enough to have a link, the task should execute without human approval. But some deployments might want human-in-the-loop for all delegated work. + +**Bidirectional task results**: When Agent B completes a task delegated by Agent A, should A get the full worker output or just a summary? Full output could be large (coding task transcripts). A summary is more practical but loses detail. Could store the full result in task metadata and show a summary in the retrigger message. + +**Multi-hop delegation**: Agent A delegates to Agent B, who delegates to Agent C. The completion notification needs to bubble up through all hops. Task metadata can track `delegation_chain: [A, B]` so C's completion notifies B, which notifies A. But this adds complexity — start with single-hop and extend later. + +**Task priority inheritance**: Should delegated tasks inherit priority from the delegating agent's context? If the user marked something urgent, the delegated task should probably be `high` priority. The LLM could set this explicitly in the `send_agent_message` call, or it could be inferred. diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 2683cc1e3..b675dcc20 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -566,6 +566,11 @@ export interface BrowserSection { evaluate_enabled: boolean; } +export interface SandboxSection { + mode: "enabled" | "disabled"; + writable_paths: string[]; +} + export interface DiscordSection { enabled: boolean; allow_bot_messages: boolean; @@ -580,6 +585,7 @@ export interface AgentConfigResponse { memory_persistence: MemoryPersistenceSection; browser: BrowserSection; discord: DiscordSection; + sandbox: SandboxSection; } // Partial update types - all fields are optional @@ -642,6 +648,11 @@ export interface BrowserUpdate { evaluate_enabled?: boolean; } +export interface SandboxUpdate { + mode?: "enabled" | "disabled"; + writable_paths?: string[]; +} + export interface DiscordUpdate { allow_bot_messages?: boolean; } @@ -656,6 +667,7 @@ export interface AgentConfigUpdateRequest { memory_persistence?: MemoryPersistenceUpdate; browser?: BrowserUpdate; discord?: DiscordUpdate; + sandbox?: SandboxUpdate; } // -- Cron Types -- @@ -864,6 +876,72 @@ export interface RegistrySearchResponse { count: number; } +// -- Task Types -- + +export type TaskStatus = "pending_approval" | "backlog" | "ready" | "in_progress" | "done"; +export type TaskPriority = "critical" | "high" | "medium" | "low"; + +export interface TaskSubtask { + title: string; + completed: boolean; +} + +export interface TaskItem { + id: string; + agent_id: string; + task_number: number; + title: string; + description?: string; + status: TaskStatus; + priority: TaskPriority; + subtasks: TaskSubtask[]; + metadata: Record; + source_memory_id?: string; + worker_id?: string; + created_by: string; + approved_at?: string; + approved_by?: string; + created_at: string; + updated_at: string; + completed_at?: string; +} + +export interface TaskListResponse { + tasks: TaskItem[]; +} + +export interface TaskResponse { + task: TaskItem; +} + +export interface TaskActionResponse { + success: boolean; + message: string; +} + +export interface CreateTaskRequest { + title: string; + description?: string; + status?: TaskStatus; + priority?: TaskPriority; + subtasks?: TaskSubtask[]; + metadata?: Record; + source_memory_id?: string; + created_by?: string; +} + +export interface UpdateTaskRequest { + title?: string; + description?: string; + status?: TaskStatus; + priority?: TaskPriority; + subtasks?: TaskSubtask[]; + metadata?: Record; + complete_subtask?: number; + worker_id?: string; + approved_by?: string; +} + // -- Messaging / Bindings Types -- export interface PlatformStatus { @@ -1710,5 +1788,59 @@ export const api = { webChatHistory: (agentId: string, sessionId: string, limit = 100) => fetch(`${API_BASE}/webchat/history?agent_id=${encodeURIComponent(agentId)}&session_id=${encodeURIComponent(sessionId)}&limit=${limit}`), + // Tasks API + listTasks: (agentId: string, params?: { status?: TaskStatus; priority?: TaskPriority; limit?: number }) => { + const search = new URLSearchParams({ agent_id: agentId }); + if (params?.status) search.set("status", params.status); + if (params?.priority) search.set("priority", params.priority); + if (params?.limit) search.set("limit", String(params.limit)); + return fetchJson(`/agents/tasks?${search}`); + }, + getTask: (agentId: string, taskNumber: number) => + fetchJson(`/agents/tasks/${taskNumber}?agent_id=${encodeURIComponent(agentId)}`), + createTask: async (agentId: string, request: CreateTaskRequest): Promise => { + const response = await fetch(`${API_BASE}/agents/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...request, agent_id: agentId }), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, + updateTask: async (agentId: string, taskNumber: number, request: UpdateTaskRequest): Promise => { + const response = await fetch(`${API_BASE}/agents/tasks/${taskNumber}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...request, agent_id: agentId }), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, + deleteTask: async (agentId: string, taskNumber: number): Promise => { + const response = await fetch(`${API_BASE}/agents/tasks/${taskNumber}?agent_id=${encodeURIComponent(agentId)}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, + approveTask: async (agentId: string, taskNumber: number, approvedBy?: string): Promise => { + const response = await fetch(`${API_BASE}/agents/tasks/${taskNumber}/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId, approved_by: approvedBy }), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, + executeTask: async (agentId: string, taskNumber: number): Promise => { + const response = await fetch(`${API_BASE}/agents/tasks/${taskNumber}/execute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: agentId }), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, + eventsUrl: `${API_BASE}/events`, }; diff --git a/interface/src/components/AgentTabs.tsx b/interface/src/components/AgentTabs.tsx index c89ddd61a..1f0aaa8b4 100644 --- a/interface/src/components/AgentTabs.tsx +++ b/interface/src/components/AgentTabs.tsx @@ -8,6 +8,7 @@ const tabs = [ { label: "Memories", to: "/agents/$agentId/memories" as const, exact: false }, { label: "Ingest", to: "/agents/$agentId/ingest" as const, exact: false }, { label: "Workers", to: "/agents/$agentId/workers" as const, exact: false }, + { label: "Tasks", to: "/agents/$agentId/tasks" as const, exact: false }, { label: "Cortex", to: "/agents/$agentId/cortex" as const, exact: false }, { label: "Skills", to: "/agents/$agentId/skills" as const, exact: false }, { label: "Cron", to: "/agents/$agentId/cron" as const, exact: false }, diff --git a/interface/src/hooks/useLiveContext.tsx b/interface/src/hooks/useLiveContext.tsx index e64c7a765..edd6989b5 100644 --- a/interface/src/hooks/useLiveContext.tsx +++ b/interface/src/hooks/useLiveContext.tsx @@ -16,6 +16,8 @@ interface LiveContextValue { activeWorkers: Record; /** Monotonically increasing counter, bumped on every worker lifecycle SSE event. */ workerEventVersion: number; + /** Monotonically increasing counter, bumped on every task lifecycle SSE event. */ + taskEventVersion: number; /** Live transcript steps for running workers, keyed by worker_id. Built from SSE tool events. */ liveTranscripts: Record; } @@ -29,6 +31,7 @@ const LiveContext = createContext({ activeLinks: new Set(), activeWorkers: {}, workerEventVersion: 0, + taskEventVersion: 0, liveTranscripts: {}, }); @@ -57,6 +60,9 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { const [workerEventVersion, setWorkerEventVersion] = useState(0); const bumpWorkerVersion = useCallback(() => setWorkerEventVersion((v) => v + 1), []); + const [taskEventVersion, setTaskEventVersion] = useState(0); + const bumpTaskVersion = useCallback(() => setTaskEventVersion((v) => v + 1), []); + // Live transcript accumulator: builds TranscriptStep[] from SSE tool events // for running workers. Cleared when worker completes. const [liveTranscripts, setLiveTranscripts] = useState>({}); @@ -211,7 +217,7 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { } }, [channelHandlers, bumpWorkerVersion]); - // Merge channel handlers with agent message handlers + // Merge channel handlers with agent message + task handlers const handlers = useMemo( () => ({ ...channelHandlers, @@ -222,8 +228,9 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { tool_completed: wrappedToolCompleted, agent_message_sent: handleAgentMessage, agent_message_received: handleAgentMessage, + task_updated: bumpTaskVersion, }), - [channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleAgentMessage], + [channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleAgentMessage, bumpTaskVersion], ); const onReconnect = useCallback(() => { @@ -231,7 +238,10 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { queryClient.invalidateQueries({ queryKey: ["channels"] }); queryClient.invalidateQueries({ queryKey: ["status"] }); queryClient.invalidateQueries({ queryKey: ["agents"] }); - }, [syncStatusSnapshot, queryClient]); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + // Bump task version so any mounted task views refetch immediately. + bumpTaskVersion(); + }, [syncStatusSnapshot, queryClient, bumpTaskVersion]); const { connectionState } = useEventSource(api.eventsUrl, { handlers, @@ -242,7 +252,7 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { const hasData = channels.length > 0 || channelsData !== undefined; return ( - + {children} ); diff --git a/interface/src/router.tsx b/interface/src/router.tsx index c48ab7593..cc5fb0a9d 100644 --- a/interface/src/router.tsx +++ b/interface/src/router.tsx @@ -21,6 +21,7 @@ import {AgentCron} from "@/routes/AgentCron"; import {AgentIngest} from "@/routes/AgentIngest"; import {AgentSkills} from "@/routes/AgentSkills"; import {AgentWorkers} from "@/routes/AgentWorkers"; +import {AgentTasks} from "@/routes/AgentTasks"; import {AgentChat} from "@/routes/AgentChat"; import {Settings} from "@/routes/Settings"; import {useLiveContext} from "@/hooks/useLiveContext"; @@ -204,6 +205,22 @@ const agentWorkersRoute = createRoute({ }, }); +const agentTasksRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/agents/$agentId/tasks", + component: function AgentTasksPage() { + const {agentId} = agentTasksRoute.useParams(); + return ( +
+ +
+ +
+
+ ); + }, +}); + const agentCronRoute = createRoute({ getParentRoute: () => rootRoute, path: "/agents/$agentId/cron", @@ -307,6 +324,7 @@ const routeTree = rootRoute.addChildren([ agentMemoriesRoute, agentIngestRoute, agentWorkersRoute, + agentTasksRoute, agentCortexRoute, agentSkillsRoute, agentCronRoute, diff --git a/interface/src/routes/AgentConfig.tsx b/interface/src/routes/AgentConfig.tsx index 8655eb339..0c291c29e 100644 --- a/interface/src/routes/AgentConfig.tsx +++ b/interface/src/routes/AgentConfig.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api, type AgentConfigResponse, type AgentConfigUpdateRequest } from "@/api/client"; import { Button, SettingSidebarButton, Input, TextArea, Toggle, NumberStepper, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, cx } from "@/ui"; import { ModelSelect } from "@/components/ModelSelect"; +import { TagInput } from "@/components/TagInput"; import { Markdown } from "@/components/Markdown"; import { motion, AnimatePresence } from "framer-motion"; import { useSearch, useNavigate } from "@tanstack/react-router"; @@ -14,7 +15,7 @@ function supportsAdaptiveThinking(modelId: string): boolean { || id.includes("sonnet-4-6") || id.includes("sonnet-4.6"); } -type SectionId = "soul" | "identity" | "user" | "routing" | "tuning" | "compaction" | "cortex" | "coalesce" | "memory" | "browser"; +type SectionId = "soul" | "identity" | "user" | "routing" | "tuning" | "compaction" | "cortex" | "coalesce" | "memory" | "browser" | "sandbox"; const SECTIONS: { id: SectionId; @@ -33,6 +34,7 @@ const SECTIONS: { { id: "coalesce", label: "Coalesce", group: "config", description: "Message batching", detail: "When multiple messages arrive in quick succession, coalescing batches them into a single LLM turn. This prevents the agent from responding to each message individually in fast-moving conversations." }, { id: "memory", label: "Memory Persistence", group: "config", description: "Auto-save interval", detail: "Spawns a silent background branch at regular intervals to recall existing memories and save new ones from the recent conversation. Runs without blocking the channel." }, { id: "browser", label: "Browser", group: "config", description: "Chrome automation", detail: "Controls browser automation tools available to workers. When enabled, workers can navigate web pages, take screenshots, and interact with sites. JavaScript evaluation is a separate permission." }, + { id: "sandbox", label: "Sandbox", group: "config", description: "Process containment", detail: "OS-level filesystem containment for shell and exec tool subprocesses. When enabled, worker processes run inside a kernel-enforced sandbox (bubblewrap on Linux, sandbox-exec on macOS) that makes the entire filesystem read-only except for the workspace and any configured writable paths. On hosted deployments, sandbox mode is always enforced." }, ]; interface AgentConfigProps { @@ -62,7 +64,7 @@ export function AgentConfig({ agentId }: AgentConfigProps) { // Sync activeSection with URL search param useEffect(() => { if (search.tab) { - const validSections: SectionId[] = ["soul", "identity", "user", "routing", "tuning", "compaction", "cortex", "coalesce", "memory", "browser"]; + const validSections: SectionId[] = ["soul", "identity", "user", "routing", "tuning", "compaction", "cortex", "coalesce", "memory", "browser", "sandbox"]; if (validSections.includes(search.tab as SectionId)) { setActiveSection(search.tab as SectionId); } @@ -103,9 +105,32 @@ export function AgentConfig({ agentId }: AgentConfigProps) { const configMutation = useMutation({ mutationFn: (update: AgentConfigUpdateRequest) => api.updateAgentConfig(update), - onMutate: () => setSaving(true), + onMutate: (update) => { + setSaving(true); + // Optimistically merge the sent values into the cache so the UI + // reflects the change immediately (covers fields the backend + // doesn't yet return in its response, like sandbox). + const previous = queryClient.getQueryData(["agent-config", agentId]); + if (previous) { + const { agent_id: _, ...sections } = update; + const merged = { ...previous } as unknown as Record; + const prev = previous as unknown as Record; + for (const [key, value] of Object.entries(sections)) { + if (value !== undefined) { + merged[key] = { + ...(prev[key] as Record | undefined), + ...value, + }; + } + } + queryClient.setQueryData(["agent-config", agentId], merged as unknown as AgentConfigResponse); + } + }, onSuccess: (result) => { - queryClient.setQueryData(["agent-config", agentId], result); + // Merge server response with cache to preserve fields the backend + // doesn't yet return (e.g. sandbox). + const previous = queryClient.getQueryData(["agent-config", agentId]); + queryClient.setQueryData(["agent-config", agentId], { ...previous, ...result }); setDirty(false); setSaving(false); }, @@ -384,24 +409,30 @@ interface ConfigSectionEditorProps { onSave: (update: Partial) => void; } +const SANDBOX_DEFAULTS = { mode: "enabled" as const, writable_paths: [] as string[] }; + function ConfigSectionEditor({ sectionId, label, description, detail, config, onDirtyChange, saveHandlerRef, onSave }: ConfigSectionEditorProps) { - const [localValues, setLocalValues] = useState>(() => { + type ConfigValues = Record; + const sandbox = config.sandbox ?? SANDBOX_DEFAULTS; + const [localValues, setLocalValues] = useState(() => { // Initialize from config based on section switch (sectionId) { case "routing": - return { ...config.routing }; + return { ...config.routing } as ConfigValues; case "tuning": - return { ...config.tuning }; + return { ...config.tuning } as ConfigValues; case "compaction": - return { ...config.compaction }; + return { ...config.compaction } as ConfigValues; case "cortex": - return { ...config.cortex }; + return { ...config.cortex } as ConfigValues; case "coalesce": - return { ...config.coalesce }; + return { ...config.coalesce } as ConfigValues; case "memory": - return { ...config.memory_persistence }; + return { ...config.memory_persistence } as ConfigValues; case "browser": - return { ...config.browser }; + return { ...config.browser } as ConfigValues; + case "sandbox": + return { mode: sandbox.mode, writable_paths: sandbox.writable_paths } as ConfigValues; default: return {}; } @@ -438,11 +469,14 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on case "browser": setLocalValues({ ...config.browser }); break; + case "sandbox": + setLocalValues({ mode: sandbox.mode, writable_paths: sandbox.writable_paths }); + break; } } }, [config, sectionId, localDirty]); - const handleChange = useCallback((field: string, value: string | number | boolean) => { + const handleChange = useCallback((field: string, value: string | number | boolean | string[]) => { setLocalValues((prev) => ({ ...prev, [field]: value })); setLocalDirty(true); }, []); @@ -475,6 +509,9 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on case "browser": setLocalValues({ ...config.browser }); break; + case "sandbox": + setLocalValues({ mode: sandbox.mode, writable_paths: sandbox.writable_paths }); + break; } setLocalDirty(false); }, [config, sectionId]); @@ -785,6 +822,36 @@ function ConfigSectionEditor({ sectionId, label, description, detail, config, on /> ); + case "sandbox": + return ( +
+
+ +

Kernel-enforced filesystem containment for shell and exec subprocesses. On hosted deployments this is always enforced regardless of this setting.

+ +
+
+ +

Additional directories workers can write to beyond the workspace. The workspace is always writable. Press Enter to add a path.

+ handleChange("writable_paths", paths)} + placeholder="/home/user/projects/myapp" + /> +
+
+ ); default: return null; } diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx new file mode 100644 index 000000000..e4d2adc09 --- /dev/null +++ b/interface/src/routes/AgentTasks.tsx @@ -0,0 +1,586 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, type TaskItem, type TaskStatus, type TaskPriority, type CreateTaskRequest } from "@/api/client"; +import { useLiveContext } from "@/hooks/useLiveContext"; +import { Badge } from "@/ui/Badge"; +import { Button } from "@/ui/Button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/ui/Dialog"; +import { formatTimeAgo } from "@/lib/format"; +import { AnimatePresence, motion } from "framer-motion"; + +const COLUMNS: { status: TaskStatus; label: string }[] = [ + { status: "pending_approval", label: "Pending Approval" }, + { status: "backlog", label: "Backlog" }, + { status: "ready", label: "Ready" }, + { status: "in_progress", label: "In Progress" }, + { status: "done", label: "Done" }, +]; + +const STATUS_COLORS: Record = { + pending_approval: "amber", + backlog: "default", + ready: "accent", + in_progress: "violet", + done: "green", +}; + +const PRIORITY_LABELS: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; + +const PRIORITY_COLORS: Record = { + critical: "red", + high: "amber", + medium: "default", + low: "outline", +}; + +export function AgentTasks({ agentId }: { agentId: string }) { + const queryClient = useQueryClient(); + const { taskEventVersion } = useLiveContext(); + + // Invalidate on SSE task events + const prevVersion = useRef(taskEventVersion); + useEffect(() => { + if (taskEventVersion !== prevVersion.current) { + prevVersion.current = taskEventVersion; + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + } + }, [taskEventVersion, agentId, queryClient]); + + const { data, isLoading } = useQuery({ + queryKey: ["tasks", agentId], + queryFn: () => api.listTasks(agentId, { limit: 200 }), + refetchInterval: 15_000, + }); + + const tasks = data?.tasks ?? []; + + // Group tasks by status + const tasksByStatus: Record = { + pending_approval: [], + backlog: [], + ready: [], + in_progress: [], + done: [], + }; + for (const task of tasks) { + tasksByStatus[task.status]?.push(task); + } + + // Create task dialog + const [createOpen, setCreateOpen] = useState(false); + // Detail dialog — store task number and derive from live list to stay current. + const [selectedTaskNumber, setSelectedTaskNumber] = useState(null); + const selectedTask = selectedTaskNumber !== null + ? tasks.find((t) => t.task_number === selectedTaskNumber) ?? null + : null; + + const createMutation = useMutation({ + mutationFn: (request: CreateTaskRequest) => api.createTask(agentId, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + setCreateOpen(false); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ taskNumber, ...request }: { taskNumber: number; status?: TaskStatus; priority?: TaskPriority }) => + api.updateTask(agentId, taskNumber, request), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + const approveMutation = useMutation({ + mutationFn: (taskNumber: number) => api.approveTask(agentId, taskNumber, "human"), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + const executeMutation = useMutation({ + mutationFn: (taskNumber: number) => api.executeTask(agentId, taskNumber), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (taskNumber: number) => api.deleteTask(agentId, taskNumber), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks", agentId] }); + setSelectedTaskNumber(null); + }, + }); + + if (isLoading) { + return ( +
+ Loading tasks... +
+ ); + } + + if (tasks.length === 0 && !createOpen) { + return ( +
+

No tasks yet

+ + setCreateOpen(false)} + onCreate={(request) => createMutation.mutate(request)} + isPending={createMutation.isPending} + /> +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ {tasks.length} task{tasks.length !== 1 ? "s" : ""} + {tasksByStatus.pending_approval.length > 0 && ( + + {tasksByStatus.pending_approval.length} pending approval + + )} + {tasksByStatus.in_progress.length > 0 && ( + + {tasksByStatus.in_progress.length} in progress + + )} +
+ +
+ + {/* Kanban Board */} +
+ {COLUMNS.map(({ status, label }) => ( + setSelectedTaskNumber(task.task_number)} + onApprove={(task) => approveMutation.mutate(task.task_number)} + onExecute={(task) => executeMutation.mutate(task.task_number)} + onStatusChange={(task, newStatus) => + updateMutation.mutate({ taskNumber: task.task_number, status: newStatus }) + } + /> + ))} +
+ + {/* Create Dialog */} + setCreateOpen(false)} + onCreate={(request) => createMutation.mutate(request)} + isPending={createMutation.isPending} + /> + + {/* Detail Dialog */} + {selectedTask && ( + setSelectedTaskNumber(null)} + onApprove={() => approveMutation.mutate(selectedTask.task_number)} + onExecute={() => executeMutation.mutate(selectedTask.task_number)} + onDelete={() => deleteMutation.mutate(selectedTask.task_number)} + onStatusChange={(status) => + updateMutation.mutate({ taskNumber: selectedTask.task_number, status }) + } + /> + )} +
+ ); +} + +// -- Kanban Column -- + +function KanbanColumn({ + status, + label, + tasks, + onSelect, + onApprove, + onExecute, + onStatusChange, +}: { + status: TaskStatus; + label: string; + tasks: TaskItem[]; + onSelect: (task: TaskItem) => void; + onApprove: (task: TaskItem) => void; + onExecute: (task: TaskItem) => void; + onStatusChange: (task: TaskItem, status: TaskStatus) => void; +}) { + return ( +
+ {/* Column Header */} +
+ + {label} + + {tasks.length} +
+ + {/* Cards */} +
+ + {tasks.map((task) => ( + onSelect(task)} + onApprove={() => onApprove(task)} + onExecute={() => onExecute(task)} + onStatusChange={(newStatus) => onStatusChange(task, newStatus)} + /> + ))} + + {tasks.length === 0 && ( +
+ No tasks +
+ )} +
+
+ ); +} + +// -- Task Card -- + +function TaskCard({ + task, + onSelect, + onApprove, + onExecute, + onStatusChange, +}: { + task: TaskItem; + onSelect: () => void; + onApprove: () => void; + onExecute: () => void; + onStatusChange: (status: TaskStatus) => void; +}) { + const subtasksDone = task.subtasks.filter((s) => s.completed).length; + const subtasksTotal = task.subtasks.length; + + return ( + + {/* Title row */} +
+ + #{task.task_number} {task.title} + +
+ + {/* Meta row */} +
+ + {PRIORITY_LABELS[task.priority]} + + {subtasksTotal > 0 && ( + + {subtasksDone}/{subtasksTotal} + + )} + {task.worker_id && ( + + Worker + + )} +
+ + {/* Subtask progress bar */} + {subtasksTotal > 0 && ( +
+
+
+ )} + + {/* Quick actions */} +
e.stopPropagation()}> + {task.status === "pending_approval" && ( + + )} + {(task.status === "backlog" || task.status === "pending_approval") && ( + + )} + {task.status === "in_progress" && ( + + )} +
+ + {/* Footer */} +
+ {formatTimeAgo(task.created_at)} by {task.created_by} +
+ + ); +} + +// -- Create Task Dialog -- + +function CreateTaskDialog({ + open, + onClose, + onCreate, + isPending, +}: { + open: boolean; + onClose: () => void; + onCreate: (request: CreateTaskRequest) => void; + isPending: boolean; +}) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState("medium"); + const [status, setStatus] = useState("backlog"); + + const handleSubmit = useCallback(() => { + if (!title.trim()) return; + onCreate({ + title: title.trim(), + description: description.trim() || undefined, + priority, + status, + }); + setTitle(""); + setDescription(""); + setPriority("medium"); + setStatus("backlog"); + }, [title, description, priority, status, onCreate]); + + return ( + !v && onClose()}> + + + Create Task + +
+
+ + setTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + autoFocus + /> +
+
+ +