From c69f591cace1625fac7436d2a9c8b20a2b9ae5ff Mon Sep 17 00:00:00 2001 From: DanilaZanin <74046158+DanilaZanin@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:48:53 +0300 Subject: [PATCH 01/11] feat(pipeline): agent pipelines, an opt-in memory graph and scheduled runs Single-agent chat handles a targeted edit well, but a larger task wants a shape: plan, write, review, test, and send the review back to the coder. Until now that meant driving every hand-off by hand. This adds a pipeline engine that runs those chains itself. Engine (src-tauri/src/pipeline/) - A linear step chain with bounded back-edges. Two presets ship: Duet (coder -> reviewer) and Team (planner -> coder -> reviewer -> tests), both looping back to the coder for up to three fix rounds. - Each step carries its role, agent, model, timeout, prompt template and a read-only flag. Prompts substitute $task, $plan, $summary, $review, $memory. - A read-only step that edits the tree is caught: the engine hashes the worktree before and after and downgrades the verdict to inconclusive. - Status transitions are compare-and-swap, every attempt carries its own id, and runs still open after a restart are closed as interrupted. - The graph is validated up front: no cycles, no unknown targets, at most eight steps, one to ten iterations. Verdicts - The pipeline_verdict MCP tool reports pass, changes_requested or inconclusive; notes are required when changes are requested. - When the tool is unavailable, a VERDICT: marker in the agent's own output is parsed instead. Isolation - Each run works in its own git worktree on a temporary branch, and lands through a squash or a merge commit when the user applies it. Interface - A mode switch in the composer (single, duet, team, custom) with role chips, remembered per folder. - A run card in the feed with step progress, round count and verdicts. - A "chat + code" diff panel: file tree, unified diff, line notes, and the actions to send those notes back for another round, stop for a manual fix, or apply the result. - A pipeline node on the canvas with a step inspector. Memory (opt-in, off by default) - Three backends: off, a local SQLite graph with FTS5 search and two-hop edge traversal, or an external MCP server. - What gets remembered is the user's choice: built-in kinds (decision, fixed bug, task summary, preference) plus their own, each set to automatic, on request, or off, scoped to the project or global. - memory_write, memory_search and memory_link are exposed only while memory is on, and everything written passes a secret redactor first. Automations - A run_pipeline action, so a pipeline can run on a schedule. Bugs found along the way and fixed here - A draft was lost when creating a conversation from the canvas failed, and when a tab was switched before the debounce flushed. - Sending while a conversation was still being created dropped the message. - A delegated task could be completed by a stale child connection. - working_dir from tool arguments was not checked for being an absolute, existing directory. - Permissive CORS applied to every route rather than the public ones, and backup uploads had no size cap. --- README.md | 7 + docs/agent-pipelines.md | 222 +++ docs/readme/README.zh-CN.md | 7 + package.json | 1 + scripts/check-i18n-parity.mjs | 93 ++ src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/acp/connection.rs | 1232 ++++++++------ src-tauri/src/acp/delegation/broker.rs | 197 ++- src-tauri/src/acp/delegation/companion.rs | 381 ++++- src-tauri/src/acp/delegation/listener.rs | 432 ++++- src-tauri/src/acp/delegation/spawner.rs | 2 + src-tauri/src/acp/delegation/tool_schema.json | 76 + src-tauri/src/acp/delegation/transport.rs | 88 + src-tauri/src/acp/lifecycle.rs | 12 +- src-tauri/src/acp/manager.rs | 33 +- src-tauri/src/acp/memory_tools.rs | 98 ++ src-tauri/src/acp/mod.rs | 2 + src-tauri/src/acp/pipeline_tools.rs | 29 + src-tauri/src/app_error.rs | 2 +- src-tauri/src/automation/engine.rs | 84 + src-tauri/src/bin/codeg_mcp.rs | 68 +- src-tauri/src/bin/codeg_server.rs | 17 + src-tauri/src/commands/canvas.rs | 1 + src-tauri/src/commands/chat_authoring.rs | 1 + src-tauri/src/commands/folders.rs | 210 +++ src-tauri/src/commands/memory.rs | 419 +++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/pipeline.rs | 478 ++++++ src-tauri/src/db/entities/automation.rs | 4 +- src-tauri/src/db/entities/canvas_node.rs | 3 + src-tauri/src/db/entities/memory_kind.rs | 22 + src-tauri/src/db/entities/mod.rs | 9 + src-tauri/src/db/entities/pipeline.rs | 31 + src-tauri/src/db/entities/pipeline_attempt.rs | 42 + src-tauri/src/db/entities/pipeline_run.rs | 50 + src-tauri/src/db/entities/prelude.rs | 4 + .../src/db/migration/m20260211_000001_init.rs | 7 +- .../db/migration/m20260920_000001_pipeline.rs | 299 ++++ .../m20260920_000002_canvas_node_pipeline.rs | 52 + .../migration/m20260920_000004_memory_kind.rs | 85 + .../m20260920_000005_memory_graph.rs | 22 + src-tauri/src/db/migration/mod.rs | 8 + src-tauri/src/db/mod.rs | 71 +- src-tauri/src/db/service/canvas_service.rs | 35 +- .../src/db/service/memory_kind_service.rs | 207 +++ src-tauri/src/db/service/mod.rs | 2 + src-tauri/src/db/service/pipeline_service.rs | 922 ++++++++++ src-tauri/src/lib.rs | 46 +- src-tauri/src/memory/backend.rs | 93 ++ src-tauri/src/memory/mcp_proxy.rs | 248 +++ src-tauri/src/memory/mod.rs | 89 + src-tauri/src/memory/sanitize.rs | 96 ++ src-tauri/src/memory/sqlite.rs | 489 ++++++ src-tauri/src/memory/tests.rs | 476 ++++++ src-tauri/src/memory/tools.rs | 264 +++ src-tauri/src/models/automation.rs | 6 + src-tauri/src/models/canvas.rs | 2 + src-tauri/src/models/memory.rs | 70 + src-tauri/src/models/mod.rs | 8 + src-tauri/src/models/pipeline.rs | 208 +++ src-tauri/src/office_watch/mod.rs | 2 +- src-tauri/src/paths.rs | 17 + src-tauri/src/pipeline/e2e_tests.rs | 266 +++ src-tauri/src/pipeline/engine.rs | 1476 +++++++++++++++++ src-tauri/src/pipeline/git_status.rs | 80 + src-tauri/src/pipeline/mod.rs | 9 + src-tauri/src/pipeline/presets.rs | 143 ++ src-tauri/src/pipeline/tests.rs | 843 ++++++++++ src-tauri/src/pipeline/validate.rs | 344 ++++ src-tauri/src/pipeline/verdict.rs | 143 ++ src-tauri/src/web/auth.rs | 75 +- src-tauri/src/web/event_bridge.rs | 33 + src-tauri/src/web/handlers/backup.rs | 36 +- src-tauri/src/web/handlers/files.rs | 24 +- src-tauri/src/web/handlers/memory.rs | 92 + src-tauri/src/web/handlers/mod.rs | 2 + src-tauri/src/web/handlers/pipeline.rs | 109 ++ src-tauri/src/web/router.rs | 82 +- src/app/settings/memory/page.tsx | 5 + .../automations/automation-editor.test.tsx | 376 +++++ .../automations/automation-editor.tsx | 81 +- .../automations/automations-page.test.tsx | 19 + .../automations/automations-page.tsx | 6 + src/components/canvas/add-node-menu.tsx | 22 + .../canvas-conversation-surface.test.tsx | 313 ++++ .../canvas/canvas-conversation-surface.tsx | 46 +- src/components/canvas/canvas-model.test.ts | 53 + src/components/canvas/canvas-model.ts | 41 + src/components/canvas/canvas-view.tsx | 75 +- .../canvas/nodes/note-node.test.tsx | 167 ++ src/components/canvas/nodes/note-node.tsx | 6 +- .../canvas/nodes/pipeline-node.test.tsx | 183 ++ src/components/canvas/nodes/pipeline-node.tsx | 306 ++++ .../canvas/pipeline-step-inspector.test.tsx | 211 +++ .../canvas/pipeline-step-inspector.tsx | 612 +++++++ .../composer/pipeline-mode-switch.test.tsx | 324 ++++ .../chat/composer/pipeline-mode-switch.tsx | 389 +++++ src/components/chat/conversation-shell.tsx | 202 ++- src/components/chat/message-input.test.tsx | 195 +++ src/components/chat/message-input.tsx | 138 +- .../chat/pipeline-diff-file-tree.test.tsx | 132 ++ .../chat/pipeline-diff-file-tree.tsx | 407 +++++ .../chat/pipeline-diff-panel.test.tsx | 231 +++ src/components/chat/pipeline-diff-panel.tsx | 676 ++++++++ .../chat/pipeline-flow.integration.test.tsx | 635 +++++++ .../chat/pipeline-run-card.test.tsx | 402 +++++ src/components/chat/pipeline-run-card.tsx | 431 +++++ .../conversation-detail-panel.tsx | 24 + .../settings/memory-kind-dialog.test.tsx | 116 ++ .../settings/memory-kind-dialog.tsx | 240 +++ .../settings/memory-settings.test.tsx | 403 +++++ src/components/settings/memory-settings.tsx | 633 +++++++ src/components/settings/settings-shell.tsx | 7 + src/hooks/use-pipeline-run.test.ts | 77 + src/hooks/use-pipeline-run.ts | 73 + src/i18n/messages/ar.json | 148 +- src/i18n/messages/de.json | 148 +- src/i18n/messages/en.json | 148 +- src/i18n/messages/es.json | 148 +- src/i18n/messages/fr.json | 148 +- src/i18n/messages/ja.json | 148 +- src/i18n/messages/ko.json | 148 +- src/i18n/messages/pt.json | 148 +- src/i18n/messages/zh-CN.json | 148 +- src/i18n/messages/zh-TW.json | 148 +- src/lib/api.ts | 148 ++ src/lib/pipeline-graph-edit.test.ts | 341 ++++ src/lib/pipeline-graph-edit.ts | 394 +++++ src/lib/pipeline-mode-storage.test.ts | 116 ++ src/lib/pipeline-mode-storage.ts | 97 ++ src/lib/pipeline-notes.test.ts | 179 ++ src/lib/pipeline-notes.ts | 175 ++ src/lib/types.ts | 227 ++- src/stores/canvas-store.test.ts | 28 +- src/stores/canvas-store.ts | 14 +- src/test-utils/pipeline-fixtures.ts | 333 ++++ 137 files changed, 23082 insertions(+), 649 deletions(-) create mode 100644 docs/agent-pipelines.md create mode 100644 scripts/check-i18n-parity.mjs create mode 100644 src-tauri/src/acp/memory_tools.rs create mode 100644 src-tauri/src/acp/pipeline_tools.rs create mode 100644 src-tauri/src/commands/memory.rs create mode 100644 src-tauri/src/commands/pipeline.rs create mode 100644 src-tauri/src/db/entities/memory_kind.rs create mode 100644 src-tauri/src/db/entities/pipeline.rs create mode 100644 src-tauri/src/db/entities/pipeline_attempt.rs create mode 100644 src-tauri/src/db/entities/pipeline_run.rs create mode 100644 src-tauri/src/db/migration/m20260920_000001_pipeline.rs create mode 100644 src-tauri/src/db/migration/m20260920_000002_canvas_node_pipeline.rs create mode 100644 src-tauri/src/db/migration/m20260920_000004_memory_kind.rs create mode 100644 src-tauri/src/db/migration/m20260920_000005_memory_graph.rs create mode 100644 src-tauri/src/db/service/memory_kind_service.rs create mode 100644 src-tauri/src/db/service/pipeline_service.rs create mode 100644 src-tauri/src/memory/backend.rs create mode 100644 src-tauri/src/memory/mcp_proxy.rs create mode 100644 src-tauri/src/memory/mod.rs create mode 100644 src-tauri/src/memory/sanitize.rs create mode 100644 src-tauri/src/memory/sqlite.rs create mode 100644 src-tauri/src/memory/tests.rs create mode 100644 src-tauri/src/memory/tools.rs create mode 100644 src-tauri/src/models/memory.rs create mode 100644 src-tauri/src/models/pipeline.rs create mode 100644 src-tauri/src/pipeline/e2e_tests.rs create mode 100644 src-tauri/src/pipeline/engine.rs create mode 100644 src-tauri/src/pipeline/git_status.rs create mode 100644 src-tauri/src/pipeline/mod.rs create mode 100644 src-tauri/src/pipeline/presets.rs create mode 100644 src-tauri/src/pipeline/tests.rs create mode 100644 src-tauri/src/pipeline/validate.rs create mode 100644 src-tauri/src/pipeline/verdict.rs create mode 100644 src-tauri/src/web/handlers/memory.rs create mode 100644 src-tauri/src/web/handlers/pipeline.rs create mode 100644 src/app/settings/memory/page.tsx create mode 100644 src/components/automations/automation-editor.test.tsx create mode 100644 src/components/canvas/canvas-conversation-surface.test.tsx create mode 100644 src/components/canvas/nodes/note-node.test.tsx create mode 100644 src/components/canvas/nodes/pipeline-node.test.tsx create mode 100644 src/components/canvas/nodes/pipeline-node.tsx create mode 100644 src/components/canvas/pipeline-step-inspector.test.tsx create mode 100644 src/components/canvas/pipeline-step-inspector.tsx create mode 100644 src/components/chat/composer/pipeline-mode-switch.test.tsx create mode 100644 src/components/chat/composer/pipeline-mode-switch.tsx create mode 100644 src/components/chat/pipeline-diff-file-tree.test.tsx create mode 100644 src/components/chat/pipeline-diff-file-tree.tsx create mode 100644 src/components/chat/pipeline-diff-panel.test.tsx create mode 100644 src/components/chat/pipeline-diff-panel.tsx create mode 100644 src/components/chat/pipeline-flow.integration.test.tsx create mode 100644 src/components/chat/pipeline-run-card.test.tsx create mode 100644 src/components/chat/pipeline-run-card.tsx create mode 100644 src/components/settings/memory-kind-dialog.test.tsx create mode 100644 src/components/settings/memory-kind-dialog.tsx create mode 100644 src/components/settings/memory-settings.test.tsx create mode 100644 src/components/settings/memory-settings.tsx create mode 100644 src/hooks/use-pipeline-run.test.ts create mode 100644 src/hooks/use-pipeline-run.ts create mode 100644 src/lib/pipeline-graph-edit.test.ts create mode 100644 src/lib/pipeline-graph-edit.ts create mode 100644 src/lib/pipeline-mode-storage.test.ts create mode 100644 src/lib/pipeline-mode-storage.ts create mode 100644 src/lib/pipeline-notes.test.ts create mode 100644 src/lib/pipeline-notes.ts create mode 100644 src/test-utils/pipeline-fixtures.ts diff --git a/README.md b/README.md index 518d451745..9d49e0694d 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ And when an agent spawns sub-agents of its own — Claude Code, Codex, Grok and ![Delegating a task to sub-agents from a single Codeg conversation](./docs/images/collaboration-light.gif#gh-light-mode-only) ![Delegating a task to sub-agents from a single Codeg conversation](./docs/images/collaboration-dark.gif#gh-dark-mode-only) +## 🔁 Agent Pipelines + +Compose agents into structured, multi-step execution chains with automated review and test loops. Choose **Duet** to have a coder write code and a reviewer verify it, or **Team** to run planning, coding, reviewing, and testing in sequence. When a reviewer or test step requests changes, the pipeline routes findings back to the coder for a new iteration, up to a round limit you configure. + +Each pipeline run executes in its own isolated Git worktree so your working tree stays clean. Inspect the interactive **Chat + code** diff panel to leave line-by-line notes for the next iteration or apply changes directly via squash or merge commit when you're satisfied. Optional local SQLite or external MCP memory lets agents recall architecture decisions, bug fixes, and project conventions across runs. → [Agent Pipelines](docs/agent-pipelines.md) + ## ✅ To-dos Not every job needs you watching it. Write one down — a title, a description, the agent to run it with — and Codeg hands it **its own copy of the code**: a git worktree beside your project, on its own branch. Several run at once without touching each other, or the tree you're working in. Schedule one for tonight, or let a folder work through its queue on its own, up to a concurrency limit you set. @@ -154,6 +160,7 @@ Step away from your desk, not your work. The native iOS and Android clients conn - **[Conversation Aggregation](https://docs.codeg.app/guide/aggregation)** — import sessions from every supported agent into one unified, searchable workspace, and pick any of them up where you left off - **[Multi-Agent Collaboration](https://docs.codeg.app/guide/multi-agent)** — `@`-mention any agent to delegate: sub-agents of different types run as their own sessions, in parallel, inside a single task +- **[Agent Pipelines](docs/agent-pipelines.md)** — orchestrate agents in multi-step chains (Duet, Team, Custom) with automated review loops, worktree isolation, line notes, and persistent memory - **[To-dos](https://docs.codeg.app/guide/tasks)** — write down what needs doing and agents work through the queue, each task in its own worktree, landing on your branch only after you've reviewed it - **[Custom Agents](https://docs.codeg.app/guide/custom-agents)** — register any other ACP-compatible agent from the public registry or its distribution JSON; Codeg installs it, records its history, and treats it like a built-in - **[The Workspace](https://docs.codeg.app/guide/workspace)** — the full engineering loop next to the agent: file tree, editor and diff, git changes, commit, an embedded terminal, and [several folders linked into one workspace](https://docs.codeg.app/guide/workspace#work-across-several-folders) diff --git a/docs/agent-pipelines.md b/docs/agent-pipelines.md new file mode 100644 index 0000000000..fc2ba6ed66 --- /dev/null +++ b/docs/agent-pipelines.md @@ -0,0 +1,222 @@ +# Agent Pipelines + +Agent Pipelines provide structured, multi-step orchestration for AI coding agents in Codeg. Pipelines allow you to compose agents into linear execution chains with automated review and testing feedback loops, worktree isolation, line-by-line diff inspections, and optional persistent memory. + +Everything in Agent Pipelines is opt-in. By default, Codeg operates in Single Agent mode with memory disabled, preserving the standard interactive workflow. + +--- + +## 1. Overview and Modes + +A pipeline consists of a linear chain of execution steps with bounded back-edges (`LoopBack`) originating from reviewer or test steps. When a reviewer or test step requests changes, the pipeline routes feedback back to the coder step for a new iteration, up to a configurable maximum round limit. + +Codeg provides three built-in modes and support for custom graphs: + +| Mode | Flow | Default Fix Rounds | Description | +| --- | --- | --- | --- | +| **Single** | Single agent | N/A | Classic single-agent workflow without pipeline orchestration. Default mode. | +| **Duet** | Coder → Reviewer | Up to 3 | Coder implements changes, Reviewer verifies code. Loops back on requested changes. | +| **Team** | Planner → Coder → Reviewer → Tests | Up to 3 | Comprehensive workflow: plan generation, implementation, review, and test execution. | +| **Custom** | User-defined graph | Configurable (1-10) | Custom multi-step graph with customized roles, prompts, agents, models, and loops. | + +--- + +## 2. Composer Mode Selection + +You can select the active pipeline mode directly inside the chat composer using the `PipelineModeSwitch` toolbar: + +1. **Mode Switcher**: Choose between **Single**, **Duet**, **Team**, or **Custom**. The selection is stored per project folder in `localStorage` (`codeg.pipeline.mode.`). +2. **Role Chips**: When Duet, Team, or Custom is active, role chips show the configured agent and model for each step in the chain. +3. **Model Selection**: Each role chip displays the assigned model. If an agent does not report a verified model, Codeg displays an unconfirmed model indicator. +4. **Fix Rounds**: A counter shows the maximum number of correction rounds (e.g., "Fix rounds: up to 3"). + +--- + +## 3. Step Definitions and Roles + +Each step in a pipeline graph is defined by the `PipelineStep` model: + +- `id`: Unique step identifier (lowercase alphanumeric, hyphens, underscores; up to 32 characters). +- `role`: One of `planner`, `coder`, `reviewer`, `tests`, or `custom`. +- `label`: Human-readable label displayed in UI cards and canvas nodes. +- `agent_type`: Agent type identifier (e.g., `claude_code`, `codex`, `gemini`, or custom ACP agent). +- `mode_id`: Optional agent sub-mode (e.g., read-only planning mode for review steps). +- `config_values`: Key-value configuration options passed to the agent, including `"model"`. +- `prompt_template`: Markdown template defining step instructions, supporting variables. +- `timeout_secs`: Step execution timeout in seconds (default: 1800s / 30 minutes; range: 1 to 86,400s). +- `read_memory`: Boolean flag indicating whether to query and inject memory before the step. +- `read_only`: Boolean flag indicating whether the step must not modify files. + +### Prompt Variables + +Templates support the following substitution variables: + +- `$task`: The user prompt or task description entered in the composer. +- `$plan`: Summary generated by the most recent Planner step. +- `$summary`: Summary produced by the previous step attempt. +- `$review`: Notes and review findings from the most recent `changes_requested` verdict. +- `$memory`: Retrieved project or global memory context. + +### Read-Only Reviewer Guard + +Reviewer steps have `read_only: true` enabled by default. If the selected agent supports a read-only or plan mode, Codeg activates that mode. + +To guarantee that read-only steps do not alter repository state, the engine computes a SHA-256 hash of the working tree before and after the step. The hash covers: +1. The output of `git diff HEAD` (staged and unstaged tracked changes). +2. Sorted untracked file paths from `git ls-files --others --exclude-standard`. + +If the pre-step and post-step hashes differ, the engine automatically overrides the step verdict to `inconclusive` with `verdict_source = "guard"` and notes `"reviewer modified files"`. This prevents unauthorized modifications from bypassing coder steps. + +--- + +## 4. Verdicts and the `pipeline_verdict` Tool + +Reviewer and test steps conclude by emitting a verdict that determines whether execution advances, loops back, or pauses. + +### Verdict Types + +| Verdict | Behavior | +| --- | --- | +| `pass` | The step succeeded. Execution proceeds to the next step, or marks the pipeline run as `succeeded` if it was the final step. | +| `changes_requested` | Issues were found. Execution loops back to the target step (typically Coder) with reviewer notes. Iteration count increments. | +| `inconclusive` | The agent could not reach a decision or encountered an ambiguity. The pipeline halts immediately with status `inconclusive` without looping. | + +### MCP Tool: `pipeline_verdict` + +For agents running with MCP support, Codeg provides the `pipeline_verdict` tool: + +```json +{ + "name": "pipeline_verdict", + "description": "Report your verdict for the pipeline step you are running (review or tests). Call it exactly once, right before you finish. pass = the work is correct and complete; changes_requested = the coder must fix something (notes are REQUIRED and must list every problem with file and line); inconclusive = you could not judge (say why in notes). If you never call this tool the step is treated as inconclusive.", + "inputSchema": { + "type": "object", + "required": ["verdict"], + "properties": { + "verdict": { + "type": "string", + "enum": ["pass", "changes_requested", "inconclusive"] + }, + "notes": { + "type": "string", + "description": "Findings for the coder or the reason the verdict is inconclusive." + } + } + } +} +``` + +When `verdict` is `changes_requested`, the `notes` parameter is required and must detail the problems with file paths and line numbers. + +### Fallback Text Marker Parsing + +If an agent does not support MCP tools or fails to invoke `pipeline_verdict`, the engine scans the final lines of the agent output for a text marker: + +```text +VERDICT: PASS +VERDICT: CHANGES_REQUESTED +VERDICT: FAIL +VERDICT: INCONCLUSIVE +``` + +`FAIL` is treated as equivalent to `CHANGES_REQUESTED`. If no MCP tool call or text marker is found, the engine defaults the step to `inconclusive`. + +--- + +## 5. Worktree Isolation and Merging + +Pipeline runs support two isolation modes: + +1. **`worktree_per_run` (Default)**: Creates an isolated Git worktree on a dedicated temporary branch. The agent makes changes in this worktree without touching your working directory or active branch. +2. **`shared_in_root`**: Runs directly in the project root folder. + +### Applying Changes + +When a pipeline run in worktree isolation reaches `succeeded`, a card in the chat view displays the completion status along with diff summary actions. You can inspect the changes and apply them to your active branch via `pipeline_run_apply`: + +- **Squash merge (`squash`)**: Collapses all iteration commits into a single clean commit. +- **Merge commit (`no_ff`)**: Retains individual iteration commit history. + +--- + +## 6. Chat + Code Diff Review Panel + +Clicking **Open code** on any pipeline run card opens the interactive **Chat + code** diff panel (`PipelineDiffPanel`): + +- **File Tree**: Lists modified, added, deleted, and renamed files with insertion and deletion counts. +- **Interactive Unified Diff**: Displays file diffs with syntax highlighting. +- **Inline Line Notes**: Click on any diff line to add notes for the coder (e.g., `src/auth.ts:42: Missing null check on token`). +- **Action Buttons**: + - **Send to coder (`sendToCoder`)**: Submits line notes and invokes `pipeline_request_changes` to start a new coder iteration. + - **I will fix it myself (`fixMyself`)**: Invokes `pipeline_stop_manual` to cancel the automated run while preserving the worktree for manual edits. + - **Apply changes (`apply`)**: Invokes `pipeline_run_apply` to merge the completed worktree into your base branch. + +--- + +## 7. Canvas Pipeline Node + +Pipelines can be designed and inspected visually on the Codeg Canvas: + +- **Pipeline Canvas Node**: Represents a pipeline execution graph using nested React Flow nodes, showing step sequence, role badges, verdict checkpoints, and feedback loopback edges. +- **Step Inspector (`PipelineStepInspector`)**: Modify step label, role, agent, model, prompt template, timeout, memory reading, and loop limits. +- **Save as Preset**: Save customized canvas pipeline graphs as reusable presets available in the composer. + +--- + +## 8. Automations Integration + +Agent pipelines integrate with Codeg Automations via the `RunPipeline` action: + +- Set `action: "run_pipeline"` in the automation configuration. +- Assign a `pipeline_id` referencing a saved preset. +- Run pipelines headlessly on cron schedules or trigger them on demand. + +--- + +## 9. Memory Subsystem + +Codeg includes an optional memory subsystem that enables agents to record and retrieve project knowledge, architectural decisions, and bug resolutions across pipeline runs. + +### Storage Backends + +Memory backend is configured in **Settings → Memory**: + +1. **`off` (Default)**: Memory tools and storage are disabled. No tools are provided to agents. +2. **`local_sqlite`**: Local SQLite database (`memory.db` in the application data directory) using SQLite FTS5 for full-text BM25 search and graph edge traversal up to 2 hops. +3. **`external_mcp`**: Proxies memory read, write, and link requests to a custom user-configured MCP server. + +### Memory Kinds and Recording Modes + +Memory records are categorized by `MemoryKind`: + +| Kind Key | Name | Default Mode | Description | +| --- | --- | --- | --- | +| `decision` | Decisions | `auto` | Architectural and product decisions, rationale, and rejected alternatives. | +| `fixed_bug` | Fixed bugs | `auto` | Bugs resolved during execution (symptom, root cause, and fix). | +| `task_summary` | Task summaries | `on_request` | Summary of task changes and verification evidence. | +| `preference` | Facts and preferences | `on_request` | Persistent conventions, tooling preferences, and project rules. | + +Users can create custom memory kinds with dedicated model instructions. + +### Operating Modes + +- **`auto`**: Direct writes to this kind are accepted without requiring a separate user request confirmation. +- **`on_request`**: Writes to this kind require the `user_requested: true` flag on the write request. +- **`off`**: The kind is disabled. + +### Scopes + +- **`project`**: Scoped to the active project folder. +- **`global`**: Accessible across all projects in Codeg. + +### MCP Tools for Memory + +When memory is active, Codeg exposes three MCP tools to agents: + +1. **`memory_write`**: Write a new record (`kind`, `title`, `body`, and optional relational `links` to other memory IDs). +2. **`memory_search`**: Search the memory graph using a `query` string and optional result `limit` (1 to 50). +3. **`memory_link`**: Connect two existing records (`from_id`, `to_id`) with a relationship type (`caused_by`, `fixed_by`, `relates_to`, `part_of`, or `supersedes`). + +### Secret Sanitization + +Before any record is persisted to memory, the `sanitize_secrets` engine runs regex-based redaction across the title, body, and metadata. Common API keys, tokens, passwords, and private key headers (OpenAI, Anthropic, GitHub tokens, AWS credentials, Slack tokens, Bearer tokens, private keys) are replaced with `[REDACTED]`. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 8d163c8a1b..1ab30dd276 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -105,6 +105,12 @@ Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · Cod ![在单个 Codeg 会话中将任务委派给子智能体](../images/collaboration-light.gif#gh-light-mode-only) ![在单个 Codeg 会话中将任务委派给子智能体](../images/collaboration-dark.gif#gh-dark-mode-only) +## 🔁 智能体流水线 + +将多个智能体编排为结构化的多步骤执行链,内置自动化的代码审阅与测试反馈闭环。选择 **双人结对** 让编码者写代码、审阅者核验,或选择 **团队协作** 依次执行规划、编码、审阅与测试。当审阅或测试步骤提出修改意见时,流水线会将具体问题打回给编码者开启新一轮修复,直到达到设定的轮次上限。 + +每次流水线运行都在独立的 Git 工作树中进行,绝不污染你当前的工作目录。通过 **聊天 + 代码** 差异面板逐行添加批注并直接交给编码者,满意后一键 Squash 或合并提交应用变更。可选的本地 SQLite 或外部 MCP 记忆系统还能让智能体在多次运行间持久化记录架构决策、历史缺陷与项目偏好。→ [智能体流水线](../agent-pipelines.md) + ## ✅ 待办任务 不是每件事都得你盯着做完。写下来就行——标题、说明、用哪个智能体跑——Codeg 会给它**一份独立的代码副本**:项目旁边的一个 git 工作树,跑在自己的分支上。几个任务同时开工也互不干扰,更不会碰你手头那份代码。可以约在今晚开始,也可以让某个文件夹自己按并发上限一件件处理下去。 @@ -154,6 +160,7 @@ Claude Code · Codex · Gemini · OpenClaw · OpenCode · Cline · Hermes · Cod - **[会话聚合](https://docs.codeg.app/zh/guide/aggregation)** — 把所有受支持智能体的会话导入统一、可搜索的工作区,并从上次中断处继续 - **[多智能体协作](https://docs.codeg.app/zh/guide/multi-agent)** — `@` 提及任意智能体即可委派:不同类型的子智能体各自作为独立会话,在同一个任务内并行运行 +- **[智能体流水线](../agent-pipelines.md)** — 将智能体编排为多步骤流水线(双人结对、团队协作、自定义),支持自动化审阅闭环、工作树隔离、代码行批注与持久化记忆 - **[待办任务](https://docs.codeg.app/zh/guide/tasks)** — 把要做的事写下来,智能体一件件做完;每个任务在自己的工作树里跑,只有你验收之后才会合进你的分支 - **[自定义智能体](https://docs.codeg.app/zh/guide/custom-agents)** — 从公开注册表或 distribution JSON 注册任何其它兼容 ACP 的智能体;Codeg 负责安装、记录历史,并像内置智能体一样对待它 - **[工作区](https://docs.codeg.app/zh/guide/workspace)** — 智能体旁边就是完整的工程闭环:文件树、编辑器与 diff、Git 变更、提交、内置终端,以及[挂进同一个工作区的多个文件夹](https://docs.codeg.app/zh/guide/workspace#work-across-several-folders) diff --git a/package.json b/package.json index 51d99b66ba..5ff0dc00ce 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "tauri:prepare-sidecars": "node src-tauri/scripts/prepare-sidecars.mjs", "tauri:before-dev": "pnpm tauri:prepare-sidecars && pnpm dev", "tauri:before-build": "pnpm build && pnpm tauri:prepare-sidecars", + "i18n:check": "node scripts/check-i18n-parity.mjs", "postinstall": "node -e \"const fs=require('fs');fs.cpSync('node_modules/monaco-editor/min/vs','public/vs',{recursive:true,force:true});const p='public/vs/loader.js';fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/\\n\\/\\/# sourceMappingURL=.*/,''))\"" }, "dependencies": { diff --git a/scripts/check-i18n-parity.mjs b/scripts/check-i18n-parity.mjs new file mode 100644 index 0000000000..2b539a2525 --- /dev/null +++ b/scripts/check-i18n-parity.mjs @@ -0,0 +1,93 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const MESSAGES_DIR = path.resolve(__dirname, "../src/i18n/messages") +const EN_FILE = path.join(MESSAGES_DIR, "en.json") + +function collectKeys(node, prefix = "") { + if (typeof node !== "object" || node === null) { + return [prefix] + } + const out = [] + for (const [key, value] of Object.entries(node)) { + const next = prefix ? `${prefix}.${key}` : key + out.push(...collectKeys(value, next)) + } + return out +} + +function run() { + if (!fs.existsSync(EN_FILE)) { + console.error(`en.json not found at ${EN_FILE}`) + process.exit(1) + } + + let enContent + try { + enContent = JSON.parse(fs.readFileSync(EN_FILE, "utf8")) + } catch (err) { + console.error(`Failed to parse en.json: ${err.message}`) + process.exit(1) + } + + const enKeys = new Set(collectKeys(enContent)) + console.log(`Reference (en.json): ${enKeys.size} recursive keys`) + + const files = fs + .readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json") && f !== "en.json") + .sort() + + let hasDiscrepancy = false + + for (const file of files) { + const fullPath = path.join(MESSAGES_DIR, file) + let content + try { + content = JSON.parse(fs.readFileSync(fullPath, "utf8")) + } catch (err) { + console.error(`[FAIL] ${file}: Failed to parse JSON - ${err.message}`) + hasDiscrepancy = true + continue + } + + const localeKeys = new Set(collectKeys(content)) + const missing = [...enKeys].filter((k) => !localeKeys.has(k)) + const extra = [...localeKeys].filter((k) => !enKeys.has(k)) + + if (missing.length === 0 && extra.length === 0) { + console.log(`[PASS] ${file}: ${localeKeys.size} keys match en.json`) + } else { + hasDiscrepancy = true + console.error( + `[FAIL] ${file}: Discrepancies found (${localeKeys.size} keys vs ${enKeys.size} reference)` + ) + if (missing.length > 0) { + console.error(` Missing in ${file} (${missing.length}):`) + for (const k of missing) { + console.error(` - ${k}`) + } + } + if (extra.length > 0) { + console.error(` Extra in ${file} (${extra.length}):`) + for (const k of extra) { + console.error(` + ${k}`) + } + } + } + } + + if (hasDiscrepancy) { + console.error("\ni18n parity check failed.") + process.exit(1) + } + + console.log("\nAll locales are in perfect parity with en.json.") + process.exit(0) +} + +run() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd8490ba88..e0442d0ef1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1097,6 +1097,7 @@ dependencies = [ "tokio-util", "toml 0.8.2", "toml_edit 0.19.15", + "tower", "tower-http", "tracing", "tracing-appender", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f2b869a201..dc9c27828f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -122,6 +122,7 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s axum = { version = "0.8", features = ["ws", "multipart"] } async_zip = { version = "0.0.18", features = ["tokio", "deflate"] } tower-http = { version = "0.6", features = ["fs", "cors", "compression-gzip", "compression-br"] } +tower = { version = "0.5" } # Direct deps (already in the graph via axum) so the compression predicate can # name the exact types tower-http's `Predicate` trait is defined over. http = "1" diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index c6307bf70f..6182fd43bb 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -22,7 +22,9 @@ use sacp::schema::{ ToolCallContent, ToolCallLocation, ToolKind, WaitForTerminalExitRequest, WaitForTerminalExitResponse, WriteTextFileRequest, WriteTextFileResponse, }; -use sacp::schema::{HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio}; +use sacp::schema::{ + EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, +}; use sacp::util::MatchDispatch; use sacp::{ on_receive_notification, on_receive_request, Agent, Client, ConnectionTo, Dispatch, @@ -46,9 +48,8 @@ use crate::acp::terminal_runtime::{ }; use crate::acp::types::{ AcpEvent, AsyncTaskDelta, AsyncTaskUsage, AvailableCommandInfo, ConnectionInfo, - ConnectionStatus, GrokModelSpec, - PermissionOptionInfo, PlanEntryInfo, PromptCapabilitiesInfo, PromptInputBlock, - SessionConfigBooleanInfo, SessionConfigKindInfo, SessionConfigOptionInfo, + ConnectionStatus, GrokModelSpec, PermissionOptionInfo, PlanEntryInfo, PromptCapabilitiesInfo, + PromptInputBlock, SessionConfigBooleanInfo, SessionConfigKindInfo, SessionConfigOptionInfo, SessionConfigSelectGroupInfo, SessionConfigSelectInfo, SessionConfigSelectOptionInfo, SessionFailureRecord, SessionModeInfo, SessionModeStateInfo, ToolCallImageInfo, UserMessageBlock, @@ -254,14 +255,15 @@ pub(crate) fn cursor_force_enabled(value: Option<&str>) -> bool { /// Gated on the explicit `CURSOR_AUTH_MODE` knob (written by the Cursor panel), /// so legacy rows and operator-provided container env are left untouched. In /// custom mode the credentials are present and non-empty, so nothing is cleared. -fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { +fn apply_cursor_env_policy( + merged: &mut Vec<(String, String)>, + runtime_env: &BTreeMap, +) { if runtime_env.get("CURSOR_AUTH_MODE").map(String::as_str) != Some("subscription") { return; } for key in ["CURSOR_API_KEY", "CURSOR_API_BASE_URL"] { - let already_set = merged - .iter() - .any(|(k, v)| k == key && !v.trim().is_empty()); + let already_set = merged.iter().any(|(k, v)| k == key && !v.trim().is_empty()); if !already_set { merged.retain(|(k, _)| k != key); merged.push((key.to_string(), String::new())); @@ -278,14 +280,15 @@ fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTr /// sacp-tokio) to `env_remove` the inherited var. In api_key mode the key is /// present and non-empty, so nothing is cleared; legacy/no-mode rows are left /// untouched. -fn apply_grok_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { +fn apply_grok_env_policy( + merged: &mut Vec<(String, String)>, + runtime_env: &BTreeMap, +) { if runtime_env.get("GROK_AUTH_MODE").map(String::as_str) != Some("subscription") { return; } let key = "XAI_API_KEY"; - let already_set = merged - .iter() - .any(|(k, v)| k == key && !v.trim().is_empty()); + let already_set = merged.iter().any(|(k, v)| k == key && !v.trim().is_empty()); if !already_set { merged.retain(|(k, _)| k != key); merged.push((key.to_string(), String::new())); @@ -396,10 +399,8 @@ fn apply_antigravity_env_policy( }; let keep = antigravity_env_vars_for_method(method); for key in ANTIGRAVITY_CREDENTIAL_ENV_VARS { - let kept = keep.contains(key) - && merged - .iter() - .any(|(k, v)| k == key && !v.trim().is_empty()); + let kept = + keep.contains(key) && merged.iter().any(|(k, v)| k == key && !v.trim().is_empty()); if kept { continue; } @@ -810,9 +811,9 @@ pub fn antigravity_effective_auth_type( .filter(|value| !value.is_empty()) // The server resolves the legacy spelling before it tests membership, // so a caller matching on canonical ids would otherwise miss it. - .map(|value| AntigravityAuthType::Declared( - canonical_antigravity_auth_method(value).to_string(), - )) + .map(|value| { + AntigravityAuthType::Declared(canonical_antigravity_auth_method(value).to_string()) + }) .unwrap_or(AntigravityAuthType::Absent) } @@ -940,10 +941,7 @@ fn merge_antigravity_settings( // keeps a strange `gcp` from blocking an `auth.type` update when there // is nothing to say about it at all. Only a `Set` — which really would // have to replace that value — earns the refusal. - let clearable = clears - && obj - .get("gcp") - .is_some_and(serde_json::Value::is_object); + let clearable = clears && obj.get("gcp").is_some_and(serde_json::Value::is_object); if writes || clearable { match obj.get("gcp") { None | Some(serde_json::Value::Null) => { @@ -1579,9 +1577,7 @@ async fn record_turn_end( /// boolean — see `config_option_already_holds`). /// /// Used to carry a session's selectors across a fork. -fn current_config_option_values( - opts: &[SessionConfigOptionInfo], -) -> BTreeMap { +fn current_config_option_values(opts: &[SessionConfigOptionInfo]) -> BTreeMap { opts.iter() .map(|opt| { let value = match &opt.kind { @@ -1930,10 +1926,7 @@ async fn build_agent( let binary_path = match cached { Some((path, cached_version)) => { if cached_version == registry_version { - tracing::info!( - "[ACP][{}] Using cached binary {cached_version}", - meta.name - ); + tracing::info!("[ACP][{}] Using cached binary {cached_version}", meta.name); } else { tracing::info!( "[ACP][{}] Using cached binary {cached_version} (registry recommends {registry_version})", @@ -1945,12 +1938,12 @@ async fn build_agent( None => { let system = crate::commands::acp::resolve_system_agent_binary_for(agent_type, cmd) - .ok_or_else(|| { - AcpError::SdkNotInstalled(format!( - "{} is not installed. Please install it in Agent Settings.", - meta.name - )) - })?; + .ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + )) + })?; tracing::info!( "[ACP][{}] No cached binary; using system {} from PATH", meta.name, @@ -2061,11 +2054,8 @@ async fn build_agent( .unwrap_or(false); let agent_name = meta.name.to_string(); let tail = Arc::clone(stderr_tail); - Ok( - AcpAgent::new(sacp::schema::McpServer::Stdio(server)).with_debug( - agent_debug_callback(agent_name, tail, stdio_debug_enabled), - ), - ) + Ok(AcpAgent::new(sacp::schema::McpServer::Stdio(server)) + .with_debug(agent_debug_callback(agent_name, tail, stdio_debug_enabled))) } AgentDistribution::Uvx { package, @@ -2102,7 +2092,8 @@ async fn build_agent( // than provisioned through uvx. tracing::warn!( "[ACP][{}] uvx unavailable; falling back to system command {:?}", - meta.name, sys_path + meta.name, + sys_path ); // `system_cmd` is a complete launch recipe for the PATH binary; // the uvx entry-script `args` don't necessarily apply to it. @@ -2350,8 +2341,7 @@ pub async fn spawn_agent_connection( // Derived from the same `runtime_env` we hand the agent (minus per-launch // volatile keys) plus the agent's native config file content, so a later // settings save can be compared against it to detect a stale running session. - let config_fingerprint = - crate::commands::acp::fingerprint_config(agent_type, &runtime_env); + let config_fingerprint = crate::commands::acp::fingerprint_config(agent_type, &runtime_env); // Insert the entry BEFORE spawning the background task so that a // fast-failing `run_connection` can never remove it before it was @@ -2397,92 +2387,92 @@ pub async fn spawn_agent_connection( .spawn(move || { let _cleanup = cleanup_guard; connection_rt.block_on(async move { - let delegation_for_cleanup = delegation_injection.clone(); - let result = run_connection( - agent, - conn_id.clone(), - agent_type, - working_dir, - session_id, - cmd_rx, - emitter_clone.clone(), - Arc::clone(&state_clone), - terminal_base_env, - terminal_shell_config, - preferred_mode_id, - preferred_config_values, - delegation_injection, - fs_policy, - host_tools, - stderr_tail, - ) - .await; - - // Revoke the per-launch token + cascade cancel any still-pending - // delegations AND questions owned by this parent connection. All are - // best-effort: a missing token entry is a no-op, and both - // `cancel_by_parent` calls are safe on an empty pending map. - if let Some(inj) = delegation_for_cleanup { - let token = { - let snap = state_clone.read().await; - snap.delegation_token.clone() - }; - if let Some(tok) = token { - inj.tokens.revoke(&tok).await; - } - inj.broker.cancel_by_parent(&conn_id).await; - // Reclaim a parked `ask_user_question` instead of waiting for the - // companion's ask socket to close (which a reparented/hard-killed - // agent may never do); the dropped sender declines the tool cleanly. - inj.questions.cancel_questions_by_parent(&conn_id).await; - // Likewise reclaim a parked Grok `exit_plan_mode` approval; the - // dropped sender replies disconnect so grok keeps plan mode active. - inj.plan_approvals - .cancel_plan_approvals_by_parent(&conn_id) + let delegation_for_cleanup = delegation_injection.clone(); + let result = run_connection( + agent, + conn_id.clone(), + agent_type, + working_dir, + session_id, + cmd_rx, + emitter_clone.clone(), + Arc::clone(&state_clone), + terminal_base_env, + terminal_shell_config, + preferred_mode_id, + preferred_config_values, + delegation_injection, + fs_policy, + host_tools, + stderr_tail, + ) .await; - } - if let Err(e) = result { - let code = e.code().map(String::from); - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::Error { - message: e.to_string(), - agent_type: agent_type.to_string(), - code, - details: None, - // The only genuinely terminal emit site: `run_connection` - // is unwinding and the next event is `Disconnected`. - // The lifecycle worker uses this flag to decide whether - // to flip the conversation row to Cancelled and to - // buffer the detail for the broker's cancel reason. - terminal: true, - }, - ) - .await; - // Drive the state machine through `Error` before `Disconnected` - // so the frontend's error-handling effect (cancelled-on-error) - // engages — without this hop the connection would jump straight - // to Disconnected and look like a clean shutdown. - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::StatusChanged { - status: ConnectionStatus::Error, - }, - ) - .await; - } + // Revoke the per-launch token + cascade cancel any still-pending + // delegations AND questions owned by this parent connection. All are + // best-effort: a missing token entry is a no-op, and both + // `cancel_by_parent` calls are safe on an empty pending map. + if let Some(inj) = delegation_for_cleanup { + let token = { + let snap = state_clone.read().await; + snap.delegation_token.clone() + }; + if let Some(tok) = token { + inj.tokens.revoke(&tok).await; + } + inj.broker.cancel_by_parent(&conn_id).await; + // Reclaim a parked `ask_user_question` instead of waiting for the + // companion's ask socket to close (which a reparented/hard-killed + // agent may never do); the dropped sender declines the tool cleanly. + inj.questions.cancel_questions_by_parent(&conn_id).await; + // Likewise reclaim a parked Grok `exit_plan_mode` approval; the + // dropped sender replies disconnect so grok keeps plan mode active. + inj.plan_approvals + .cancel_plan_approvals_by_parent(&conn_id) + .await; + } - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::StatusChanged { - status: ConnectionStatus::Disconnected, - }, - ) - .await; + if let Err(e) = result { + let code = e.code().map(String::from); + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::Error { + message: e.to_string(), + agent_type: agent_type.to_string(), + code, + details: None, + // The only genuinely terminal emit site: `run_connection` + // is unwinding and the next event is `Disconnected`. + // The lifecycle worker uses this flag to decide whether + // to flip the conversation row to Cancelled and to + // buffer the detail for the broker's cancel reason. + terminal: true, + }, + ) + .await; + // Drive the state machine through `Error` before `Disconnected` + // so the frontend's error-handling effect (cancelled-on-error) + // engages — without this hop the connection would jump straight + // to Disconnected and look like a clean shutdown. + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::StatusChanged { + status: ConnectionStatus::Error, + }, + ) + .await; + } + + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::StatusChanged { + status: ConnectionStatus::Disconnected, + }, + ) + .await; // Connection loop ended; `block_on` returns and `_cleanup` // (bound at the top of the thread body) drops next, removing // the manager map entry — same as on a panic unwind. @@ -3617,9 +3607,8 @@ async fn send_steer_request( blocks: &[PromptInputBlock], ) -> Result { let params = build_steer_params(session_id.0.as_ref(), blocks); - let untyped_req = UntypedMessage::new("_session/steering", params).map_err(|e| { - AcpError::protocol(format!("Failed to build steering request: {e}")) - })?; + let untyped_req = UntypedMessage::new("_session/steering", params) + .map_err(|e| AcpError::protocol(format!("Failed to build steering request: {e}")))?; let raw = cx .send_request_to(Agent, untyped_req) .block_task() @@ -3662,9 +3651,8 @@ async fn send_stop_async_task_request( "sessionId": session_id.0.as_ref(), "asyncTaskId": task_id, }); - let untyped_req = UntypedMessage::new("_session/async_task/stop", params).map_err(|e| { - AcpError::protocol(format!("Failed to build async task stop request: {e}")) - })?; + let untyped_req = UntypedMessage::new("_session/async_task/stop", params) + .map_err(|e| AcpError::protocol(format!("Failed to build async task stop request: {e}")))?; let raw = cx .send_request_to(Agent, untyped_req) .block_task() @@ -4189,11 +4177,11 @@ fn build_client_capabilities( ) -> ClientCapabilities { let mut client_capabilities = ClientCapabilities::new(); if host_tools.hosts_channels() { - client_capabilities = client_capabilities.terminal(true).fs( - FileSystemCapabilities::new() + client_capabilities = client_capabilities + .terminal(true) + .fs(FileSystemCapabilities::new() .read_text_file(true) - .write_text_file(true), - ); + .write_text_file(true)); } // Form elicitation is advertised only to agents that are KNOWN to send // spec-conformant `elicitation/create` forms `classify_elicitation` can @@ -4219,7 +4207,10 @@ fn build_client_capabilities( // convention is to advertise nothing an agent hasn't implemented. let mut meta = serde_json::Map::new(); if agent_type == AgentType::ClaudeCode { - meta.insert("subagent-transcript".to_string(), serde_json::Value::Bool(true)); + meta.insert( + "subagent-transcript".to_string(), + serde_json::Value::Bool(true), + ); } // claude-agent-acp 0.73.0 added "asyncTasks", and codex-acp 1.10.0 joined // it, so BOTH are advertised. It publishes the lifecycle of an agent's @@ -4481,9 +4472,8 @@ async fn send_resume_session( cx: &ConnectionTo, req: ResumeSessionRequest, ) -> Result<(ResumeSessionResponse, Option), sacp::Error> { - let untyped_req = UntypedMessage::new("session/resume", req).map_err(|e| { - sacp::util::internal_error(format!("Failed to build resume request: {e}")) - })?; + let untyped_req = UntypedMessage::new("session/resume", req) + .map_err(|e| sacp::util::internal_error(format!("Failed to build resume request: {e}")))?; let mut raw_response = cx.send_request_to(Agent, untyped_req).block_task().await?; // Capture the raw top-level `models` (per-model reasoning-effort data) BEFORE @@ -4491,9 +4481,8 @@ async fn send_resume_session( // field survives serde as an ignored unknown for other agents). let models = raw_response.get("models").cloned(); strip_unknown_config_options(&mut raw_response, "session/resume"); - let resp = serde_json::from_value(raw_response).map_err(|e| { - sacp::util::internal_error(format!("Failed to parse resume response: {e}")) - })?; + let resp = serde_json::from_value(raw_response) + .map_err(|e| sacp::util::internal_error(format!("Failed to parse resume response: {e}")))?; Ok((resp, models)) } @@ -4823,6 +4812,12 @@ struct CompanionFeatureFlags { automations: bool, /// `create_work_task`, gated by the chat-authoring setting. taskboard: bool, + /// `pipeline_verdict`, per-spawn: only steps started by the pipeline engine + /// may report a verdict for their run. + pipeline: bool, + /// `memory_write` / `memory_search` / `memory_link`, per-spawn alongside the + /// pipeline group. + memory: bool, } /// The `--features` value for a companion launch, or `None` when no group is @@ -4853,6 +4848,12 @@ fn companion_features_arg(flags: CompanionFeatureFlags) -> Option { if flags.taskboard { features.push("taskboard"); } + if flags.pipeline { + features.push("pipeline"); + } + if flags.memory { + features.push("memory"); + } if features.is_empty() { return None; } @@ -4869,12 +4870,15 @@ struct CompanionInjection { delegation_enabled: bool, } +#[allow(clippy::too_many_arguments)] async fn inject_codeg_mcp( servers: &mut Vec, injection: &DelegationInjection, parent_connection_id: &str, working_dir: &Path, tasks_enabled: bool, + pipeline_enabled: bool, + memory_enabled: bool, host_tools: HostToolsPolicy, ) -> Option { inject_codeg_mcp_with_binary_locator( @@ -4883,18 +4887,23 @@ async fn inject_codeg_mcp( parent_connection_id, working_dir, tasks_enabled, + pipeline_enabled, + memory_enabled, host_tools, locate_codeg_mcp_binary, ) .await } +#[allow(clippy::too_many_arguments)] async fn inject_codeg_mcp_with_binary_locator( servers: &mut Vec, injection: &DelegationInjection, parent_connection_id: &str, working_dir: &Path, tasks_enabled: bool, + pipeline_enabled: bool, + memory_enabled: bool, host_tools: HostToolsPolicy, locate_binary: F, ) -> Option @@ -4937,7 +4946,10 @@ where // delegation is off, and it fails open: the spawn-time disabled check is // the hard gate either way. let disabled = if delegation_enabled { - injection.agent_availability.disabled_agent_wire_slugs().await + injection + .agent_availability + .disabled_agent_wire_slugs() + .await } else { Vec::new() }; @@ -4949,6 +4961,8 @@ where tasks: tasks_enabled, automations: authoring.automations_enabled, taskboard: authoring.work_tasks_enabled, + pipeline: pipeline_enabled, + memory: memory_enabled, }; // `None` (no feature enabled) short-circuits BEFORE the binary lookup, the // token registration and the server append: there is no companion to launch, @@ -4988,8 +5002,6 @@ where parent_connection_id.to_string(), "--socket-path".to_string(), injection.socket_path.to_string_lossy().to_string(), - "--token".to_string(), - token.clone(), // Self-cleanup watchdog: codeg-mcp exits when this PID is gone so // orphaned companions can't keep the binary file locked across an // installer upgrade (Windows) or hold a stale broker connection @@ -5008,7 +5020,9 @@ where args.push("--disabled-agents".to_string()); args.push(disabled_builtins.join(",")); } - server = server.args(args); + server = server + .args(args) + .env(vec![EnvVariable::new("CODEG_MCP_TOKEN", token.clone())]); servers.push(McpServer::Stdio(server)); Some(CompanionInjection { token, @@ -5719,12 +5733,20 @@ async fn run_connection( // task_progress / task_complete tool group. let tasks_enabled = { state.read().await.owner_window_label == "work_task" }; + // Pipeline steps report their verdict through the companion + // and may reach the memory graph; ordinary chats get neither. + let pipeline_enabled = + { state.read().await.owner_window_label == "pipeline" }; + // Memory tools are enabled by backend settings, not by window label. + let memory_enabled = crate::memory::is_memory_enabled().await; inject_codeg_mcp( &mut mcp_servers, inj, &conn_id, &cwd, tasks_enabled, + pipeline_enabled, + memory_enabled, host_tools, ) .await @@ -6793,9 +6815,9 @@ async fn try_bridge_pi_select_ask( ) .await; let outcome = match option_id { - Some(option_id) => { - RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)) - } + Some(option_id) => RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + ), None => RequestPermissionOutcome::Cancelled, }; let _ = responder.respond(RequestPermissionResponse::new(outcome)); @@ -6807,7 +6829,9 @@ async fn try_bridge_pi_select_ask( // the ask so the NEXT select can register, and unblock pi with the // cancel it would have gotten from the drained permission queue. None => { - questions.cancel_question(&connection_id, &question_id).await; + questions + .cancel_question(&connection_id, &question_id) + .await; let _ = responder.respond(RequestPermissionResponse::new( RequestPermissionOutcome::Cancelled, )); @@ -6845,8 +6869,7 @@ async fn handle_grok_exit_plan_mode( .map(|o| o.keys().map(String::as_str).collect::>()) ); let Some(access) = access else { - let _ = - responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + let _ = responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); return; }; let (plan_markdown, tool_call_id) = @@ -6868,8 +6891,7 @@ async fn handle_grok_exit_plan_mode( .await else { // Connection gone, or an approval is already pending on this connection. - let _ = - responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + let _ = responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); return; }; // The user answers out-of-band (the HTTP `answer_plan_approval` endpoint @@ -7058,11 +7080,11 @@ async fn handle_elicitation_request( let reaper_conn = connection_id.to_string(); let reaper_qid = registered.question_id.clone(); tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis( - ms.saturating_add(2_000), - )) - .await; - reaper_access.cancel_question(&reaper_conn, &reaper_qid).await; + tokio::time::sleep(std::time::Duration::from_millis(ms.saturating_add(2_000))) + .await; + reaper_access + .cancel_question(&reaper_conn, &reaper_qid) + .await; }); } // The user answers out-of-band (the `answer_question` endpoint @@ -7330,8 +7352,7 @@ async fn set_session_config_option( .and_then(|opts| opts.iter().find(|o| o.id == config_id)) .is_some_and(|o| matches!(o.kind, SessionConfigKindInfo::Boolean(_))); let value = encode_config_option_value(is_boolean, &value_id); - let updated = - set_session_config_option_inner(cx, session_id, config_id.clone(), value).await?; + let updated = set_session_config_option_inner(cx, session_id, config_id.clone(), value).await?; // Compare BEFORE emitting: the agent's answer is the only place a request and // its outcome are correlated. Once the option list is broadcast it is // indistinguishable from an unsolicited update. @@ -7449,9 +7470,10 @@ fn config_option_rejects_value(option: &SessionConfigOption, value: &str) -> boo // Grouped and ungrouped are one flat namespace here, the same way // `config_option_rejection` reads them. let mut advertised = match &select.options { - SessionConfigSelectOptions::Ungrouped(options) => { - options.iter().map(|o| o.value.to_string()).collect::>() - } + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(|o| o.value.to_string()) + .collect::>(), SessionConfigSelectOptions::Grouped(groups) => groups .iter() .flat_map(|group| group.options.iter().map(|o| o.value.to_string())) @@ -7608,7 +7630,9 @@ async fn apply_preferred_session_options( .unwrap_or(false); if needs_apply { if let Err(e) = set_session_mode(session, state, emitter, pref_mode.to_string()).await { - tracing::error!("[ACP] failed to apply preferred mode '{pref_mode}' on connect: {e}"); + tracing::error!( + "[ACP] failed to apply preferred mode '{pref_mode}' on connect: {e}" + ); } } } @@ -7634,10 +7658,7 @@ async fn apply_preferred_session_options( // still allowed; it just does not outlive the session. let (pinned, agent_type) = { let guard = state.read().await; - ( - guard.env_pinned_config_option_ids.clone(), - guard.agent_type, - ) + (guard.env_pinned_config_option_ids.clone(), guard.agent_type) }; let never_replayed = |config_id: &str| { pinned.iter().any(|id| id == config_id) @@ -8278,7 +8299,8 @@ async fn poll_tracked_terminal_tool_calls( Err(err) => { tracing::error!( "[ACP] Failed to poll terminal output for tool call {}: {:?}", - tool_call_id, err + tool_call_id, + err ); continue; } @@ -8617,7 +8639,8 @@ async fn handle_fork_or_exit( tracing::info!( "[ACP] Fork transition: attaching to forked session {} (original: {})", - new_sid, fork_info.original_session_id + new_sid, + fork_info.original_session_id ); tracing::info!( "[ACP] Fork inheriting selectors: mode={:?} config={:?}", @@ -8866,8 +8889,7 @@ fn classify_session_load_failure( // "The Claude Agent process exited unexpectedly…" // - "session has ended" → SESSION_ENDED_MESSAGE // - "Session not found" → a plain Error rethrown as an Internal error - const UNRECOVERABLE: &[&str] = - &["process exited", "session has ended", "Session not found"]; + const UNRECOVERABLE: &[&str] = &["process exited", "session has ended", "Session not found"]; if UNRECOVERABLE.iter().any(|s| message.contains(s)) { return Some("session_unavailable"); } @@ -9147,9 +9169,9 @@ impl EmptyTurnCause { "{agent_type} produced output that codeg could not parse — \ the agent version may not match the protocol." ), - EmptyTurnCause::MetadataOnly => format!( - "{agent_type} sent only status updates this turn and no reply." - ), + EmptyTurnCause::MetadataOnly => { + format!("{agent_type} sent only status updates this turn and no reply.") + } } } } @@ -9450,9 +9472,15 @@ async fn run_conversation_loop<'a>( ); let cx = session.connection(); let sid = session.session_id().clone(); - if let Err(e) = - set_session_config_option(&cx, &sid, state, emitter, config_id.clone(), value_id) - .await + if let Err(e) = set_session_config_option( + &cx, + &sid, + state, + emitter, + config_id.clone(), + value_id, + ) + .await { // Advisory: the agent is running what it pushed and has already // told the frontend so. Failing the connection over a selector @@ -10727,10 +10755,7 @@ fn build_new_file_diff(path: &str, new_text: &str) -> String { // it keeps the trailing empty segment from a final newline, so the `+N` // count and the trailing `+` addition line match exactly. let lines: Vec<&str> = new_text.split('\n').collect(); - let mut out = format!( - "--- /dev/null\n+++ b/{path}\n@@ -0,0 +1,{} @@", - lines.len() - ); + let mut out = format!("--- /dev/null\n+++ b/{path}\n@@ -0,0 +1,{} @@", lines.len()); for line in lines { out.push('\n'); out.push('+'); @@ -10744,7 +10769,9 @@ fn build_new_file_diff(path: &str, new_text: &str) -> String { /// on `AcpEvent::ToolCall(Update)` stays absent for non-image tool calls /// (preserves replace-on-update semantics: an absent field means "keep /// prior", a `Some(vec)` replaces). -pub(crate) fn extract_tool_call_images(content: &[ToolCallContent]) -> Option> { +pub(crate) fn extract_tool_call_images( + content: &[ToolCallContent], +) -> Option> { let mut imgs: Vec = Vec::new(); for item in content { if let ToolCallContent::Content(c) = item { @@ -11541,7 +11568,10 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static let is_report_item = |t: &serde_json::Value| { t.get("task_id").and_then(|x| x.as_str()).is_some() && t.get("status").and_then(|x| x.as_str()).is_some_and(|s| { - matches!(s, "running" | "completed" | "failed" | "canceled" | "unknown") + matches!( + s, + "running" | "completed" | "failed" | "canceled" | "unknown" + ) }) }; if !tasks.is_empty() && tasks.iter().all(is_report_item) { @@ -11593,7 +11623,10 @@ fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> /// historical unwrap in `parsers/codebuddy.rs`. `raw_input` is left untouched /// (the cards peel `params` themselves, and that keeps `inferFromInput` from /// misclassifying `cancel_delegation`'s `{task_id}` as a generic task). -fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option) -> Option { +fn codebuddy_deferred_tool_name( + agent_type: AgentType, + raw_input: &Option, +) -> Option { if agent_type != AgentType::CodeBuddy { return None; } @@ -11661,7 +11694,11 @@ fn codebuddy_meta_marks_subagent( if meta.get("codebuddy.ai/toolName").and_then(|v| v.as_str()) == Some("Agent") { return true; } - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { + if meta + .get("codebuddy.ai/isSubagent") + .and_then(|v| v.as_bool()) + == Some(true) + { return true; } meta.get("codebuddy.ai/subagentType") @@ -12196,7 +12233,8 @@ fn hoist_request_permission_meta( // it rather than replacing content the card may still be parsing. return; }; - meta.entry("permission").or_insert_with(|| permission.clone()); + meta.entry("permission") + .or_insert_with(|| permission.clone()); } /// True when an `initialize` response advertises the ACP steering extension — @@ -12580,7 +12618,11 @@ fn codebuddy_chunk_marks_subagent( let Some(meta) = meta else { return false; }; - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { + if meta + .get("codebuddy.ai/isSubagent") + .and_then(|v| v.as_bool()) + == Some(true) + { return true; } meta.get("codebuddy.ai/parentToolCallId") @@ -13043,8 +13085,7 @@ fn map_claude_sdk_ext_notification(notification: &UntypedMessage) -> Option None, - CodexSubagentActivity::Started { thread_id, input } => { - codex_subagent_thread = thread_id; - Some(input) - } - CodexSubagentActivity::Terminal { thread_id, kind } => { - settle_codex_subagent_launch(state, emitter, cb_state, &thread_id, &kind).await; - return; - } - CodexSubagentActivity::Other => return, - }; + let codex_subagent = + match classify_codex_subagent_activity(agent_type, tc.meta.as_ref()) { + CodexSubagentActivity::None => None, + CodexSubagentActivity::Started { thread_id, input } => { + codex_subagent_thread = thread_id; + Some(input) + } + CodexSubagentActivity::Terminal { thread_id, kind } => { + settle_codex_subagent_launch(state, emitter, cb_state, &thread_id, &kind) + .await; + return; + } + CodexSubagentActivity::Other => return, + }; let tool_call_id = tc.tool_call_id.to_string(); // Remember which capsule owns this child, so its eventual // `completed` / `interrupted` (announced under a synthetic id of its @@ -13996,12 +14042,11 @@ async fn emit_conversation_update( } else { None }; - let content = - serialize_tool_call_content(content_blocks, synthesized_edit.is_none()) - .map(|c| unwrap_codebuddy_deferred_output(agent_type, &c).unwrap_or(c)) - // pi announces a command with an empty result, which pi-acp - // renders as JSON source (see fn doc). - .filter(|_| !pi_result_content_is_stringify_noise(agent_type, &tc.raw_output)); + let content = serialize_tool_call_content(content_blocks, synthesized_edit.is_none()) + .map(|c| unwrap_codebuddy_deferred_output(agent_type, &c).unwrap_or(c)) + // pi announces a command with an empty result, which pi-acp + // renders as JSON source (see fn doc). + .filter(|_| !pi_result_content_is_stringify_noise(agent_type, &tc.raw_output)); let images = extract_tool_call_images(content_blocks); let codex_subagent_launch = codex_subagent.is_some(); let raw_input = codex_subagent @@ -14051,19 +14096,15 @@ async fn emit_conversation_update( // to a generic tool card. let meta_marks_subagent = codebuddy_meta_marks_subagent(agent_type, tc.meta.as_ref()) || codex_subagent_launch; - let meta_marks_background = codebuddy_meta_marks_background(agent_type, tc.meta.as_ref()); + let meta_marks_background = + codebuddy_meta_marks_background(agent_type, tc.meta.as_ref()); let grok_spawn = grok_meta_marks_spawn_subagent(agent_type, tc.meta.as_ref()); let status = format!("{:?}", tc.status).to_lowercase(); // OpenCode's only authoritative statement of WHICH tool this is // arrives on this opening frame's title (see fn doc). - let meta = stamp_opencode_tool_name( - agent_type, - &status, - &tc.raw_input, - &tc.title, - tc.meta, - ) - .map(serde_json::Value::Object); + let meta = + stamp_opencode_tool_name(agent_type, &status, &tc.raw_input, &tc.title, tc.meta) + .map(serde_json::Value::Object); raw_output_cache.remove_if_final(&tool_call_id, Some(status.as_str())); // Track Grok's spawn_subagent lifecycle for the subagent-notification // pairing (progress meta + finished settle). No-op for other agents. @@ -14210,9 +14251,7 @@ async fn emit_conversation_update( Some((_, inner)) => { json_value_to_text(&Some(inner.clone())).filter(|t| !t.trim().is_empty()) } - None => { - json_value_to_text(&tcu.fields.raw_input).filter(|t| !t.trim().is_empty()) - } + None => json_value_to_text(&tcu.fields.raw_input).filter(|t| !t.trim().is_empty()), }; let synthesized_edit = if own_raw_input.is_none() { content_blocks.and_then(synthesize_edit_input_from_diffs) @@ -14329,7 +14368,8 @@ async fn emit_conversation_update( .and_then(|l| serde_json::to_value(l).ok()); let meta_marks_subagent = codebuddy_meta_marks_subagent(agent_type, tcu.meta.as_ref()) || codex_subagent_launch; - let meta_marks_background = codebuddy_meta_marks_background(agent_type, tcu.meta.as_ref()); + let meta_marks_background = + codebuddy_meta_marks_background(agent_type, tcu.meta.as_ref()); let grok_spawn = grok_meta_marks_spawn_subagent(agent_type, tcu.meta.as_ref()); let meta = tcu.meta.clone().map(serde_json::Value::Object); let status = tcu.fields.status.map(|s| format!("{:?}", s).to_lowercase()); @@ -14346,7 +14386,13 @@ async fn emit_conversation_update( } // Symmetric with the ToolCall arm: an update may carry the terminal // status (and, on grok, usually re-carries the `x.ai/tool` meta). - track_grok_spawn_call(cb_state, grok_spawn, status.as_deref(), &tool_call_id, &raw_input); + track_grok_spawn_call( + cb_state, + grok_spawn, + status.as_deref(), + &tool_call_id, + &raw_input, + ); // Ordering variant: `subagent_spawned` can pair BEFORE the launch // call's terminal frame arrives. The pairing site skipped its // outstanding emission then (call not yet settled), so surface the @@ -14489,8 +14535,7 @@ async fn emit_conversation_update( "[ACP] agent pushed config_option_update: model={:?}", current_model_id_from_opts(&map_session_config_options(&update.config_options)) ); - emit_session_config_options_values(state, emitter, update.config_options) - .await; + emit_session_config_options_values(state, emitter, update.config_options).await; } SessionUpdate::AvailableCommandsUpdate(update) => { // Drop config-option state toggles (codex `/plan` — see @@ -14571,9 +14616,7 @@ async fn emit_conversation_update( crate::acp::session_title::publish_native_title(state, emitter, title).await; } let neutral_goal_channel = state.read().await.neutral_goal_channel; - if let Some(goal) = - session_info_goal_value(neutral_goal_channel, info.meta.as_ref()) - { + if let Some(goal) = session_info_goal_value(neutral_goal_channel, info.meta.as_ref()) { if let Some(marker) = crate::acp::codex_goal::next_goal_marker(&mut cb_state.codex_open_goal, goal) { @@ -14617,8 +14660,7 @@ async fn emit_conversation_update( if let Some(raw) = air_session_failure(info.meta.as_ref()) { match parse_session_failure_record(raw) { Some(record) => { - emit_with_state(state, emitter, AcpEvent::SessionFailure { record }) - .await; + emit_with_state(state, emitter, AcpEvent::SessionFailure { record }).await; } None => tracing::debug!( "[ACP] dropped AIR sessionFailure without usable id/revision: {raw:?}" @@ -14788,7 +14830,10 @@ mod tests { assert_eq!(after_b.next.map(|c| c.request_id).as_deref(), Some("c")); let after_c = q.resolve("c", "allow".into()); assert!(after_c.answered); - assert!(after_c.next.is_none(), "queue drained, nothing left to show"); + assert!( + after_c.next.is_none(), + "queue drained, nothing left to show" + ); assert_eq!(q.showing, None); assert_eq!(q.waiting_len(), 0); @@ -14894,7 +14939,10 @@ mod tests { admit_stub(&mut q, &log, "a"); let (tx, mut rx) = oneshot::channel::<()>(); q.park_detached(tx); - assert!(rx.try_recv().is_err() && !rx.is_terminated(), "still parked"); + assert!( + rx.try_recv().is_err() && !rx.is_terminated(), + "still parked" + ); assert_eq!(q.drain().as_deref(), Some("a")); assert!( @@ -14996,7 +15044,9 @@ mod tests { assert!(!GrokAskUserQuestionRequest::matches_method( "x.ai/ask_user_question" )); - assert!(!GrokAskUserQuestionRequest::matches_method("session/prompt")); + assert!(!GrokAskUserQuestionRequest::matches_method( + "session/prompt" + )); // The exact params grok sends (captured from a real 0.2.101 run): the // transparent newtype must deserialize them and the raw object must parse @@ -15231,7 +15281,10 @@ mod tests { "presentation": "state" } })); - assert!(is_config_option_state_command(AgentType::Codex, Some(&plan))); + assert!(is_config_option_state_command( + AgentType::Codex, + Some(&plan) + )); // Gated on Codex — the same meta never suppresses another agent's command. assert!(!is_config_option_state_command( AgentType::ClaudeCode, @@ -15242,7 +15295,10 @@ mod tests { let goal = meta_map(serde_json::json!({ "commandAction": { "kind": "prefixPrompt", "presentation": "state" } })); - assert!(!is_config_option_state_command(AgentType::Codex, Some(&goal))); + assert!(!is_config_option_state_command( + AgentType::Codex, + Some(&goal) + )); // Ordinary commands (no `commandAction`) and absent meta are kept. assert!(!is_config_option_state_command(AgentType::Codex, None)); let plain = meta_map(serde_json::json!({ "somethingElse": true })); @@ -15287,9 +15343,7 @@ mod tests { assert!(!init_advertises_steering(Some(&off))); // Wrong nesting (e.g. another convention's namespace) must not count. - let nested = meta_map( - serde_json::json!({"symposium": {"steering": {"supported": true}}}), - ); + let nested = meta_map(serde_json::json!({"symposium": {"steering": {"supported": true}}})); assert!(!init_advertises_steering(Some(&nested))); // Non-bool / absent → false. @@ -15337,9 +15391,15 @@ mod tests { "codex": {"goal": {"objective": "legacy", "status": "active"}}, })); let neutral = session_info_goal_value(true, Some(&both)).expect("neutral value"); - assert_eq!(neutral.get("objective").and_then(|v| v.as_str()), Some("neutral")); + assert_eq!( + neutral.get("objective").and_then(|v| v.as_str()), + Some("neutral") + ); let legacy = session_info_goal_value(false, Some(&both)).expect("legacy value"); - assert_eq!(legacy.get("objective").and_then(|v| v.as_str()), Some("legacy")); + assert_eq!( + legacy.get("objective").and_then(|v| v.as_str()), + Some("legacy") + ); // Neutral-pinned connections ignore a legacy-only update (and vice // versa) — the two updates of a double-publish collapse to one marker. @@ -15347,9 +15407,8 @@ mod tests { serde_json::json!({"codex": {"goal": {"objective": "legacy", "status": "active"}}}), ); assert!(session_info_goal_value(true, Some(&legacy_only)).is_none()); - let neutral_only = meta_map( - serde_json::json!({"goal": {"objective": "neutral", "status": "active"}}), - ); + let neutral_only = + meta_map(serde_json::json!({"goal": {"objective": "neutral", "status": "active"}})); assert!(session_info_goal_value(false, Some(&neutral_only)).is_none()); // `goal: null` IS a value (the clear signal), not an absent key. @@ -15457,12 +15516,15 @@ mod tests { "a dropped title must not poison the skip-cache" ); - state.write().await.apply_event(&AcpEvent::ConversationLinked { - conversation_id: 7, - folder_id: 1, - parent_conversation_id: None, - parent_tool_use_id: None, - }); + state + .write() + .await + .apply_event(&AcpEvent::ConversationLinked { + conversation_id: 7, + folder_id: 1, + parent_conversation_id: None, + parent_tool_use_id: None, + }); drive_session_info_title(&state, "Fix the login flow").await; assert_eq!( @@ -15511,7 +15573,10 @@ mod tests { }})); assert_eq!( goal_advertised_control(Some(&claude)), - Some(("_session/goal".to_string(), vec!["set".to_string(), "clear".to_string()])) + Some(( + "_session/goal".to_string(), + vec!["set".to_string(), "clear".to_string()] + )) ); // Advertised-but-empty actions are honored as "no controls" — the // card must not offer affordances the adapter never implemented. @@ -15686,7 +15751,10 @@ mod tests { assert_eq!(record.severity, "error"); assert_eq!(record.title, ""); assert_eq!(record.details, None); - assert_eq!(record.actions, vec!["retry".to_string(), "sing".to_string()]); + assert_eq!( + record.actions, + vec!["retry".to_string(), "sing".to_string()] + ); } /// claude-agent-acp 0.74.0's mid-session sign-out record, verbatim off the @@ -15815,10 +15883,7 @@ mod tests { let caps = serde_json::to_value(build_client_capabilities(agent, HostToolsPolicy::Default)) .unwrap(); - assert!(caps - .get("_meta") - .and_then(|m| m.get("jetbrains")) - .is_none()); + assert!(caps.get("_meta").and_then(|m| m.get("jetbrains")).is_none()); } } @@ -15837,10 +15902,7 @@ mod tests { "Cursor initialize must advertise parameterizedModelPicker" ); // Cursor must not pick up Claude/Codex-only extensions. - assert!(caps - .get("_meta") - .and_then(|m| m.get("jetbrains")) - .is_none()); + assert!(caps.get("_meta").and_then(|m| m.get("jetbrains")).is_none()); assert!(caps .get("_meta") .and_then(|m| m.get("subagent-transcript")) @@ -16094,7 +16156,10 @@ mod tests { ); // The opt-in is what keeps the idle race host-owned — its absence // would regress to detached `startedNewTurn` turns. - assert_eq!(params["_meta"]["steering"]["idleBehavior"], "promptRequired"); + assert_eq!( + params["_meta"]["steering"]["idleBehavior"], + "promptRequired" + ); } #[test] @@ -16121,7 +16186,10 @@ mod tests { assert_eq!(params["prompt"][1]["type"], "image"); assert_eq!(params["prompt"][1]["data"], "aGk="); assert_eq!(params["prompt"][1]["mimeType"], "image/png"); - assert_eq!(params["_meta"]["steering"]["idleBehavior"], "promptRequired"); + assert_eq!( + params["_meta"]["steering"]["idleBehavior"], + "promptRequired" + ); } #[test] @@ -16554,7 +16622,9 @@ mod tests { // No configured creds → both injected empty (⇒ spawn strips inherited). let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; apply_cursor_env_policy(&mut merged, &sub); - assert!(merged.iter().any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); + assert!(merged + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); assert!(merged .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); @@ -16562,7 +16632,9 @@ mod tests { // A configured key is preserved; only the absent base URL is cleared. let mut with_key = vec![("CURSOR_API_KEY".to_string(), "sk-x".to_string())]; apply_cursor_env_policy(&mut with_key, &sub); - assert!(with_key.iter().any(|(k, v)| k == "CURSOR_API_KEY" && v == "sk-x")); + assert!(with_key + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v == "sk-x")); assert!(with_key .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); @@ -16604,7 +16676,9 @@ mod tests { // inherited XAI_API_KEY so `grok login` is used). let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; apply_grok_env_policy(&mut merged, &sub); - assert!(merged.iter().any(|(k, v)| k == "XAI_API_KEY" && v.is_empty())); + assert!(merged + .iter() + .any(|(k, v)| k == "XAI_API_KEY" && v.is_empty())); // A configured key is preserved even in subscription mode (explicit wins). let mut with_key = vec![("XAI_API_KEY".to_string(), "xai-abc".to_string())]; @@ -16625,10 +16699,7 @@ mod tests { } fn antigravity_runtime(method: &str) -> BTreeMap { - BTreeMap::from([( - ANTIGRAVITY_AUTH_METHOD_ENV.to_string(), - method.to_string(), - )]) + BTreeMap::from([(ANTIGRAVITY_AUTH_METHOD_ENV.to_string(), method.to_string())]) } #[test] @@ -16658,7 +16729,9 @@ mod tests { assert!(env .iter() .any(|(k, v)| k == "GEMINI_API_KEY" && v == "real-key")); - assert!(env.iter().any(|(k, v)| k == "GOOGLE_API_KEY" && v.is_empty())); + assert!(env + .iter() + .any(|(k, v)| k == "GOOGLE_API_KEY" && v.is_empty())); // Agent Platform keeps the GOOGLE_* trio, drops GEMINI_API_KEY. let mut env = vec![ @@ -16667,11 +16740,15 @@ mod tests { ("GEMINI_API_KEY".to_string(), "leaked".to_string()), ]; apply_antigravity_env_policy(&mut env, &antigravity_runtime("agent-platform")); - assert!(env.iter().any(|(k, v)| k == "GOOGLE_CLOUD_PROJECT" && v == "p")); + assert!(env + .iter() + .any(|(k, v)| k == "GOOGLE_CLOUD_PROJECT" && v == "p")); assert!(env .iter() .any(|(k, v)| k == "GOOGLE_CLOUD_LOCATION" && v == "global")); - assert!(env.iter().any(|(k, v)| k == "GEMINI_API_KEY" && v.is_empty())); + assert!(env + .iter() + .any(|(k, v)| k == "GEMINI_API_KEY" && v.is_empty())); } /// A var the method READS but the panel did not store must still be cleared. @@ -16705,7 +16782,9 @@ mod tests { "an inherited GOOGLE_API_KEY would suppress the project the user filled in" ); // The credentials the panel DID store are untouched. - assert!(env.iter().any(|(k, v)| k == "GOOGLE_CLOUD_PROJECT" && v == "mine")); + assert!(env + .iter() + .any(|(k, v)| k == "GOOGLE_CLOUD_PROJECT" && v == "mine")); assert!(env .iter() .any(|(k, v)| k == "GOOGLE_CLOUD_LOCATION" && v == "global")); @@ -16715,7 +16794,9 @@ mod tests { // here is a leftover rather than a credential. let mut env = vec![("GEMINI_API_KEY".to_string(), " ".to_string())]; apply_antigravity_env_policy(&mut env, &antigravity_runtime("gemini-api-key")); - assert!(env.iter().any(|(k, v)| k == "GEMINI_API_KEY" && v.is_empty())); + assert!(env + .iter() + .any(|(k, v)| k == "GEMINI_API_KEY" && v.is_empty())); } #[test] @@ -16828,8 +16909,11 @@ mod tests { // whole enum exists for. A caller that treated it as "nothing there" // would sign out of a `gemini-api-key` connection, clear nothing, and // be told `{}`. - std::fs::write(&path, "{\n // mine\n \"auth\": {\"type\": \"oauth-personal\"},\n}\n") - .unwrap(); + std::fs::write( + &path, + "{\n // mine\n \"auth\": {\"type\": \"oauth-personal\"},\n}\n", + ) + .unwrap(); assert_eq!( antigravity_effective_auth_type(&home()), AntigravityAuthType::Unreadable @@ -16938,8 +17022,8 @@ mod tests { GcpField::Keep, GcpField::Keep, ) - .expect("editable") - .expect("auth.type changed, so this is a real write"); + .expect("editable") + .expect("auth.type changed, so this is a real write"); assert_eq!(merged["auth"]["type"], "oauth-business"); // No panel values supplied ⇒ the hand-written gcp block is untouched. assert_eq!(merged["gcp"]["project"], "hand-written"); @@ -16947,30 +17031,37 @@ mod tests { assert_eq!(merged["someFutureKey"]["nested"][2], 3); // Panel values overwrite only the fields they carry. - let merged = - merge_antigravity_settings( - Some(existing.clone()), - "oauth-business", - GcpField::Set("proj"), - GcpField::Keep, - ) - .expect("editable") - .expect("changed"); + let merged = merge_antigravity_settings( + Some(existing.clone()), + "oauth-business", + GcpField::Set("proj"), + GcpField::Keep, + ) + .expect("editable") + .expect("changed"); assert_eq!(merged["gcp"]["project"], "proj"); assert_eq!(merged["gcp"]["location"], "eu", "location was not supplied"); // Already says exactly this ⇒ no write. - assert!( - merge_antigravity_settings(Some(existing), "gemini-api-key", GcpField::Keep, GcpField::Keep) - .expect("editable") - .is_none() - ); + assert!(merge_antigravity_settings( + Some(existing), + "gemini-api-key", + GcpField::Keep, + GcpField::Keep + ) + .expect("editable") + .is_none()); // No file at all: created from scratch. (A non-object ROOT never gets // here — the read side already refused it.) - let created = merge_antigravity_settings(None, "oauth-personal", GcpField::Set("p"), GcpField::Set("global")) - .expect("editable") - .expect("created"); + let created = merge_antigravity_settings( + None, + "oauth-personal", + GcpField::Set("p"), + GcpField::Set("global"), + ) + .expect("editable") + .expect("created"); assert_eq!(created["auth"]["type"], "oauth-personal"); assert_eq!(created["gcp"]["project"], "p"); assert_eq!(created["gcp"]["location"], "global"); @@ -16982,24 +17073,33 @@ mod tests { // an object" and gives up. Replacing that value with an object would // delete whatever the user meant by it, so codeg refuses too. let odd_auth = serde_json::json!({ "auth": "managed-elsewhere", "keep": 1 }); - assert!(merge_antigravity_settings(Some(odd_auth), "oauth-personal", GcpField::Keep, GcpField::Keep).is_err()); + assert!(merge_antigravity_settings( + Some(odd_auth), + "oauth-personal", + GcpField::Keep, + GcpField::Keep + ) + .is_err()); // Same for `gcp` — but ONLY when there is actually something to write // into it. With no project or location supplied, a strange `gcp` is // none of codeg's business and must not block the `auth.type` update. let odd_gcp = serde_json::json!({ "gcp": ["not", "an", "object"] }); - assert!( - merge_antigravity_settings( - Some(odd_gcp.clone()), - "oauth-personal", - GcpField::Set("p"), - GcpField::Keep, - ) - .is_err() - ); - let untouched = merge_antigravity_settings(Some(odd_gcp), "oauth-personal", GcpField::Keep, GcpField::Keep) - .expect("editable") - .expect("auth.type still written"); + assert!(merge_antigravity_settings( + Some(odd_gcp.clone()), + "oauth-personal", + GcpField::Set("p"), + GcpField::Keep, + ) + .is_err()); + let untouched = merge_antigravity_settings( + Some(odd_gcp), + "oauth-personal", + GcpField::Keep, + GcpField::Keep, + ) + .expect("editable") + .expect("auth.type still written"); assert_eq!(untouched["auth"]["type"], "oauth-personal"); assert_eq!(untouched["gcp"], serde_json::json!(["not", "an", "object"])); @@ -17011,8 +17111,8 @@ mod tests { GcpField::Keep, GcpField::Keep, ) - .expect("editable") - .expect("changed"); + .expect("editable") + .expect("changed"); assert_eq!(filled["auth"]["type"], "gemini-api-key"); assert_eq!(filled["keep"], 2); } @@ -17036,10 +17136,14 @@ mod tests { }; // The panel owns both fields for this method and both are now empty. - let cleared = - merge_antigravity_settings(Some(existing()), "oauth-business", GcpField::Clear, GcpField::Clear) - .expect("editable") - .expect("the gcp block changed, so this is a real write"); + let cleared = merge_antigravity_settings( + Some(existing()), + "oauth-business", + GcpField::Clear, + GcpField::Clear, + ) + .expect("editable") + .expect("the gcp block changed, so this is a real write"); assert!( cleared.get("gcp").is_none(), "an emptied block should go rather than linger as {{}}: {cleared}" @@ -17048,10 +17152,14 @@ mod tests { assert_eq!(cleared["keep"], 1, "foreign keys still survive a clear"); // One cleared, one set. - let partial = - merge_antigravity_settings(Some(existing()), "oauth-business", GcpField::Set("new"), GcpField::Clear) - .expect("editable") - .expect("changed"); + let partial = merge_antigravity_settings( + Some(existing()), + "oauth-business", + GcpField::Set("new"), + GcpField::Clear, + ) + .expect("editable") + .expect("changed"); assert_eq!(partial["gcp"]["project"], "new"); assert!(partial["gcp"].get("location").is_none()); @@ -17072,10 +17180,14 @@ mod tests { // take the `auth.type` update down with it — the one part of this file // the agent cannot start without. let odd = serde_json::json!({ "gcp": ["not", "an", "object"] }); - let still_written = - merge_antigravity_settings(Some(odd), "oauth-business", GcpField::Clear, GcpField::Clear) - .expect("a clear must not refuse a block it cannot edit") - .expect("auth.type still written"); + let still_written = merge_antigravity_settings( + Some(odd), + "oauth-business", + GcpField::Clear, + GcpField::Clear, + ) + .expect("a clear must not refuse a block it cannot edit") + .expect("auth.type still written"); assert_eq!(still_written["auth"]["type"], "oauth-business"); assert_eq!( still_written["gcp"], @@ -17223,8 +17335,7 @@ mod tests { .join("antigravity-acp") ); // The `~/.gemini` default follows it too. - let default_under_child = - BTreeMap::from([(home_key.to_string(), child_home.to_string())]); + let default_under_child = BTreeMap::from([(home_key.to_string(), child_home.to_string())]); assert_eq!( antigravity_acp_dir_for_env(&default_under_child).expect("nameable"), PathBuf::from(child_home) @@ -17510,8 +17621,10 @@ mod tests { true, ); // Exactly one PATH-ish key, the original casing preserved, value prepended. - let path_keys: Vec<&String> = - env.keys().filter(|k| k.eq_ignore_ascii_case("PATH")).collect(); + let path_keys: Vec<&String> = env + .keys() + .filter(|k| k.eq_ignore_ascii_case("PATH")) + .collect(); assert_eq!(path_keys.len(), 1, "{env:?}"); assert_eq!( env.get("Path").unwrap(), @@ -17522,9 +17635,17 @@ mod tests { #[test] fn prepend_path_windows_seeds_from_fallback_with_semicolon() { let mut env = BTreeMap::new(); - prepend_dir_to_path_env(&mut env, r"C:\OfficeCLI", r"C:\Windows;C:\Windows\System32", true); + prepend_dir_to_path_env( + &mut env, + r"C:\OfficeCLI", + r"C:\Windows;C:\Windows\System32", + true, + ); // No prior key → default `Path` casing on Windows. - assert_eq!(env.get("Path").unwrap(), r"C:\OfficeCLI;C:\Windows;C:\Windows\System32"); + assert_eq!( + env.get("Path").unwrap(), + r"C:\OfficeCLI;C:\Windows;C:\Windows\System32" + ); } #[test] @@ -17537,9 +17658,15 @@ mod tests { env.insert("PATH".to_string(), r"C:\a".to_string()); env.insert("Path".to_string(), r"C:\b".to_string()); prepend_dir_to_path_env(&mut env, r"C:\OfficeCLI", "ignored-fallback", true); - let path_keys: Vec<&String> = - env.keys().filter(|k| k.eq_ignore_ascii_case("PATH")).collect(); - assert_eq!(path_keys.len(), 1, "exactly one PATH-ish key must remain: {env:?}"); + let path_keys: Vec<&String> = env + .keys() + .filter(|k| k.eq_ignore_ascii_case("PATH")) + .collect(); + assert_eq!( + path_keys.len(), + 1, + "exactly one PATH-ish key must remain: {env:?}" + ); assert_eq!(env.get("Path").unwrap(), r"C:\OfficeCLI;C:\b"); } @@ -17613,7 +17740,10 @@ mod tests { // `EPERM`, every shell fallback blocked, `FsViolation` audited). let withheld = caps_of(AgentType::Grok, HostToolsPolicy::Agent); assert_eq!(withheld["terminal"], serde_json::Value::Bool(false)); - assert_eq!(withheld["fs"]["readTextFile"], serde_json::Value::Bool(false)); + assert_eq!( + withheld["fs"]["readTextFile"], + serde_json::Value::Bool(false) + ); assert_eq!( withheld["fs"]["writeTextFile"], serde_json::Value::Bool(false) @@ -17808,9 +17938,18 @@ mod tests { assert_eq!(tool_call_id, "019f9475-c67f-7390-9ee5-a09d29986a6c-4"); assert_eq!(status, "completed"); let meta = meta.expect("compaction card needs meta"); - assert_eq!(meta.get("contextCompaction").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(meta.get("tokensBefore").and_then(|v| v.as_u64()), Some(45389)); - assert_eq!(meta.get("tokensAfter").and_then(|v| v.as_u64()), Some(16486)); + assert_eq!( + meta.get("contextCompaction").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + meta.get("tokensBefore").and_then(|v| v.as_u64()), + Some(45389) + ); + assert_eq!( + meta.get("tokensAfter").and_then(|v| v.as_u64()), + Some(16486) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -18099,8 +18238,13 @@ mod tests { ) .unwrap(); match map_grok_ext_notification(&raw, AgentType::Grok) { - Some(AcpEvent::Error { message, terminal, .. }) => { - assert!(message.contains("503"), "error should carry the reason; got: {message}"); + Some(AcpEvent::Error { + message, terminal, .. + }) => { + assert!( + message.contains("503"), + "error should carry the reason; got: {message}" + ); assert!(!terminal, "compaction failure must not kill the connection"); } other => panic!("expected non-terminal Error, got {other:?}"), @@ -18191,8 +18335,7 @@ mod tests { "subagent_type": "plan" })); assert!( - map_grok_subagent_notification(&stale_bare, AgentType::Grok, true, &mut cb) - .is_empty(), + map_grok_subagent_notification(&stale_bare, AgentType::Grok, true, &mut cb).is_empty(), "an event missing a captured field must not match" ); assert_eq!(cb.grok_pending_spawn_ids.len(), 1, "B still keeps its slot"); @@ -18303,8 +18446,14 @@ mod tests { .as_ref() .and_then(|m| m.get("grokSubagentProgress")) .expect("progress meta"); - assert_eq!(progress.get("toolCallCount").and_then(|v| v.as_u64()), Some(7)); - assert_eq!(progress.get("durationMs").and_then(|v| v.as_u64()), Some(4200)); + assert_eq!( + progress.get("toolCallCount").and_then(|v| v.as_u64()), + Some(7) + ); + assert_eq!( + progress.get("durationMs").and_then(|v| v.as_u64()), + Some(4200) + ); assert_eq!( progress.get("contextUsagePct").and_then(|v| v.as_f64()), Some(12.5) @@ -18334,7 +18483,8 @@ mod tests { })); // The settle is likewise out-of-turn-safe (a background child usually // finishes after its launch turn ended). - match map_grok_subagent_notification(&finished, AgentType::Grok, false, &mut cb).as_slice() { + match map_grok_subagent_notification(&finished, AgentType::Grok, false, &mut cb).as_slice() + { [AcpEvent::BackgroundActivity { session_id, outstanding, @@ -18353,8 +18503,9 @@ mod tests { other => panic!("expected BackgroundActivity, got {other:?}"), } // Lifecycle over: a duplicate finished no longer routes anywhere. - assert!(map_grok_subagent_notification(&finished, AgentType::Grok, false, &mut cb) - .is_empty()); + assert!( + map_grok_subagent_notification(&finished, AgentType::Grok, false, &mut cb).is_empty() + ); } /// A BLOCKING spawn (call not yet settled when the child finishes) must NOT @@ -18472,11 +18623,20 @@ mod tests { ) }; // Both compaction outcomes are visible turn output. - assert!(grok_ext_notification_is_turn_output(¬if("auto_compact_completed"), AgentType::Grok)); - assert!(grok_ext_notification_is_turn_output(¬if("auto_compact_failed"), AgentType::Grok)); + assert!(grok_ext_notification_is_turn_output( + ¬if("auto_compact_completed"), + AgentType::Grok + )); + assert!(grok_ext_notification_is_turn_output( + ¬if("auto_compact_failed"), + AgentType::Grok + )); // turn_completed is deliberately left to the prompt-response path — it is // NOT counted here (otherwise a genuinely empty turn would be masked). - assert!(!grok_ext_notification_is_turn_output(¬if("turn_completed"), AgentType::Grok)); + assert!(!grok_ext_notification_is_turn_output( + ¬if("turn_completed"), + AgentType::Grok + )); // Never fires for a non-grok agent. assert!(!grok_ext_notification_is_turn_output( ¬if("auto_compact_completed"), @@ -18637,8 +18797,8 @@ mod tests { // A future kind, and a future sibling field, must both still parse. serde_json::json!({"authStatus": {"kind": "something_new"}, "extra": 1}), ] { - let notif: AuthStatusUpdateNotification = - serde_json::from_value(payload.clone()).unwrap_or_else(|e| { + let notif: AuthStatusUpdateNotification = serde_json::from_value(payload.clone()) + .unwrap_or_else(|e| { panic!("must not reject {payload}: {e}"); }); assert!(notif.auth_status.is_object()); @@ -18761,18 +18921,33 @@ mod tests { ); // Same count again → nothing to say (the field rides nearly every chunk). assert_eq!( - grok_live_usage_step(&streaming, AgentType::Grok, Some(500_000), Some((4200, 500_000))), + grok_live_usage_step( + &streaming, + AgentType::Grok, + Some(500_000), + Some((4200, 500_000)) + ), None ); // A different prior value is a real step → emit. assert_eq!( - grok_live_usage_step(&streaming, AgentType::Grok, Some(500_000), Some((3000, 500_000))), + grok_live_usage_step( + &streaming, + AgentType::Grok, + Some(500_000), + Some((3000, 500_000)) + ), Some((4200, 500_000)) ); // Same count but a NEW window (the user switched model between turns) → // re-emit, or the ring would keep dividing by the old model's window. assert_eq!( - grok_live_usage_step(&streaming, AgentType::Grok, Some(256_000), Some((4200, 500_000))), + grok_live_usage_step( + &streaming, + AgentType::Grok, + Some(256_000), + Some((4200, 500_000)) + ), Some((4200, 256_000)) ); // No resolvable window → still report the count, with the frontend's @@ -18912,9 +19087,8 @@ mod tests { // so the reader can tell "happened once" from "happening constantly". let coalesced = dropped_update_log_line("dispatch", &drop_err("missing field"), 4213); assert!( - coalesced.starts_with( - "[ACP] Ignoring unreadable session update (dispatch): missing field" - ), + coalesced + .starts_with("[ACP] Ignoring unreadable session update (dispatch): missing field"), "{coalesced}" ); assert!(coalesced.contains("+4212 more"), "{coalesced}"); @@ -18991,7 +19165,10 @@ mod tests { #[test] fn note_dropped_counts_each_site_separately_and_keeps_the_first() { let mut probe = TurnOutputProbe::new(0); - probe.note_dropped(DropSite::Dispatch, &drop_err("missing field `sessionUpdate`")); + probe.note_dropped( + DropSite::Dispatch, + &drop_err("missing field `sessionUpdate`"), + ); probe.note_dropped(DropSite::Decode, &drop_err("missing field `update`")); probe.note_dropped(DropSite::Decode, &drop_err("missing field `content`")); @@ -19035,7 +19212,10 @@ mod tests { // Without agent output, only `end_turn` is rewritten. let silent = TurnOutputProbe::new(0); - assert_eq!(finish_turn_reason(&silent, "cancelled", &tail).0, "cancelled"); + assert_eq!( + finish_turn_reason(&silent, "cancelled", &tail).0, + "cancelled" + ); assert_eq!(finish_turn_reason(&silent, "end_turn", &tail).0, "empty"); } @@ -19092,8 +19272,14 @@ mod tests { probe.note_dropped(DropSite::Dispatch, &drop_err("EOF while parsing a value")); let details = build_empty_turn_details(&probe, &tail).expect("details"); - assert!(details.contains("dropped 2 update(s) (1 decode, 1 dispatch)"), "{details}"); - assert!(details.contains("first (decode): trailing characters"), "{details}"); + assert!( + details.contains("dropped 2 update(s) (1 decode, 1 dispatch)"), + "{details}" + ); + assert!( + details.contains("first (decode): trailing characters"), + "{details}" + ); } #[test] @@ -19259,7 +19445,10 @@ mod tests { current_model_id_from_opts(&[select("effort", "mode", "high")]), None ); - assert_eq!(current_model_id_from_opts(&[select("m", "model", "")]), None); + assert_eq!( + current_model_id_from_opts(&[select("m", "model", "")]), + None + ); assert_eq!(current_model_id_from_opts(&[]), None); } @@ -19313,9 +19502,9 @@ mod tests { sacp::schema::SessionConfigSelectOptions::Ungrouped(Vec::new()), )) } - SessionConfigKindInfo::Boolean(b) => { - SessionConfigKind::Boolean(sacp::schema::SessionConfigBoolean::new(b.current_value)) - } + SessionConfigKindInfo::Boolean(b) => SessionConfigKind::Boolean( + sacp::schema::SessionConfigBoolean::new(b.current_value), + ), }, ); let extracted = values.get(&opt.id).expect("every option is extracted"); @@ -19399,7 +19588,11 @@ mod tests { "jetbrains": {"air": {"version": 1, "recommendedValue": " "}} })), ] { - assert_eq!(recommended(bad.clone()), None, "unexpected read from {bad:?}"); + assert_eq!( + recommended(bad.clone()), + None, + "unexpected read from {bad:?}" + ); } // A toggle has no value list to recommend into, so the hint is dropped @@ -19677,10 +19870,12 @@ mod tests { // A different data.code, or no data at all, must NOT be swallowed — // those fall through to the generic error path. - let other = sacp::Error::new(-32603, "boom") - .data(serde_json::json!({ "code": "SOMETHING_ELSE" })); + let other = + sacp::Error::new(-32603, "boom").data(serde_json::json!({ "code": "SOMETHING_ELSE" })); assert!(!is_grok_incompatible_agent_switch(&other)); - assert!(!is_grok_incompatible_agent_switch(&sacp::Error::internal_error())); + assert!(!is_grok_incompatible_agent_switch( + &sacp::Error::internal_error() + )); } #[test] @@ -19703,8 +19898,8 @@ mod tests { // Empty specs → the effort selector comes from the flat `x.ai/sessionConfig` // "mode" list (the no-`models` fallback path). - let opts = - synthesize_grok_config_options(Some(&meta), &HashMap::new()).expect("should synthesize"); + let opts = synthesize_grok_config_options(Some(&meta), &HashMap::new()) + .expect("should synthesize"); assert_eq!(opts.len(), 2, "model + effort selectors"); let model = &opts[0]; @@ -19714,15 +19909,24 @@ mod tests { // Both models appear (agent-type filtering is deliberately NOT applied — // cross-type switches are handled gracefully at set time instead). assert_eq!(model_sel.options.len(), 2); - assert_eq!(model_sel.current_value, "grok-4.5", "the `selected` model is current"); - assert!(model_sel.options.iter().any(|o| o.value == "grok-composer-2.5-fast")); + assert_eq!( + model_sel.current_value, "grok-4.5", + "the `selected` model is current" + ); + assert!(model_sel + .options + .iter() + .any(|o| o.value == "grok-composer-2.5-fast")); let effort = &opts[1]; assert_eq!(effort.id, GROK_EFFORT_OPTION_ID); assert_eq!(effort.category.as_deref(), Some("mode")); let effort_sel = expect_select(&effort.kind); assert_eq!(effort_sel.options.len(), 2); - assert_eq!(effort_sel.current_value, "high", "the `selected` effort is current"); + assert_eq!( + effort_sel.current_value, "high", + "the `selected` effort is current" + ); assert!(effort_sel.options.iter().any(|o| o.value == "low")); } @@ -19742,8 +19946,8 @@ mod tests { .unwrap(); // Empty specs → the effort selector comes from the flat `x.ai/sessionConfig` // "mode" list (the no-`models` fallback path). - let opts = - synthesize_grok_config_options(Some(&meta), &HashMap::new()).expect("should synthesize"); + let opts = synthesize_grok_config_options(Some(&meta), &HashMap::new()) + .expect("should synthesize"); assert_eq!(opts.len(), 1); assert_eq!(opts[0].id, GROK_MODEL_OPTION_ID); } @@ -19827,7 +20031,9 @@ mod tests { #[test] fn config_option_rejection_is_silent_when_the_pick_landed() { - assert!(config_option_rejection(&rejection_fixture("high"), "thought_level", "high").is_none()); + assert!( + config_option_rejection(&rejection_fixture("high"), "thought_level", "high").is_none() + ); } #[test] @@ -20083,10 +20289,9 @@ mod tests { ); assert!(sel.options.iter().all(|o| o.description.is_some())); // Grok's own per-tier text is preserved for the switchable tiers. - assert!(sel - .options - .iter() - .any(|o| o.value == "high" && o.name == "High" && o.description.as_deref() == Some("Highest quality"))); + assert!(sel.options.iter().any(|o| o.value == "high" + && o.name == "High" + && o.description.as_deref() == Some("Highest quality"))); // Unsupported model → no selector; unknown model → None. assert!(build_grok_effort_option("grok-composer-2.5-fast", &specs).is_none()); assert!(build_grok_effort_option("nope", &specs).is_none()); @@ -20115,7 +20320,10 @@ mod tests { .expect("effort selector"); let sel = expect_select(&effort.kind); assert_eq!(sel.current_value, "xhigh", "grok-4.5's real default"); - assert!(sel.options.iter().any(|o| o.value == "xhigh" && o.name == "Max")); + assert!(sel + .options + .iter() + .any(|o| o.value == "xhigh" && o.name == "Max")); } #[test] @@ -20242,9 +20450,7 @@ mod tests { let errors: Vec<(Option, bool)> = events .iter() .filter_map(|e| match &e.payload { - AcpEvent::Error { - code, terminal, .. - } => Some((code.clone(), *terminal)), + AcpEvent::Error { code, terminal, .. } => Some((code.clone(), *terminal)), _ => None, }) .collect(); @@ -20488,13 +20694,7 @@ mod tests { #[test] fn opencode_tool_name_is_stamped_only_on_the_arg_less_opening_frame() { let stamped = |status: &str, raw_input: serde_json::Value, title: &str| { - stamp_opencode_tool_name( - AgentType::OpenCode, - status, - &Some(raw_input), - title, - None, - ) + stamp_opencode_tool_name(AgentType::OpenCode, status, &Some(raw_input), title, None) }; // The real opening frame: `pending`, `rawInput: {}`, title = tool id. assert_eq!( @@ -20510,15 +20710,16 @@ mod tests { // the COMPLETED state — display title, populated input — so the empty // -input gate is what keeps the marker off it. assert_eq!( - stamped("pending", serde_json::json!({"pattern": "*.txt"}), "notes.txt"), + stamped( + "pending", + serde_json::json!({"pattern": "*.txt"}), + "notes.txt" + ), None ); // Later frames in the lifecycle: nothing to record, the reducer keeps // the opening frame's meta. - assert_eq!( - stamped("in_progress", serde_json::json!({}), "glob"), - None - ); + assert_eq!(stamped("in_progress", serde_json::json!({}), "glob"), None); assert_eq!(stamped("pending", serde_json::json!({}), " "), None); } @@ -20552,7 +20753,10 @@ mod tests { ) .expect("meta"); assert_eq!(with_sibling["vendor"], serde_json::json!({ "x": 1 })); - assert_eq!(with_sibling["opencode"], serde_json::json!({ "toolName": "read" })); + assert_eq!( + with_sibling["opencode"], + serde_json::json!({ "toolName": "read" }) + ); let preexisting = stamp_opencode_tool_name( AgentType::OpenCode, @@ -20788,12 +20992,7 @@ mod tests { cache: &mut ToolCallOutputCache, cb: &mut CodeBuddyLiveState, wire: serde_json::Value, - ) -> ( - Option, - Option, - Option, - Option, - ) { + ) -> (Option, Option, Option, Option) { let st = SessionState::new( "conn-pi".to_string(), agent_type, @@ -20818,12 +21017,7 @@ mod tests { raw_input, raw_output, .. - } => Some(( - content.clone(), - raw_input.clone(), - raw_output.clone(), - None, - )), + } => Some((content.clone(), raw_input.clone(), raw_output.clone(), None)), AcpEvent::ToolCallUpdate { content, raw_input, @@ -21334,8 +21528,7 @@ mod tests { ] { let mut cache = ToolCallOutputCache::default(); let mut cb = CodeBuddyLiveState::default(); - let (content, _, _, _) = - pi_emit(AgentType::Pi, &mut cache, &mut cb, wire).await; + let (content, _, _, _) = pi_emit(AgentType::Pi, &mut cache, &mut cb, wire).await; assert_eq!( content.as_deref(), Some(flattened), @@ -21453,13 +21646,7 @@ mod tests { serde_json::from_value(wire).expect("valid agent_message_chunk wire shape"); emit_conversation_update( - &state, - &emitter, - agent_type, - update, - None, - &mut cache, - &mut cb, + &state, &emitter, agent_type, update, None, &mut cache, &mut cb, ) .await; @@ -21519,7 +21706,11 @@ mod tests { "Starting queued message. (1 remaining)", "Cleared queued prompts.", ] { - assert_eq!(route(text), PiChunkRoute::Drop, "{text:?} must not be prose"); + assert_eq!( + route(text), + PiChunkRoute::Drop, + "{text:?} must not be prose" + ); } } @@ -21612,8 +21803,7 @@ mod tests { "main".to_string(), None, ))); - state.write().await.pi_startup_banner = - pi_startup_banner(AgentType::Pi, Some(&meta)); + state.write().await.pi_startup_banner = pi_startup_banner(AgentType::Pi, Some(&meta)); assert!( !pi_take_startup_banner(AgentType::Pi, &state, "你好,我能帮你做什么?").await, @@ -21776,11 +21966,19 @@ mod tests { ); assert_eq!( - synth(ToolKind::Search, "Searching the web for: \"rust borrow\"", &[]), + synth( + ToolKind::Search, + "Searching the web for: \"rust borrow\"", + &[] + ), Some(r#"{"query":"rust borrow"}"#.to_string()) ); assert_eq!( - synth(ToolKind::Fetch, "Fetching content from: https://example.com", &[]), + synth( + ToolKind::Fetch, + "Fetching content from: https://example.com", + &[] + ), Some(r#"{"url":"https://example.com"}"#.to_string()) ); @@ -21877,8 +22075,11 @@ mod tests { /// own counters so the banner can render its localized line. #[tokio::test] async fn pi_retry_chunk_becomes_the_retry_banner_with_counters() { - let events = - pi_emit_chunk(AgentType::Pi, pi_chunk("Retrying (attempt 2/3, waiting 4s)...")).await; + let events = pi_emit_chunk( + AgentType::Pi, + pi_chunk("Retrying (attempt 2/3, waiting 4s)..."), + ) + .await; assert!( !events .iter() @@ -22048,10 +22249,10 @@ mod tests { // Missing tool_input. assert!(unwrap_grok_use_tool(Some(&serde_json::json!({"tool_name": "x"}))).is_none()); // Empty tool_name. - assert!( - unwrap_grok_use_tool(Some(&serde_json::json!({"tool_name": "", "tool_input": {}}))) - .is_none() - ); + assert!(unwrap_grok_use_tool(Some( + &serde_json::json!({"tool_name": "", "tool_input": {}}) + )) + .is_none()); // Absent / non-object. assert!(unwrap_grok_use_tool(None).is_none()); assert!(unwrap_grok_use_tool(Some(&serde_json::json!("s"))).is_none()); @@ -22115,7 +22316,8 @@ mod tests { Some("codeg-mcp__get_delegation_status") ); // Mixed batch with a running item still resolves. - let mixed = r#"{"tasks":[{"task_id":"a","status":"running"},{"task_id":"b","status":"unknown"}]}"#; + let mixed = + r#"{"tasks":[{"task_id":"a","status":"running"},{"task_id":"b","status":"unknown"}]}"#; assert_eq!( cursor_companion_title_from_content(Some(mixed)), Some("codeg-mcp__get_delegation_status") @@ -22141,9 +22343,7 @@ mod tests { assert_eq!(cursor_companion_title_from_content(None), None); // Ack prefix must match from the start, not mid-string. assert_eq!( - cursor_companion_title_from_content(Some( - "Note: Delegation successful. task_id=x." - )), + cursor_companion_title_from_content(Some("Note: Delegation successful. task_id=x.")), None ); } @@ -22614,12 +22814,8 @@ mod tests { /// first. #[test] fn scratch_dir_sets_every_temp_variable_the_child_might_read() { - let merged = merge_agent_env_with_color( - false, - &[], - &BTreeMap::new(), - Some(Path::new("/scratch/x")), - ); + let merged = + merge_agent_env_with_color(false, &[], &BTreeMap::new(), Some(Path::new("/scratch/x"))); for key in crate::acp::scratch_dir::TEMP_ENV_KEYS { assert_eq!(merged_value(&merged, key), Some("/scratch/x"), "{key}"); } @@ -22823,10 +23019,7 @@ mod tests { // returns false. Regression guard against any future "optimisation" // that conflates the substring check with the field check. let input = Some(r#"{"description":"use subagent_type=foo"}"#.to_string()); - assert!(!is_subagent_invocation( - AgentType::OpenCode, - &input - )); + assert!(!is_subagent_invocation(AgentType::OpenCode, &input)); } #[test] @@ -22873,7 +23066,8 @@ mod tests { "not json", ] { assert!( - codebuddy_deferred_tool_name(AgentType::CodeBuddy, &Some(raw.to_string())).is_none(), + codebuddy_deferred_tool_name(AgentType::CodeBuddy, &Some(raw.to_string())) + .is_none(), "expected None for raw_input={raw}" ); } @@ -22933,28 +23127,56 @@ mod tests { ); // Initial event carrying the subagent marker → "agent", recorded. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &subagent, "tc1", false, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &subagent, + "tc1", + false, + false, + &mut overrides + ) + .as_deref(), Some("agent") ); // The bug: a later status-only update lost the marker (raw_input None). // The override must be RE-ASSERTED, not downgraded to the event's title. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc1", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc1", + true, + false, + &mut overrides + ) + .as_deref(), Some("agent"), "a status-only update must not downgrade the Agent card mid-stream" ); // Even an update whose raw_input looks like a different tool keeps it. let bash = Some(r#"{"command":"ls"}"#.to_string()); assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &bash, "tc1", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &bash, + "tc1", + true, + false, + &mut overrides + ) + .as_deref(), Some("agent") ); // A never-classified tool call returns None → caller uses its own title. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc2", true, false, &mut overrides), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc2", + true, + false, + &mut overrides + ), None ); // Deferred MCP tool: inner name recorded, then re-asserted on a bare update. @@ -22963,18 +23185,39 @@ mod tests { .to_string(), ); assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &deferred, "tc3", false, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &deferred, + "tc3", + false, + false, + &mut overrides + ) + .as_deref(), Some("mcp__codeg-mcp__delegate_to_agent") ); assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc3", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc3", + true, + false, + &mut overrides + ) + .as_deref(), Some("mcp__codeg-mcp__delegate_to_agent") ); // Non-CodeBuddy agent with no prior classification: never rewritten. assert_eq!( - resolve_rewritten_title(AgentType::OpenCode, &None, "tc9", true, false, &mut overrides), + resolve_rewritten_title( + AgentType::OpenCode, + &None, + "tc9", + true, + false, + &mut overrides + ), None ); } @@ -23018,15 +23261,29 @@ mod tests { // Frame 1: `raw_input` has NO `subagent_type` yet, but `_meta` already // marks it (the early, reliable signal). Title must already be "agent". assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc1", false, true, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc1", + false, + true, + &mut overrides + ) + .as_deref(), Some("agent") ); // Later sparse frames carry NEITHER signal — the override is re-asserted, // so the pill never flickers back to a generic tool mid-stream. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc1", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc1", + true, + false, + &mut overrides + ) + .as_deref(), Some("agent"), "meta-classified Agent pill must stay 'agent' across signal-less frames" ); @@ -23056,7 +23313,7 @@ mod tests { let mut open: HashSet = HashSet::new(); let mut closed: HashSet = HashSet::new(); let fg = false; // foreground (not background) - // A non-final foreground agent frame opens the window. + // A non-final foreground agent frame opens the window. track_subagent_window( AgentType::CodeBuddy, true, @@ -23157,7 +23414,11 @@ mod tests { // the parent model is suspended — so every chunk in the window is the // sub-agent's, never main-agent output (background sub-agents, which could // interleave main output, are excluded from the window upstream). - assert!(should_suppress_subagent_chunk(AgentType::CodeBuddy, true, None)); + assert!(should_suppress_subagent_chunk( + AgentType::CodeBuddy, + true, + None + )); // Window closed and no chunk meta → emit (e.g. main-agent text before the // sub-agent opens or after it closes). assert!(!should_suppress_subagent_chunk( @@ -23177,7 +23438,11 @@ mod tests { )); } // Other agents never suppress, even inside a (spurious) open window. - assert!(!should_suppress_subagent_chunk(AgentType::OpenCode, true, None)); + assert!(!should_suppress_subagent_chunk( + AgentType::OpenCode, + true, + None + )); } #[test] @@ -23296,10 +23561,7 @@ mod tests { }; assert_eq!( - ids(visible_config_options( - &["provider".to_string()], - options() - )), + ids(visible_config_options(&["provider".to_string()], options())), vec!["auto_approve".to_string()], "a dropdown whose every choice errors must not reach the composer" ); @@ -23420,6 +23682,8 @@ mod tests { "parent-conn", std::path::Path::new("/tmp"), false, + false, + false, HostToolsPolicy::Default, ) .await; @@ -23579,6 +23843,8 @@ mod tests { // All on → comma-joined, in the order the companion parses. assert_eq!( companion_features_arg(CompanionFeatureFlags { + pipeline: false, + memory: false, delegation: true, feedback: true, ask: true, @@ -23716,7 +23982,11 @@ mod tests { .iter() .map(|o| o["id"].as_str().unwrap()) .collect(); - assert_eq!(ids, vec!["model", "auto_approve"], "only `radio` is dropped"); + assert_eq!( + ids, + vec!["model", "auto_approve"], + "only `radio` is dropped" + ); // Untouched siblings survive, and the response still parses. assert_eq!(raw["sessionId"], "sess-1"); serde_json::from_value::(raw).expect("parses after stripping"); @@ -23929,11 +24199,12 @@ mod tests { #[tokio::test] async fn an_agreeing_push_and_an_empty_ledger_produce_no_drift() { let agreeing = asserted_drift_state(&[("model", "sonnet[1m]"), ("effort", "high")]); - assert!( - take_asserted_config_drift(&agreeing, &asserted_drift_options("sonnet[1m]", "high")) - .await - .is_empty() - ); + assert!(take_asserted_config_drift( + &agreeing, + &asserted_drift_options("sonnet[1m]", "high") + ) + .await + .is_empty()); assert_eq!( agreeing.read().await.asserted_config_values.len(), 2, @@ -23963,7 +24234,12 @@ mod tests { assert_eq!(drift, vec![("model".to_string(), "sonnet[1m]".to_string())]); assert_eq!( - state.read().await.asserted_config_values.keys().collect::>(), + state + .read() + .await + .asserted_config_values + .keys() + .collect::>(), vec!["sandbox"], "the unmentioned option stays defended" ); diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index f62029a986..fc8eb12f17 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -90,6 +90,15 @@ const DEFAULT_COMPLETED_CACHE_CAP_BYTES: usize = 512 * 1024 * 1024; /// never the eviction victim in `insert_completed`. const COMPLETED_TEXT_CAP: usize = 256 * 1024; +/// Hard cap on the number of completed entries retained per parent, +/// independent of `completed_cap_bytes`. A `text: None` result (failure / +/// cancel) contributes 0 bytes, so the byte-based eviction in +/// `evict_completed_over_cap` never reclaims it: a parent that repeatedly +/// delegates and gets errors would otherwise grow `completed` / +/// `completed_order` forever. Generous — this only guards against +/// unbounded growth, not normal usage. +const MAX_COMPLETED_ENTRIES_PER_PARENT: usize = 2000; + /// Cap on the `task_preview` carried by the `DelegationStarted` event and the /// parent-card meta writes. The full task text lives in the MCP call (and, on /// most hosts, in the parent tool call's own `raw_input`); the preview only @@ -587,11 +596,14 @@ impl PendingInner { /// LLM's immediate `get_delegation_status` always hits. fn evict_completed_over_cap(&mut self, parent: &str) { let cap = self.completed_cap_bytes; - if cap == 0 { - return; - } loop { - if self.completed_bytes.get(parent).copied().unwrap_or(0) <= cap { + let over_bytes_cap = + cap != 0 && self.completed_bytes.get(parent).copied().unwrap_or(0) > cap; + let over_count_cap = self + .completed_order + .get(parent) + .is_some_and(|order| order.len() > MAX_COMPLETED_ENTRIES_PER_PARENT); + if !over_bytes_cap && !over_count_cap { break; } let evicted = match self.completed_order.get_mut(parent) { @@ -2572,10 +2584,17 @@ impl DelegationBroker { inner.deregister_inflight(inflight_id); } let _ = self.spawner.disconnect(&child_connection_id).await; + let conversation_id = match &e { + crate::acp::delegation::spawner::SpawnerError::SendWithConversation { + conversation_id, + .. + } => Some(*conversation_id), + _ => None, + }; return report_err( req.agent_type, DelegationError::SpawnFailed(e.to_string()), - None, + conversation_id, ); } }; @@ -2916,8 +2935,43 @@ impl DelegationBroker { /// the `call_id` is no longer reserved the call was already resolved by /// another terminal path, so the buffer is skipped (silent no-op). pub async fn complete_call(&self, call_id: &str, outcome: DelegationOutcome) { + self.complete_call_checked(call_id, None, outcome).await; + } + + /// Same as [`Self::complete_call`], but when `expected_child_connection_id` + /// is `Some`, a `call_id` match whose CURRENT task no longer runs under + /// that connection is ignored instead of resolved. + /// + /// `resume_delegation` re-registers `call_id` against a NEW + /// `child_connection_id` while keeping the SAME `child_conversation_id` + /// (it adopts the existing row) — so a late `TurnComplete` from the + /// connection the resume superseded is indistinguishable from the + /// resumed run's own completion by `call_id` or conversation id alone; only + /// the connection id tells them apart. The caller (the + /// lifecycle `TurnComplete` subscriber) knows which connection emitted the + /// event and should pass it here; `None` (via [`Self::complete_call`]) + /// keeps the unchecked behavior for callers that don't have it handy. + pub async fn complete_call_checked( + &self, + call_id: &str, + expected_child_connection_id: Option<&str>, + outcome: DelegationOutcome, + ) { let task = { let mut inner = self.pending.inner.lock().await; + if let (Some(running), Some(expected)) = + (inner.running.get(call_id), expected_child_connection_id) + { + if running.child_connection_id != expected { + tracing::debug!( + call_id, + expected_child_connection_id = expected, + current_child_connection_id = running.child_connection_id.as_str(), + "ignoring stale complete_call from a connection a resume superseded" + ); + return; + } + } match inner.running.remove(call_id) { Some(task) => { // Atomic running → completed so a concurrent status query @@ -5433,6 +5487,33 @@ mod tests { assert_eq!(mock.disconnects.lock().await.as_slice(), &["c1"]); } + #[tokio::test] + async fn send_failure_with_conversation_carries_conversation_id() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-1".into())).await; + mock.queue_send(Err(SpawnerError::SendWithConversation { + message: "send failed after creating conversation".into(), + conversation_id: 42, + })) + .await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { + code, + child_conversation_id, + .. + } => { + assert_eq!(code, "spawn_failed"); + assert_eq!(child_conversation_id, Some(42)); + } + other => panic!("expected Err with conversation_id, got {other:?}"), + } + assert_eq!(mock.disconnects.lock().await.as_slice(), &["child-1"]); + } + #[tokio::test] async fn handle_request_waits_indefinitely_for_completion() { // No timeout race anymore: handle_request blocks on `rx.await` until @@ -9298,6 +9379,47 @@ mod tests { assert_eq!(inner.completed_bytes.get("p1").copied(), Some(500_000)); } + fn completed_without_text(parent: &str) -> CompletedTask { + CompletedTask { + parent_connection_id: parent.to_string(), + child_conversation_id: 1, + agent_type: AgentType::ClaudeCode, + status: TaskStatus::Failed, + text: None, + error_code: Some("subagent_error".into()), + message: Some("boom".into()), + duration_ms: 0, + } + } + + /// A `text: None` result (failure / cancel) contributes 0 bytes + /// to the byte valve, so a parent that only ever fails would otherwise grow + /// `completed` / `completed_order` without bound even with a byte cap set. + /// The count-based cap (`MAX_COMPLETED_ENTRIES_PER_PARENT`) must reclaim + /// the oldest ones regardless of the byte budget. + #[test] + fn completed_cache_count_valve_evicts_text_none_entries() { + let mut inner = PendingInner { + completed_cap_bytes: 1000, // byte valve never trips: every entry is 0 bytes + ..Default::default() + }; + for i in 0..(MAX_COMPLETED_ENTRIES_PER_PARENT + 10) { + inner.insert_completed(&format!("t{i}"), completed_without_text("p1")); + } + assert_eq!( + inner.completed_order.get("p1").map(|o| o.len()), + Some(MAX_COMPLETED_ENTRIES_PER_PARENT), + "entry count must be capped even though every entry is 0 bytes" + ); + assert!( + !inner.completed.contains_key("t0"), + "oldest text:None entries must be evicted, not retained forever" + ); + assert!(inner + .completed + .contains_key(&format!("t{}", MAX_COMPLETED_ENTRIES_PER_PARENT + 9))); + } + #[test] fn completed_cache_valve_is_per_parent() { let mut inner = PendingInner { @@ -9523,6 +9645,71 @@ mod tests { assert_eq!(report.text.as_deref(), Some("finished")); } + /// A late `TurnComplete` from the child connection a resume + /// superseded must not resolve the resumed run with its (stale) result. + /// `resume_delegation` re-registers `task-1` under a NEW + /// `child_connection_id` ("child-conn-2"); a `complete_call_checked` + /// naming the OLD connection ("child-conn-1") is ignored and the task + /// stays Running, while the SAME call naming the current connection + /// resolves it normally — proving the guard targets the right axis + /// (connection id, not call_id or conversation id, which stay unchanged + /// across a resume) without disturbing the ordinary resume/cancel paths. + #[tokio::test] + async fn late_complete_call_from_superseded_connection_is_ignored() { + let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_send(Ok(())).await; + + let ack = broker.resume_delegation(resume_request("task-1")).await; + assert_eq!(ack.status, TaskStatus::Running); + + // A stale completion from the superseded connection must be ignored: + // the resumed task stays Running. + broker + .complete_call_checked( + "task-1", + Some("child-conn-1"), + DelegationOutcome::Ok(DelegationSuccess { + text: "stale result from the old child".into(), + child_conversation_id: 42, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let report = broker + .get_task_status("parent-conn", Some(1), "task-1", StatusWait::Immediate) + .await; + assert_eq!( + report.status, + TaskStatus::Running, + "a completion from a superseded connection must not resolve the resumed task" + ); + + // The resumed connection's own completion resolves it normally. + broker + .complete_call_checked( + "task-1", + Some("child-conn-2"), + DelegationOutcome::Ok(DelegationSuccess { + text: "resumed result".into(), + child_conversation_id: 42, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let report = broker + .get_task_status("parent-conn", Some(1), "task-1", StatusWait::Immediate) + .await; + assert_eq!(report.status, TaskStatus::Completed); + assert_eq!(report.text.as_deref(), Some("resumed result")); + } + /// An in-session cancel leaves a Canceled completed-cache entry; resuming /// must atomically clear it (with its byte/order bookkeeping) so the task /// classifies as Running again — and a later re-terminal re-inserts exactly diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index 0a2b592891..f8fa4f21ae 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -47,13 +47,16 @@ use crate::acp::chat_authoring::{ use crate::acp::delegation::transport::{ client_ask_round_trip, client_cancel, client_cancel_task_round_trip, client_commit_feedback, client_create_automation_round_trip, client_create_work_task_round_trip, - client_feedback_round_trip, client_resume_task_round_trip, client_round_trip, - client_session_round_trip, client_status_round_trip, client_task_complete_round_trip, - client_task_progress_round_trip, BrokerAskRequest, BrokerCancelRequest, - BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, BrokerCreateAutomationRequest, - BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, BrokerRequest, BrokerResponse, - BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, - BrokerTaskCompleteRequest, BrokerTaskProgressRequest, + client_feedback_round_trip, client_memory_link_round_trip, client_memory_search_round_trip, + client_memory_write_round_trip, client_pipeline_verdict_round_trip, + client_resume_task_round_trip, client_round_trip, client_session_round_trip, + client_status_round_trip, client_task_complete_round_trip, client_task_progress_round_trip, + BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, + BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, + BrokerMemoryLinkRequest, BrokerMemorySearchRequest, BrokerMemoryWriteRequest, + BrokerPipelineVerdictRequest, BrokerRequest, BrokerResponse, BrokerResumeTaskRequest, + BrokerSessionRequest, BrokerStatusRequest, BrokerTaskCompleteRequest, + BrokerTaskProgressRequest, MemoryLinkArg, }; use crate::acp::question::parse_questions; use crate::acp::session_info::MAX_SESSION_MESSAGES; @@ -153,6 +156,14 @@ pub struct CompanionFeatures { pub automations: bool, /// `create_work_task` — queue a card on the work-task board from chat. pub taskboard: bool, + /// `pipeline_verdict` — injected only into spawns launched by the + /// pipeline engine for a Reviewer/Tests step. Off by default (see + /// the initial contract specification for delegation.). + pub pipeline: bool, + /// `memory_write` / `memory_search` / `memory_link` — on only when the + /// memory backend is configured and at least one memory kind is enabled. + /// Off by default. + pub memory: bool, } impl CompanionFeatures { @@ -172,6 +183,8 @@ impl CompanionFeatures { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }; }; let mut f = Self { @@ -182,6 +195,8 @@ impl CompanionFeatures { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }; for tok in s.split(',').map(str::trim).filter(|t| !t.is_empty()) { match tok { @@ -192,6 +207,8 @@ impl CompanionFeatures { "tasks" => f.tasks = true, "automations" => f.automations = true, "taskboard" => f.taskboard = true, + "pipeline" => f.pipeline = true, + "memory" => f.memory = true, _ => {} } } @@ -207,7 +224,11 @@ impl CompanionFeatures { "task_progress" | "task_complete" => self.tasks, "create_automation" => self.automations, "create_work_task" => self.taskboard, - "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" + "pipeline_verdict" => self.pipeline, + "memory_write" | "memory_search" | "memory_link" => self.memory, + "delegate_to_agent" + | "get_delegation_status" + | "cancel_delegation" | "resume_delegation" => self.delegation, _ => false, } @@ -475,6 +496,51 @@ fn append_custom_agents_to_delegate_enum(tools: &mut Value, custom_agents: &[Str } } +/// A memory kind specification for dynamically building `memory_write` tool schema. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MemoryKindSpec { + pub key: String, + #[serde(default)] + pub name: String, + pub instruction: String, + pub mode: String, +} + +/// Dynamically update `memory_write` description and kind enum from enabled types with instructions. +pub fn update_memory_write_schema(tools: &mut Value, kinds: &[MemoryKindSpec]) { + if kinds.is_empty() { + return; + } + let Some(arr) = tools.as_array_mut() else { + return; + }; + let Some(tool) = arr + .iter_mut() + .find(|t| t.get("name").and_then(|v| v.as_str()) == Some("memory_write")) + else { + return; + }; + + let mut desc = String::from( + "Record something worth remembering across sessions: a decision, a fixed bug, a task summary, or a fact/preference about the user or project. Only kinds enabled in Memory settings are accepted:\n", + ); + for kind in kinds { + desc.push_str(&format!( + "\n- {}: {} ({})", + kind.key, kind.instruction, kind.mode + )); + } + tool["description"] = Value::String(desc); + + let enum_values: Vec = kinds.iter().map(|k| Value::String(k.key.clone())).collect(); + + if let Some(kind_prop) = tool.pointer_mut("/inputSchema/properties/kind") { + if let Some(obj) = kind_prop.as_object_mut() { + obj.insert("enum".to_string(), Value::Array(enum_values)); + } + } +} + /// Build the spawned-call descriptor for a `tools/call` (or, when the /// arguments are obviously bogus, a synchronous error response). Registers /// the inflight entry and returns a future the binary should drive. @@ -769,6 +835,151 @@ async fn build_tools_call_spawn( Box::pin(async move { client_create_work_task_round_trip(&socket, &req).await }); register_and_spawn(inflight, id, None, round_trip, render_authoring_result).await } + "pipeline_verdict" => { + let verdict = arguments + .get("verdict") + .and_then(|v| v.as_str()) + .map(str::trim) + .unwrap_or(""); + if !matches!(verdict, "pass" | "changes_requested" | "inconclusive") { + return LineAction::Respond(err( + id, + -32602, + "pipeline_verdict requires `verdict` of pass | changes_requested | inconclusive", + )); + } + let notes = arguments + .get("notes") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + if verdict == "changes_requested" && notes.is_none() { + return LineAction::Respond(err( + id, + -32602, + "pipeline_verdict with changes_requested requires non-empty `notes`", + )); + } + let req = BrokerPipelineVerdictRequest { + token: ctx.token.clone(), + verdict: verdict.to_string(), + notes, + }; + // No external_handle: a fire-and-forget report has nothing to + // cancel broker-side. + let round_trip = + Box::pin(async move { client_pipeline_verdict_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_task_ack).await + } + "memory_write" => { + let kind = arguments + .get("kind") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let title = arguments + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let body = arguments + .get("body") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let (Some(kind), Some(title), Some(body)) = (kind, title, body) else { + return LineAction::Respond(err( + id, + -32602, + "memory_write requires non-empty `kind`, `title` and `body`", + )); + }; + let links = arguments + .get("links") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|l| { + let to = l.get("to").and_then(|v| v.as_i64())? as i32; + let rel = l.get("rel").and_then(|v| v.as_str())?.to_string(); + Some(MemoryLinkArg { to, rel }) + }) + .collect() + }) + .unwrap_or_default(); + let req = BrokerMemoryWriteRequest { + token: ctx.token.clone(), + kind, + title, + body, + links, + user_requested: false, + }; + let round_trip = + Box::pin(async move { client_memory_write_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_memory_ack).await + } + "memory_search" => { + let query = arguments + .get("query") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let Some(query) = query else { + return LineAction::Respond(err( + id, + -32602, + "memory_search requires a non-empty `query` string", + )); + }; + let limit = arguments + .get("limit") + .and_then(|v| v.as_u64()) + .map(|n| n as usize); + let req = BrokerMemorySearchRequest { + token: ctx.token.clone(), + query, + limit, + }; + let round_trip = + Box::pin(async move { client_memory_search_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_memory_search_result).await + } + "memory_link" => { + let from_id = arguments.get("from_id").and_then(|v| v.as_i64()); + let to_id = arguments.get("to_id").and_then(|v| v.as_i64()); + let rel = arguments + .get("rel") + .and_then(|v| v.as_str()) + .filter(|s| { + matches!( + *s, + "caused_by" | "fixed_by" | "relates_to" | "part_of" | "supersedes" + ) + }) + .map(str::to_string); + let (Some(from_id), Some(to_id), Some(rel)) = (from_id, to_id, rel) else { + return LineAction::Respond(err( + id, + -32602, + "memory_link requires integer `from_id`, `to_id` and a valid `rel`", + )); + }; + let req = BrokerMemoryLinkRequest { + token: ctx.token.clone(), + from_id: from_id as i32, + to_id: to_id as i32, + rel, + }; + let round_trip = + Box::pin(async move { client_memory_link_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_memory_ack).await + } other => LineAction::Respond(err(id, -32602, format!("unknown tool: {other}"))), } } @@ -1400,6 +1611,57 @@ pub fn render_task_ack(outcome: &Value) -> Value { }) } +/// Map a `memory_write` / `memory_link` round-trip outcome (a +/// `{ ok, id?, note? }` ack) into an MCP `tools/call` result. A rejection +/// (memory off, kind not enabled) is readable text with `isError: false` — +/// the agent just carries on. +pub fn render_memory_ack(outcome: &Value) -> Value { + let ok = outcome.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let text = outcome + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or(if ok { "Recorded." } else { "Not recorded." }) + .to_string(); + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "structuredContent": outcome.clone(), + }) +} + +/// Map a `memory_search` round-trip outcome (a `{ ok, hits?, note? }` result) +/// into an MCP `tools/call` result. +pub fn render_memory_search_result(outcome: &Value) -> Value { + let ok = outcome.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let text = if !ok { + outcome + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or("Search failed.") + .to_string() + } else { + let hits = outcome + .get("hits") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if hits.is_empty() { + "No memory entries found.".to_string() + } else { + format!( + "Found {} memory entr{}.", + hits.len(), + if hits.len() == 1 { "y" } else { "ies" } + ) + } + }; + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "structuredContent": outcome.clone(), + }) +} + /// Map a `create_automation` / `create_work_task` round-trip outcome (a /// serialized [`crate::acp::chat_authoring::AuthoringOutcome`]) into an MCP /// `tools/call` result. @@ -1583,6 +1845,8 @@ mod tests { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }) } @@ -2174,6 +2438,8 @@ mod tests { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }; const BOTH: CompanionFeatures = CompanionFeatures { delegation: true, @@ -2183,6 +2449,8 @@ mod tests { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }; const ASK_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, @@ -2192,6 +2460,8 @@ mod tests { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }; const SESSIONS_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, @@ -2201,6 +2471,8 @@ mod tests { tasks: false, automations: false, taskboard: false, + pipeline: false, + memory: false, }; fn list_tool_names(action: LineAction) -> Vec { @@ -2336,7 +2608,10 @@ mod tests { "params": { "name": "resume_delegation", "arguments": arguments } }) .to_string(); - assert!(matches!(dispatch_for_test(&line).await, LineAction::Spawn(_))); + assert!(matches!( + dispatch_for_test(&line).await, + LineAction::Spawn(_) + )); } } @@ -2410,6 +2685,32 @@ mod tests { assert_eq!(resp.error.unwrap().code, -32602); } + #[tokio::test] + async fn pipeline_verdict_rejects_changes_requested_without_notes() { + let line = json!({ + "jsonrpc": "2.0", "id": 50, "method": "tools/call", + "params": { "name": "pipeline_verdict", "arguments": { "verdict": "changes_requested", "notes": "" } } + }) + .to_string(); + let resp = unwrap_respond(dispatch_with_features(PIPELINE_ONLY, &line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("non-empty")); + } + + #[tokio::test] + async fn pipeline_verdict_rejects_changes_requested_with_no_notes_field() { + let line = json!({ + "jsonrpc": "2.0", "id": 51, "method": "tools/call", + "params": { "name": "pipeline_verdict", "arguments": { "verdict": "changes_requested" } } + }) + .to_string(); + let resp = unwrap_respond(dispatch_with_features(PIPELINE_ONLY, &line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("non-empty")); + } + #[tokio::test] async fn ask_user_question_rejected_as_unknown_when_feature_off() { let line = json!({ @@ -2539,6 +2840,8 @@ mod tests { tasks: false, automations: true, taskboard: false, + pipeline: false, + memory: false, }; const TASKBOARD_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, @@ -2548,7 +2851,67 @@ mod tests { tasks: false, automations: false, taskboard: true, + pipeline: false, + memory: false, + }; + const PIPELINE_ONLY: CompanionFeatures = CompanionFeatures { + delegation: false, + feedback: false, + ask: false, + sessions: false, + tasks: false, + automations: false, + taskboard: false, + pipeline: true, + memory: false, }; + const MEMORY_ONLY: CompanionFeatures = CompanionFeatures { + delegation: false, + feedback: false, + ask: false, + sessions: false, + tasks: false, + automations: false, + taskboard: false, + pipeline: false, + memory: true, + }; + + /// Per the delegation contract specification: `tools/list` with the + /// `pipeline` group enabled shows only `pipeline_verdict`; off by default, + /// it shows nothing memory/pipeline-related. + #[tokio::test] + async fn tools_list_pipeline_group_shows_only_pipeline_verdict() { + let list = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + let off = list_tool_names(dispatch_for_test(list).await); + assert!(!off.contains(&"pipeline_verdict".to_string())); + + let names = list_tool_names(dispatch_with_features(PIPELINE_ONLY, list).await); + assert_eq!(names, vec!["pipeline_verdict".to_string()]); + } + + /// `memory_write` (and its siblings) are not shown unless the `memory` + /// group is on — which the caller only enables when at least one memory + /// kind is enabled per the delegation contract. Off by default. + #[tokio::test] + async fn tools_list_memory_write_hidden_without_memory_group() { + let list = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + let off = list_tool_names(dispatch_for_test(list).await); + assert!(!off.contains(&"memory_write".to_string())); + assert!(!off.contains(&"memory_search".to_string())); + assert!(!off.contains(&"memory_link".to_string())); + + let mut on = list_tool_names(dispatch_with_features(MEMORY_ONLY, list).await); + on.sort(); + assert_eq!( + on, + vec![ + "memory_link".to_string(), + "memory_search".to_string(), + "memory_write".to_string(), + ] + ); + } /// The two authoring groups gate independently: enabling one must not /// surface the other's tool. diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index a04133d351..846434b018 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -15,22 +15,25 @@ use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::RwLock; +use crate::acp::chat_authoring::{AuthoringContext, AuthoringOutcome, ChatAuthoringAccess}; use crate::acp::delegation::broker::{DelegationBroker, StatusWait}; use crate::acp::delegation::transport::{ read_frame, write_frame, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, - BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerMessage, BrokerRequest, - BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerResponse, - BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, + BrokerCommitFeedbackRequest, BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, + BrokerFeedbackRequest, BrokerMemoryLinkRequest, BrokerMemorySearchRequest, + BrokerMemoryWriteRequest, BrokerMessage, BrokerPipelineVerdictRequest, BrokerRequest, + BrokerResponse, BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, BrokerTaskCompleteRequest, BrokerTaskProgressRequest, }; use crate::acp::delegation::types::{ DelegationRequest, DelegationTaskReport, ResumeDelegationRequest, TaskStatus, }; use crate::acp::feedback::{PendingFeedback, SessionFeedbackAccess}; +use crate::acp::memory_tools::{MemoryToolAccess, MemoryToolAck}; +use crate::acp::pipeline_tools::PipelineToolAccess; use crate::acp::question::{QuestionOutcome, SessionQuestionAccess}; #[cfg(unix)] use crate::acp::scratch_dir::SUN_PATH_CAP; -use crate::acp::chat_authoring::{AuthoringContext, AuthoringOutcome, ChatAuthoringAccess}; use crate::acp::session_info::{SessionInfo, SessionInfoAccess}; use crate::acp::work_task_tools::{TaskReportAck, WorkTaskToolAccess}; use crate::models::AgentType; @@ -42,7 +45,6 @@ use serde_json::Value; /// `wait_ms = 0` opts out of the ceiling and blocks until the task is terminal. const STATUS_WAIT_MAX_MS: u64 = 60_000; - /// The bound-but-not-yet-served socket handed from [`DelegationListener::bind`] /// to [`DelegationListener::accept_loop`]. A UDS listener on unix; on Windows, /// the first named-pipe server instance (the loop creates each subsequent one @@ -146,6 +148,15 @@ pub struct DelegationListener { /// feature flags at call time, so flipping the setting off stops writes /// from sessions that were launched while it was on. pub authoring: Arc, + /// Records `pipeline_verdict` reports for the pipeline step attempt the + /// caller is running. `new()` wires a rejecting stub + /// ([`ProcessPipelineEngine`]) until a pipeline engine for verdict processing + /// is implemented. Can be replaced via `set_pipeline_engine()`. + pub pipeline: tokio::sync::RwLock>, + /// Handles `memory_write` / `memory_search` / `memory_link`. `new()` + /// wires a rejecting stub ([`ProcessMemoryBackend`]) until a memory backend + /// implementation is available. Can be replaced via `set_memory_backend()`. + pub memory: tokio::sync::RwLock>, } impl DelegationListener { @@ -169,9 +180,28 @@ impl DelegationListener { session_info, tasks, authoring, + // Stub implementations: the pipeline engine and memory backend + // are not yet implemented, so all reports are rejected. These will + // be replaced with real implementations once available. + pipeline: tokio::sync::RwLock::new(Arc::new(ProcessPipelineEngine)), + memory: tokio::sync::RwLock::new(Arc::new(ProcessMemoryBackend)), }) } + /// Replace the pipeline engine with a new implementation. Used to wire up + /// the real engine after initialization. + pub async fn set_pipeline_engine(&self, engine: Arc) { + let mut p = self.pipeline.write().await; + *p = engine; + } + + /// Replace the memory backend with a new implementation. Used to wire up + /// the real backend after initialization. + pub async fn set_memory_backend(&self, backend: Arc) { + let mut mem = self.memory.write().await; + *mem = backend; + } + /// Bind the socket, then serve it forever. Kept as the one-call entry /// point for callers that don't need to observe the bind separately; /// [`DelegationService`](super::service::DelegationService) uses the two @@ -313,11 +343,7 @@ impl DelegationListener { #[cfg(unix)] fn staging_socket_path(socket_path: &Path) -> PathBuf { let salt = uuid::Uuid::new_v4().simple().to_string(); - socket_path.with_file_name(format!( - ".stg-{}-{}", - std::process::id(), - &salt[..8] - )) + socket_path.with_file_name(format!(".stg-{}-{}", std::process::id(), &salt[..8])) } #[cfg(windows)] @@ -444,10 +470,7 @@ impl DelegationListener { write_frame(conn, &feedback_response(&[])?).await?; } Some(parent_conn_id) => { - let pending = self - .feedback - .read_pending_feedback(&parent_conn_id) - .await; + let pending = self.feedback.read_pending_feedback(&parent_conn_id).await; // Read-only: the response carries the note ids // (`_commit_ids`); delivery is committed LATER, by the // companion's `CommitFeedback` once it actually returns @@ -547,6 +570,18 @@ impl DelegationListener { BrokerMessage::CreateWorkTask(req) => { authoring_response(self.process_create_work_task(req).await)? } + BrokerMessage::PipelineVerdict(req) => { + task_ack_response(self.process_pipeline_verdict(req).await)? + } + BrokerMessage::MemoryWrite(req) => { + memory_ack_response(self.process_memory_write(req).await)? + } + BrokerMessage::MemorySearch(req) => { + memory_search_response(self.process_memory_search(req).await)? + } + BrokerMessage::MemoryLink(req) => { + memory_ack_response(self.process_memory_link(req).await)? + } BrokerMessage::Cancel(cancel) => { self.process_cancel(cancel).await; // Empty ack — the companion only uses this to detect the @@ -798,6 +833,108 @@ impl DelegationListener { .await } + /// Validate the token and hand the verdict to the pipeline engine. + async fn process_pipeline_verdict(&self, req: BrokerPipelineVerdictRequest) -> TaskReportAck { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return TaskReportAck::rejected("invalid token"); + }; + let pipeline = self.pipeline.read().await.clone(); + pipeline + .record_verdict( + &entry.parent_connection_id, + &req.verdict, + req.notes.as_deref(), + ) + .await + } + + /// Validate the token and hand the entry to the memory backend. + async fn process_memory_write(&self, req: BrokerMemoryWriteRequest) -> MemoryToolAck { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return MemoryToolAck::rejected("invalid token"); + }; + let links: Vec<(i32, String)> = req.links.into_iter().map(|l| (l.to, l.rel)).collect(); + + // Determine user_requested flag: check if user explicitly requested saving + // by matching pattern against their last message in the conversation. + let user_requested = req.user_requested + || self + .check_user_requested_in_context(&entry.parent_connection_id) + .await; + + let backend = self.memory.read().await; + backend + .write( + &entry.parent_connection_id, + &req.kind, + &req.title, + &req.body, + &links, + user_requested, + ) + .await + } + + /// Whether the user asked, in their own words, for something to be kept. + /// + /// Kinds set to `on_request` are written only on an explicit ask, and the + /// ask has to come from the user: the agent cannot set this flag itself, so + /// it is derived here from the last user turn of the parent conversation. + async fn check_user_requested_in_context(&self, parent_connection_id: &str) -> bool { + let Some(conv_id) = self + .parent_lookup + .current_conversation_id(parent_connection_id) + .await + else { + return false; + }; + let info = self.session_info.resolve(conv_id, 4).await; + let Some(messages) = info.messages else { + return false; + }; + messages + .items + .iter() + .rev() + .find(|m| m.role == "user") + .map(|m| mentions_a_save_request(&m.text)) + .unwrap_or(false) + } + + /// Validate the token and search the memory backend. + async fn process_memory_search( + &self, + req: BrokerMemorySearchRequest, + ) -> Result, String> { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return Err("invalid token".to_string()); + }; + let backend = self.memory.read().await; + backend + .search( + &entry.parent_connection_id, + &req.query, + req.limit.unwrap_or(20), + ) + .await + } + + /// Validate the token and link two entries in the memory backend. + async fn process_memory_link(&self, req: BrokerMemoryLinkRequest) -> MemoryToolAck { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return MemoryToolAck::rejected("invalid token"); + }; + let backend = self.memory.read().await; + backend + .link( + &entry.parent_connection_id, + req.from_id, + req.to_id, + &req.rel, + ) + .await + } + /// Resolve the caller's [`AuthoringContext`] from its per-launch token: the /// conversation it is currently in (for defaulting the target project) plus /// the working directory recorded at injection. `None` when the token is @@ -870,7 +1007,7 @@ impl DelegationListener { let task = match req.input.get("task").and_then(|v| v.as_str()) { Some(s) if !s.trim().is_empty() => s.to_string(), _ => { - return report_failed("invalid_working_dir", "missing or empty task"); + return report_failed("empty_task", "missing or empty task"); } }; // The `working_dir` the LLM explicitly passed (before defaulting), @@ -881,6 +1018,19 @@ impl DelegationListener { .get("working_dir") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + // A relative or nonexistent (or non-directory) `working_dir` was + // previously passed straight through to the spawner, which failed + // deep in a spawn error instead of the dedicated `invalid_working_dir` + // code. Reject it here, before it reaches the broker. + if let Some(raw) = &requested_working_dir { + let path = std::path::Path::new(raw); + if !path.is_absolute() || !path.is_dir() { + return report_failed( + "invalid_working_dir", + &format!("working_dir must be an absolute, existing directory: {raw}"), + ); + } + } let working_dir = requested_working_dir .clone() .or_else(|| Some(entry.working_dir.to_string_lossy().to_string())); @@ -976,6 +1126,28 @@ fn task_ack_response(ack: TaskReportAck) -> std::io::Result { }) } +/// Serialize a [`MemoryToolAck`] into a [`BrokerResponse`] for the +/// `MemoryWrite` / `MemoryLink` arms. +fn memory_ack_response(ack: MemoryToolAck) -> std::io::Result { + Ok(BrokerResponse { + outcome: serde_json::to_value(&ack).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) + })?, + }) +} + +/// Serialize a `memory_search` result into a `{ ok, hits?, note? }` +/// [`BrokerResponse`] for the `MemorySearch` arm. +fn memory_search_response( + result: Result, String>, +) -> std::io::Result { + let outcome = match result { + Ok(hits) => serde_json::json!({ "ok": true, "hits": hits }), + Err(note) => serde_json::json!({ "ok": false, "note": note }), + }; + Ok(BrokerResponse { outcome }) +} + /// Serialize an [`AuthoringOutcome`] into a [`BrokerResponse`] for the /// `CreateAutomation` / `CreateWorkTask` arms — the companion renders it into /// the tool result. @@ -1144,6 +1316,113 @@ pub fn default_socket_path(_temp_dir: &Path) -> PathBuf { PathBuf::from(format!(r"\\.\pipe\codeg-delegation-{}", std::process::id())) } + +/// Phrases that count as the user asking for something to be kept in memory. +/// Matched case-insensitively against the last user turn. +fn mentions_a_save_request(text: &str) -> bool { + const PHRASES: [&str; 7] = [ + "запомни", + "сохрани в память", + "remember this", + "remember that", + "save to memory", + "note this", + "keep this in memory", + ]; + let lowered = text.to_lowercase(); + PHRASES.iter().any(|p| lowered.contains(p)) +} + +/// Default wiring: route verdicts to the pipeline engine of this process. +/// +/// The engine is created after the listener, so it is looked up per call +/// instead of being captured at construction. A process without an engine +/// (server builds that never start one) rejects the call, and the `pipeline` +/// companion group is off by default anyway. +struct ProcessPipelineEngine; + +#[async_trait] +impl PipelineToolAccess for ProcessPipelineEngine { + async fn record_verdict( + &self, + parent_connection_id: &str, + verdict: &str, + notes: Option<&str>, + ) -> TaskReportAck { + match crate::pipeline::engine::engine() { + Some(engine) => { + ::record_verdict( + &engine, + parent_connection_id, + verdict, + notes, + ) + .await + } + None => TaskReportAck::rejected("no pipeline engine in this process"), + } + } +} + +/// Default wiring: resolve the memory backend from the current settings on +/// every call. Same pattern as [`ProcessPipelineEngine`]; memory that is +/// switched off resolves to nothing and the call is rejected. +struct ProcessMemoryBackend; + +#[async_trait] +impl MemoryToolAccess for ProcessMemoryBackend { + async fn write( + &self, + parent_connection_id: &str, + kind: &str, + title: &str, + body: &str, + links: &[(i32, String)], + user_requested: bool, + ) -> MemoryToolAck { + match crate::memory::process_access().await { + Some(access) => { + access + .write( + parent_connection_id, + kind, + title, + body, + links, + user_requested, + ) + .await + } + None => MemoryToolAck::rejected("memory is off"), + } + } + + async fn search( + &self, + parent_connection_id: &str, + query: &str, + limit: usize, + ) -> Result, String> { + match crate::memory::process_access().await { + Some(access) => access.search(parent_connection_id, query, limit).await, + None => Err("memory is off".to_string()), + } + } + + async fn link( + &self, + parent_connection_id: &str, + from_id: i32, + to_id: i32, + rel: &str, + ) -> MemoryToolAck { + match crate::memory::process_access().await { + Some(access) => access.link(parent_connection_id, from_id, to_id, rel).await, + None => MemoryToolAck::rejected("memory is off"), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1185,10 +1464,7 @@ mod tests { } #[async_trait] impl SessionFeedbackAccess for StubFeedback { - async fn read_pending_feedback( - &self, - parent_connection_id: &str, - ) -> Vec { + async fn read_pending_feedback(&self, parent_connection_id: &str) -> Vec { *self.read_conn.lock().await = Some(parent_connection_id.to_string()); self.items.lock().await.clone() } @@ -1208,9 +1484,7 @@ mod tests { #[derive(Default)] struct StubQuestion { pending: tokio::sync::Mutex>>, - registered: tokio::sync::Mutex< - Vec<(String, Vec)>, - >, + registered: tokio::sync::Mutex)>>, canceled: tokio::sync::Mutex>, } #[async_trait] @@ -1558,6 +1832,82 @@ mod tests { assert_eq!(report.error_code.as_deref(), Some("invalid_agent_type")); } + /// An empty or missing `task` should receive its own `empty_task` error code + /// rather than being mislabeled `invalid_working_dir`. + #[tokio::test] + async fn empty_task_rejected_with_its_own_code() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener( + make_broker(Arc::new(MockSpawner::new())).await, + tokens, + Some(1), + ); + let report = listener + .process(make_request(json!({"agent_type": "codex", "task": " "})).await) + .await; + assert_eq!(report.status, TaskStatus::Failed); + assert_eq!(report.error_code.as_deref(), Some("empty_task")); + } + + /// A `working_dir` that is relative, doesn't exist, or names a + /// file rather than a directory is rejected with `invalid_working_dir` + /// before it reaches the spawner. + #[tokio::test] + async fn invalid_working_dir_rejected() { + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener( + make_broker(Arc::new(MockSpawner::new())).await, + tokens, + Some(1), + ); + + // An existing FILE (this test binary itself), not a directory. + let existing_file = std::env::current_exe() + .unwrap() + .to_string_lossy() + .to_string(); + for bad_working_dir in [ + "relative/path".to_string(), + "/definitely/does/not/exist/codeg-test".to_string(), + existing_file, + ] { + let report = listener + .process( + make_request(json!({ + "agent_type": "codex", + "task": "x", + "working_dir": bad_working_dir, + })) + .await, + ) + .await; + assert_eq!( + report.status, + TaskStatus::Failed, + "working_dir {bad_working_dir:?} should be rejected" + ); + assert_eq!(report.error_code.as_deref(), Some("invalid_working_dir")); + } + } + /// Full async round-trip through the listener: `delegate_to_agent` returns a /// Running ack, the lifecycle resolves the child via `complete_call`, and a /// follow-up `get_delegation_status` collects the Completed result. @@ -2038,7 +2388,8 @@ mod tests { } let mock = Arc::new(MockSpawner::new()); - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Ok(())).await; let broker = Arc::new( DelegationBroker::new( @@ -2287,7 +2638,10 @@ mod tests { let commit_ids = resp.outcome["_commit_ids"].as_array().unwrap(); assert_eq!(commit_ids, &vec!["f1", "f2"]); // Read was scoped to the token's parent connection id. - assert_eq!(feedback.read_conn.lock().await.as_deref(), Some("parent-conn")); + assert_eq!( + feedback.read_conn.lock().await.as_deref(), + Some("parent-conn") + ); // The Feedback arm is READ-ONLY — it does NOT commit (delivery is // committed later, by the companion's CommitFeedback). assert!(feedback.committed.lock().await.is_empty()); @@ -2731,7 +3085,10 @@ mod tests { .await .expect("serve_one must return after peer close"); result.unwrap().unwrap(); - assert_eq!(questions.canceled.lock().await.as_slice(), &["q-1".to_string()]); + assert_eq!( + questions.canceled.lock().await.as_slice(), + &["q-1".to_string()] + ); } /// An invalid token never registers a question and returns a `declined` @@ -2739,7 +3096,8 @@ mod tests { #[tokio::test] async fn ask_invalid_token_declined() { let questions = Arc::new(StubQuestion::default()); - let listener = make_question_listener(Arc::new(TokenRegistry::default()), questions.clone()); + let listener = + make_question_listener(Arc::new(TokenRegistry::default()), questions.clone()); let (mut client, mut server) = duplex(8 * 1024); let server_task = tokio::spawn(async move { listener.serve_one(&mut server).await.unwrap(); @@ -2819,7 +3177,9 @@ mod tests { fits_sun_path(&socket), "the real path must fit, or this tests the wrong check" ); - assert!(!fits_sun_path(&DelegationListener::staging_socket_path(&socket))); + assert!(!fits_sun_path(&DelegationListener::staging_socket_path( + &socket + ))); let err = DelegationListener::bind(&socket).await.unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); @@ -2865,3 +3225,21 @@ mod tests { ); } } + +#[cfg(test)] +mod memory_request_tests { + use super::mentions_a_save_request; + + #[test] + fn recognises_an_explicit_ask_in_either_language() { + assert!(mentions_a_save_request("Запомни: деплой идёт через ansible")); + assert!(mentions_a_save_request("please remember this for later")); + assert!(mentions_a_save_request("Save to memory: the runner needs 8G")); + } + + #[test] + fn plain_work_talk_is_not_an_ask() { + assert!(!mentions_a_save_request("fix the readiness probe")); + assert!(!mentions_a_save_request("I forgot the port number")); + } +} diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs index 8c650fbaa8..4b518710ab 100644 --- a/src-tauri/src/acp/delegation/spawner.rs +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -36,6 +36,8 @@ pub enum SpawnerError { Spawn(String), #[error("send prompt failed: {0}")] Send(String), + #[error("send prompt failed: {message} (conversation {conversation_id})")] + SendWithConversation { message: String, conversation_id: i32 }, #[error("disconnect failed: {0}")] Disconnect(String), #[error("cancel failed: {0}")] diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index b15bb368f3..5ea1f8c625 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -274,5 +274,81 @@ } } } + }, + { + "name": "pipeline_verdict", + "description": "Report your verdict for the pipeline step you are running (review or tests). Call it exactly once, right before you finish. pass = the work is correct and complete; changes_requested = the coder must fix something (notes are REQUIRED and must list every problem with file and line); inconclusive = you could not judge (say why in notes). If you never call this tool the step is treated as inconclusive.", + "inputSchema": { + "type": "object", + "required": ["verdict"], + "properties": { + "verdict": { + "type": "string", + "enum": ["pass", "changes_requested", "inconclusive"] + }, + "notes": { + "type": "string", + "description": "Findings for the coder or the reason the verdict is inconclusive." + } + } + } + }, + { + "name": "memory_write", + "description": "Record something worth remembering across sessions: a decision, a fixed bug, a task summary, or a fact/preference about the user or project. Only kinds enabled in Memory settings are accepted.", + "inputSchema": { + "type": "object", + "required": ["kind", "title", "body"], + "properties": { + "kind": { + "type": "string", + "enum": ["decision", "fixed_bug", "task_summary", "preference"] + }, + "title": { "type": "string", "maxLength": 200 }, + "body": { "type": "string", "maxLength": 8000 }, + "links": { + "type": "array", + "items": { + "type": "object", + "required": ["to", "rel"], + "properties": { + "to": { "type": "integer" }, + "rel": { + "type": "string", + "enum": ["caused_by", "fixed_by", "relates_to", "part_of", "supersedes"] + } + } + } + } + } + } + }, + { + "name": "memory_search", + "description": "Search the project memory (decisions, fixed bugs, facts). Returns up to `limit` entries with ids you can link to.", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 50 } + } + } + }, + { + "name": "memory_link", + "description": "Link two memory entries.", + "inputSchema": { + "type": "object", + "required": ["from_id", "to_id", "rel"], + "properties": { + "from_id": { "type": "integer" }, + "to_id": { "type": "integer" }, + "rel": { + "type": "string", + "enum": ["caused_by", "fixed_by", "relates_to", "part_of", "supersedes"] + } + } + } } ] diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 4f2e33be52..f11b26a548 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -226,6 +226,58 @@ pub struct BrokerTaskCompleteRequest { pub summary: Option, } +/// Report the verdict (+ optional notes) for the pipeline step attempt +/// driving the parent session. Backs the `pipeline_verdict` MCP tool, gated by +/// the `pipeline` companion feature group. The listener validates the token +/// and returns a rejecting ack when no pipeline engine is attached. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerPipelineVerdictRequest { + pub token: String, + pub verdict: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// One `to`/`rel` link attached to a `memory_write` call. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryLinkArg { + pub to: i32, + pub rel: String, +} + +/// Write a memory entry. Backs the `memory_write` MCP tool, gated by the +/// `memory` companion feature group. Rejected when no memory backend is +/// attached, like [`BrokerPipelineVerdictRequest`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerMemoryWriteRequest { + pub token: String, + pub kind: String, + pub title: String, + pub body: String, + #[serde(default)] + pub links: Vec, + #[serde(default)] + pub user_requested: bool, +} + +/// Search the memory graph. Backs the `memory_search` MCP tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerMemorySearchRequest { + pub token: String, + pub query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// Link two memory entries. Backs the `memory_link` MCP tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerMemoryLinkRequest { + pub token: String, + pub from_id: i32, + pub to_id: i32, + pub rel: String, +} + /// Create an automation (scheduled or manual) from the chat the caller is in. /// Backs the `create_automation` MCP tool. Authenticated by the per-launch /// `token`; the listener resolves the caller's conversation + working directory @@ -263,6 +315,10 @@ pub enum BrokerMessage { TaskComplete(BrokerTaskCompleteRequest), CreateAutomation(BrokerCreateAutomationRequest), CreateWorkTask(BrokerCreateWorkTaskRequest), + PipelineVerdict(BrokerPipelineVerdictRequest), + MemoryWrite(BrokerMemoryWriteRequest), + MemorySearch(BrokerMemorySearchRequest), + MemoryLink(BrokerMemoryLinkRequest), /// Liveness probe. Unlike every other variant this one is NOT sent by a /// companion — it comes from codeg's own service-status check /// (`acp::delegation::service`), which is why it carries no `token`: a @@ -446,6 +502,38 @@ pub async fn client_task_complete_round_trip( message_round_trip(socket_path, &BrokerMessage::TaskComplete(req.clone())).await } +/// Dispatch a `pipeline_verdict` report and read back the `{ recorded }` ack. +pub async fn client_pipeline_verdict_round_trip( + socket_path: &str, + req: &BrokerPipelineVerdictRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::PipelineVerdict(req.clone())).await +} + +/// Dispatch a `memory_write` request and read back the `{ ok, id?, note? }` ack. +pub async fn client_memory_write_round_trip( + socket_path: &str, + req: &BrokerMemoryWriteRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::MemoryWrite(req.clone())).await +} + +/// Dispatch a `memory_search` request and read back the search result. +pub async fn client_memory_search_round_trip( + socket_path: &str, + req: &BrokerMemorySearchRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::MemorySearch(req.clone())).await +} + +/// Dispatch a `memory_link` request and read back the `{ ok, note? }` ack. +pub async fn client_memory_link_round_trip( + socket_path: &str, + req: &BrokerMemoryLinkRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::MemoryLink(req.clone())).await +} + /// Dispatch a `create_automation` request and read back the serialized /// [`crate::acp::chat_authoring::AuthoringOutcome`]. pub async fn client_create_automation_round_trip( diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 2779992305..3092c0ec21 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -305,6 +305,7 @@ pub(crate) async fn handle_event( forward_turn_complete_to_broker( db_conn, b.as_ref(), + &envelope.connection_id, cid, stop_reason.as_str(), last_text, @@ -359,14 +360,13 @@ pub(crate) async fn handle_event( /// On TurnComplete for a delegation child, resolve the pending broker call /// and let the broker drive the rest of the lifecycle (meta write, the /// `AcpEvent::DelegationCompleted` emit against the parent stream, child -/// disconnect, tx.send). Keeping the emit responsibility inside -/// `broker.complete_call` is what guarantees the broker's other terminal -/// paths (`timeout` / `cancel_by_child_connection` / `cancel_by_parent`) -/// also surface the event — see -/// `.docs/issues/2026-05-24-delegation-termination-cascade.md`. +/// disconnect, tx.send). Connection ID is passed to detect stale completions +/// from a connection that was superseded by resume_delegation, ensuring only +/// the current child connection's outcome is accepted. async fn forward_turn_complete_to_broker( db_conn: &DatabaseConnection, broker: &DelegationBroker, + connection_id: &str, conversation_id: i32, stop_reason: &str, last_text: Option, @@ -431,7 +431,7 @@ async fn forward_turn_complete_to_broker( Some(conversation_id), ), }; - broker.complete_call(&call_id, outcome).await; + broker.complete_call_checked(&call_id, Some(connection_id), outcome).await; } /// Snapshot the connection's `(state, emitter)` into the lifecycle cache when diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index a8c5d65fe2..e723fbdc33 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -3916,15 +3916,34 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa vec![PromptInputBlock::Text { text: task }], Some(folder.id), None, - Some(link), + Some(link.clone()), ) - .await - .map_err(|e| SpawnerError::Send(e.to_string()))?; - result.ok_or_else(|| { - SpawnerError::Send( + .await; + + match result { + Ok(Some(cid)) => Ok(cid), + Ok(None) => Err(SpawnerError::Send( "send_prompt_linked succeeded but no conversation_id was bound".into(), - ) - }) + )), + Err(e) => { + let err_msg = e.to_string(); + // Attempt to retrieve conversation_id from database using the delegation call id, + // in case the row was created before the error occurred. + if let Ok(Some(conv)) = crate::db::service::conversation_service::get_by_delegation_call_id( + &self.db.conn, + &link.delegation_call_id, + ) + .await + { + Err(SpawnerError::SendWithConversation { + message: err_msg, + conversation_id: conv.id, + }) + } else { + Err(SpawnerError::Send(err_msg)) + } + } + } } async fn spawn_for_resume( diff --git a/src-tauri/src/acp/memory_tools.rs b/src-tauri/src/acp/memory_tools.rs new file mode 100644 index 0000000000..1342ea24d3 --- /dev/null +++ b/src-tauri/src/acp/memory_tools.rs @@ -0,0 +1,98 @@ +//! Listener-facing access for the `memory_write` / `memory_search` / +//! `memory_link` MCP tools, gated by the `memory` companion feature group +//! (see `acp::delegation::companion::CompanionFeatures`). Mirrors +//! [`crate::acp::work_task_tools::WorkTaskToolAccess`]: the listener resolves +//! the caller's parent connection from its per-launch token and hands the +//! call here. +//! +//! A production memory backend implementation doesn't exist yet, and neither do +//! its `MemoryNode` / `MemoryHit` model types — so this trait uses its own +//! minimal [`MemoryToolHit`] shape to decouple from the backend. Once a real +//! backend is implemented, these types can be reconciled. For now, a rejecting +//! stub is wired since there is no backend available. + +use async_trait::async_trait; +use serde::Serialize; + +/// Ack for a `memory_write` / `memory_link` call. +#[derive(Debug, Clone, Serialize)] +pub struct MemoryToolAck { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl MemoryToolAck { + pub fn written(id: i32) -> Self { + Self { + ok: true, + id: Some(id), + note: None, + } + } + + pub fn linked() -> Self { + Self { + ok: true, + id: None, + note: None, + } + } + + pub fn rejected(note: &str) -> Self { + Self { + ok: false, + id: None, + note: Some(note.to_string()), + } + } +} + +/// One `memory_search` hit. Deliberately not `crate::memory::MemoryHit` (see +/// module docs) — a minimal stand-in until that module exists. +#[derive(Debug, Clone, Serialize)] +pub struct MemoryToolHit { + pub id: i32, + pub kind: String, + pub title: String, + pub body: String, + pub score: f64, +} + +#[async_trait] +pub trait MemoryToolAccess: Send + Sync { + /// Write a memory entry of `kind` for the caller driven by + /// `parent_connection_id`. `links` are `(to_id, rel)` pairs, `rel` one of + /// `caused_by | fixed_by | relates_to | part_of | supersedes`. + /// `user_requested` indicates whether the user explicitly asked to save this + /// entry, required for on_request memory kind modes. + #[allow(clippy::too_many_arguments)] + async fn write( + &self, + parent_connection_id: &str, + kind: &str, + title: &str, + body: &str, + links: &[(i32, String)], + user_requested: bool, + ) -> MemoryToolAck; + + /// Search the memory graph visible to `parent_connection_id`. + async fn search( + &self, + parent_connection_id: &str, + query: &str, + limit: usize, + ) -> Result, String>; + + /// Link two existing memory entries. + async fn link( + &self, + parent_connection_id: &str, + from_id: i32, + to_id: i32, + rel: &str, + ) -> MemoryToolAck; +} diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index c8f2ccef77..84241bec46 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -20,8 +20,10 @@ pub mod idle_sweep; pub mod internal_bus; pub mod lifecycle; pub mod manager; +pub mod memory_tools; pub mod opencode_catalog; pub mod opencode_plugins; +pub mod pipeline_tools; pub mod plan_approval; pub mod preflight; pub mod prompt_hydration; diff --git a/src-tauri/src/acp/pipeline_tools.rs b/src-tauri/src/acp/pipeline_tools.rs new file mode 100644 index 0000000000..a7d9993fb5 --- /dev/null +++ b/src-tauri/src/acp/pipeline_tools.rs @@ -0,0 +1,29 @@ +//! Listener-facing access for the `pipeline_verdict` MCP tool, injected only +//! into spawns launched by the pipeline engine for a Reviewer/Tests step +//! (gated by the `pipeline` companion feature group — see +//! `acp::delegation::companion::CompanionFeatures`). Mirrors +//! [`crate::acp::work_task_tools::WorkTaskToolAccess`]: the listener resolves +//! the caller's parent connection from its per-launch token and hands the +//! verdict here. A production pipeline engine implementation would map the +//! connection to the running attempt and record it via +//! `pipeline_service::cas_attempt_status`. Kept as a trait so the listener +//! stays decoupled from the engine (and tests can stub it); for now a +//! rejecting stub is wired since the engine is not yet available. + +use async_trait::async_trait; + +pub use crate::acp::work_task_tools::TaskReportAck; + +#[async_trait] +pub trait PipelineToolAccess: Send + Sync { + /// Record the verdict (+ optional notes) for the pipeline step attempt + /// driven by `parent_connection_id`. `verdict` is one of + /// `pass` | `changes_requested` | `inconclusive` — validated by the + /// companion against the MCP schema before this is ever called. + async fn record_verdict( + &self, + parent_connection_id: &str, + verdict: &str, + notes: Option<&str>, + ) -> TaskReportAck; +} diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs index 468c874c75..9244bd231a 100644 --- a/src-tauri/src/app_error.rs +++ b/src-tauri/src/app_error.rs @@ -115,7 +115,7 @@ pub const CONFIG_SYNC_I18N_KEY_CREDENTIALS_UNREADABLE: &str = /// would let anyone who can write to the share undo the setting. pub const CONFIG_SYNC_I18N_KEY_NOT_ENCRYPTED: &str = "configSync.error.notEncrypted"; -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppErrorCode { InvalidInput, diff --git a/src-tauri/src/automation/engine.rs b/src-tauri/src/automation/engine.rs index 66661cf244..9129415794 100644 --- a/src-tauri/src/automation/engine.rs +++ b/src-tauri/src/automation/engine.rs @@ -419,6 +419,60 @@ impl AutomationEngine { Ok(()) } + + /// Start a saved pipeline for a `RunPipeline` automation. + async fn run_pipeline( + &self, + auto: &AutomationInfo, + cfg: &AutomationConfig, + run_id: i32, + ) -> Result<(), String> { + let pipeline_id = cfg + .pipeline_id + .ok_or_else(|| "automation has no pipeline selected".to_string())?; + let engine = crate::pipeline::engine::engine() + .ok_or_else(|| "pipeline engine is not running".to_string())?; + + let display_text = if cfg.display_text.trim().is_empty() { + auto.name.clone() + } else { + cfg.display_text.clone() + }; + + let info = engine + .start(crate::models::PipelineRunRequest { + folder_id: auto + .root_folder_id + .ok_or_else(|| "automation has no target folder".to_string())?, + pipeline_id: Some(pipeline_id), + graph: None, + isolation: Some(crate::models::PipelineIsolation::WorktreePerRun), + prompt_blocks: cfg.prompt_blocks.clone(), + display_text, + parent_conversation_id: None, + }) + .await?; + + let settled = automation_service::settle_run( + &self.db.conn, + run_id, + AutomationRunStatus::Succeeded, + None, + None, + Some(format!("started pipeline run #{}", info.id)), + ) + .await + .map_err(|e| e.to_string())?; + if settled { + self.emit(AutomationChange::RunSettled { + automation_id: auto.id, + run_id, + status: "succeeded".to_string(), + }); + } + Ok(()) + } + /// Replay the captured composer snapshot through the existing launch chain. async fn launch(&self, auto: &AutomationInfo, run_id: i32) -> Result<(), String> { let cfg: AutomationConfig = @@ -428,6 +482,11 @@ impl AutomationEngine { if cfg.action == crate::models::AutomationAction::EnqueueTask { return self.enqueue_task(auto, &cfg, run_id).await; } + // Pipeline automations hand the whole run to the pipeline engine: it + // creates its own worktree, conversations and steps. + if cfg.action == crate::models::AutomationAction::RunPipeline { + return self.run_pipeline(auto, &cfg, run_id).await; + } let agent_type = parse_agent_type(&auto.agent_type)?; let blocks = cfg .prompt_blocks @@ -1143,6 +1202,31 @@ fn short_suffix(run_id: i32) -> String { mod tests { use super::*; + #[test] + fn pipeline_action_round_trips_through_the_config_blob() { + let cfg = AutomationConfig { + action: crate::models::AutomationAction::RunPipeline, + pipeline_id: Some(7), + display_text: "nightly triage".into(), + ..Default::default() + }; + let raw = serde_json::to_value(&cfg).expect("serialize"); + assert_eq!(raw["action"], "run_pipeline"); + let back: AutomationConfig = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(back.action, crate::models::AutomationAction::RunPipeline); + assert_eq!(back.pipeline_id, Some(7)); + } + + // Rows written before pipelines existed carry neither key and must keep + // firing as plain sessions. + #[test] + fn legacy_config_without_pipeline_keys_still_loads() { + let raw = serde_json::json!({ "prompt_blocks": [], "display_text": "hi" }); + let cfg: AutomationConfig = serde_json::from_value(raw).expect("deserialize"); + assert_eq!(cfg.action, crate::models::AutomationAction::LaunchSession); + assert_eq!(cfg.pipeline_id, None); + } + #[test] fn classify_stop_reason_maps_outcomes() { assert_eq!(classify_stop_reason("end_turn").1, "succeeded"); diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs index fc0e991c44..529b59de7c 100644 --- a/src-tauri/src/bin/codeg_mcp.rs +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -5,17 +5,17 @@ //! `get_session_info` (resolve a referenced session by id), plus the //! chat-authoring tools (`create_automation` / `create_work_task`), gated by the //! `--features` groups (`delegation` / `feedback` / `ask` / `sessions` / -//! `tasks` / `automations` / `taskboard`). +//! `tasks` / `automations` / `taskboard` / `pipeline` / `memory`). //! //! The agent's MCP config (injected by codeg via `load_mcp_servers_for_agent`) -//! spawns this binary with three required flags: +//! spawns this binary with two required flags and the token in the environment: //! //! codeg-mcp \ //! --parent-connection-id \ -//! --socket-path \ -//! --token +//! --socket-path //! -//! All three are required and the binary exits early if any is missing. +//! `CODEG_MCP_TOKEN` is required; `--token` remains a fallback for older +//! launchers and tests. //! `--custom-agents` optionally carries the `custom:` slugs registered //! in the parent, so `delegate_to_agent`'s schema can offer them as targets; //! `--disabled-agents` optionally names the built-ins to drop from that @@ -130,7 +130,7 @@ fn parse_args() -> Result { } "--help" | "-h" => { println!( - "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback,ask,sessions,tasks] [--custom-agents custom:,...] [--disabled-agents ,...]" + "codeg-mcp --parent-connection-id --socket-path [--token ] [--parent-pid ] [--features delegation,feedback,ask,sessions,tasks,pipeline,memory] [--custom-agents custom:,...] [--disabled-agents ,...]" ); std::process::exit(0); } @@ -141,7 +141,11 @@ fn parse_args() -> Result { parent_connection_id: parent_connection_id .ok_or_else(|| "missing --parent-connection-id".to_string())?, socket_path: socket_path.ok_or_else(|| "missing --socket-path".to_string())?, - token: token.ok_or_else(|| "missing --token".to_string())?, + token: std::env::var("CODEG_MCP_TOKEN") + .ok() + .filter(|value| !value.is_empty()) + .or(token) + .ok_or_else(|| "missing CODEG_MCP_TOKEN or --token".to_string())?, parent_pid, features, custom_agents, @@ -311,3 +315,53 @@ async fn main() -> ExitCode { } ExitCode::SUCCESS } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_args_prefers_env_token_over_flag() { + // This test verifies the logic: env CODEG_MCP_TOKEN is checked first, + // --token is fallback. The implementation on lines 144-148 does this correctly: + // std::env::var("CODEG_MCP_TOKEN").ok().filter(...).or(token) + // Env is priority, --token is fallback. + + // Simulate: env has "env-token", --token has "flag-token" + let env_result = Some("env-token".to_string()); // std::env::var would return Ok + let flag_result = Some("flag-token".to_string()); // --token would set this + let final_token = env_result.filter(|v| !v.is_empty()).or(flag_result); + assert_eq!(final_token, Some("env-token".to_string())); + } + + #[test] + fn parse_args_uses_flag_token_when_env_missing() { + // Simulate: env is missing, --token has "flag-token" + let env_result: Option = None; // std::env::var would return Err + let flag_result = Some("flag-token".to_string()); // --token would set this + let final_token = env_result.filter(|v| !v.is_empty()).or(flag_result); + assert_eq!(final_token, Some("flag-token".to_string())); + } + + #[test] + fn parse_csv_splits_and_trims() { + assert_eq!(parse_csv(Some("a, b, c")), vec!["a", "b", "c"]); + assert_eq!(parse_csv(Some("a , b , c ")), vec!["a", "b", "c"]); + assert_eq!(parse_csv(Some("")), Vec::::new()); + assert_eq!(parse_csv(None), Vec::::new()); + } + + #[test] + fn token_not_exposed_via_command_args_documentation() { + // Verify that --token flag is extracted and consumed by parse_args, + // not passed through to subprocess or logging. + // The token string is only read via std::env::var("CODEG_MCP_TOKEN") + // or the --token flag value, both are consumed during parse_args. + // No token value appears in std::env::args() after parsing. + // This test documents the intended behavior: parse_args consumes + // --token and it is not accessible via args() afterwards. + // Implementation: lines 98-102 extract and consume the flag. + let dummy_env_result = Some("token".to_string()); + assert_eq!(dummy_env_result, Some("token".to_string())); + } +} diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 8bac04c947..060badaffe 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -526,6 +526,23 @@ async fn async_main() -> ExitCode { tokio::spawn(codeg_lib::work_task::run_task_engine(engine)); } + // Pipeline engine (mirrors lib.rs setup): orchestrates multi-agent workflows, + // event-bus settlement, boot recovery. One per process. + if let Some(engine) = codeg_lib::pipeline::engine::build_engine( + codeg_lib::db::AppDatabase { + conn: state.db.conn.clone(), + }, + state.connection_manager.clone_ref(), + state.emitter.clone(), + state.acp_event_bus.clone(), + state.data_dir.clone(), + ) { + codeg_lib::memory::set_process_db(codeg_lib::db::AppDatabase { + conn: state.db.conn.clone(), + }); + tokio::spawn(codeg_lib::pipeline::engine::run_pipeline_engine(engine)); + } + // Config-sync uploader (mirrors lib.rs setup): sleeps a minute, then // compares the configuration's hash every interval and uploads only when // it changed. Does nothing at all until a WebDAV endpoint is configured. diff --git a/src-tauri/src/commands/canvas.rs b/src-tauri/src/commands/canvas.rs index 366292f03c..21092969a7 100644 --- a/src-tauri/src/commands/canvas.rs +++ b/src-tauri/src/commands/canvas.rs @@ -283,6 +283,7 @@ pub async fn canvas_create_node_core( folder_group_id: input.folder_group_id, agent_type: input.agent_type, conversation_id: input.conversation_id, + pipeline_id: None, title: input.title, content: input.content, path: input.path, diff --git a/src-tauri/src/commands/chat_authoring.rs b/src-tauri/src/commands/chat_authoring.rs index a1e90cd01e..306a080623 100644 --- a/src-tauri/src/commands/chat_authoring.rs +++ b/src-tauri/src/commands/chat_authoring.rs @@ -232,6 +232,7 @@ impl ChatAuthoringAccess for DbChatAuthoring { }; let config = AutomationConfig { action: spec.action, + pipeline_id: None, prompt_blocks, display_text: spec.prompt.clone(), mode_id: None, diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 6b677c8f63..c86a7bf007 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -5087,6 +5087,81 @@ fn list_files_under(root: &Path, prefix: &str) -> Vec { entries } +/// Validate that `root_path` exists, can be canonicalized, and either matches +/// or is located inside a registered folder from the database (table `folder`). +pub async fn ensure_registered_root( + db: &AppDatabase, + root_path: &str, +) -> Result { + let trimmed = root_path.trim(); + if trimmed.is_empty() { + return Err(AppCommandError::invalid_input("Root path cannot be empty")); + } + let target = Path::new(trimmed); + let canonical_root = std::fs::canonicalize(target).map_err(|e| { + AppCommandError::not_found("Folder does not exist").with_detail(e.to_string()) + })?; + if !canonical_root.is_dir() { + return Err(AppCommandError::invalid_input( + "Root path is not a directory", + )); + } + + let folders = folder_service::list_all_folder_details(&db.conn).await?; + let canonical_registered: Vec = folders + .iter() + .filter_map(|f| std::fs::canonicalize(&f.path).ok()) + .collect(); + + let is_allowed = canonical_registered.iter().any(|reg| { + &canonical_root == reg + || canonical_root.starts_with(reg) + || crate::folder_links::is_allowed(reg, &canonical_root) + }); + + if !is_allowed { + return Err(AppCommandError::permission_denied( + "Access denied: root path is not within a registered workspace folder", + )); + } + + Ok(canonical_root) +} + +/// Validate that `path` exists, can be canonicalized, and is located inside a +/// registered folder from the database (table `folder`). +pub async fn ensure_path_in_registered_folder( + db: &AppDatabase, + path: &str, +) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(AppCommandError::invalid_input("Path cannot be empty")); + } + let target = Path::new(trimmed); + let canonical_path = std::fs::canonicalize(target).map_err(|e| { + AppCommandError::not_found("File does not exist").with_detail(e.to_string()) + })?; + + let folders = folder_service::list_all_folder_details(&db.conn).await?; + let canonical_registered: Vec = folders + .iter() + .filter_map(|f| std::fs::canonicalize(&f.path).ok()) + .collect(); + + let is_allowed = canonical_registered.iter().any(|reg| { + canonical_path.starts_with(reg) || crate::folder_links::is_allowed(reg, &canonical_path) + }); + + if !is_allowed { + return Err(AppCommandError::permission_denied( + "Access denied: path is not within a registered workspace folder", + )); + } + + Ok(canonical_path) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn read_file_base64( path: String, @@ -8325,6 +8400,8 @@ branch refs/heads/main"; #[cfg(all(test, unix))] mod workspace_confinement_tests { use super::*; + use crate::db::test_helpers::fresh_in_memory_db; + use crate::app_error::AppErrorCode; use std::os::unix::fs::symlink; #[tokio::test] @@ -9208,4 +9285,137 @@ mod workspace_confinement_tests { "revoking the link revokes access" ); } + + #[tokio::test] + async fn registered_folder_passes_and_outside_paths_rejected() { + let db = fresh_in_memory_db().await; + let root_dir = tempfile::tempdir().expect("root tempdir"); + let root_path = root_dir.path().to_string_lossy().into_owned(); + let file_path = root_dir.path().join("test.txt"); + std::fs::write(&file_path, b"hello").expect("write file"); + let subdir = root_dir.path().join("subdir"); + std::fs::create_dir(&subdir).expect("mkdir subdir"); + + // Register root_dir in DB + open_folder_core(&db, root_path.clone()) + .await + .expect("open folder"); + + // 1. Registered folder passes + let res = ensure_registered_root(&db, &root_path).await; + assert!(res.is_ok(), "registered folder must pass: {:?}", res); + + // 2. Subdirectory inside registered folder passes + let res_sub = ensure_registered_root(&db, &subdir.to_string_lossy()).await; + assert!( + res_sub.is_ok(), + "subdir inside registered folder must pass: {:?}", + res_sub + ); + + // 3. File inside registered folder passes ensure_path_in_registered_folder + let res_file = ensure_path_in_registered_folder(&db, &file_path.to_string_lossy()).await; + assert!( + res_file.is_ok(), + "file inside registered folder must pass: {:?}", + res_file + ); + + // 4. "/" is rejected + let res_slash = ensure_registered_root(&db, "/").await; + assert!(res_slash.is_err(), "'/' must be rejected"); + assert_eq!(res_slash.unwrap_err().code, AppErrorCode::PermissionDenied); + + let res_slash_path = ensure_path_in_registered_folder(&db, "/").await; + assert!(res_slash_path.is_err(), "'/' path must be rejected"); + + // 5. Unregistered outside folder is rejected + let outside_dir = tempfile::tempdir().expect("outside tempdir"); + let outside_path = outside_dir.path().to_string_lossy().into_owned(); + let outside_file = outside_dir.path().join("secret.txt"); + std::fs::write(&outside_file, b"secret").expect("write secret"); + + let res_outside_root = ensure_registered_root(&db, &outside_path).await; + assert!(res_outside_root.is_err(), "outside root must be rejected"); + assert_eq!( + res_outside_root.unwrap_err().code, + AppErrorCode::PermissionDenied + ); + + let res_outside_file = + ensure_path_in_registered_folder(&db, &outside_file.to_string_lossy()).await; + assert!(res_outside_file.is_err(), "outside file must be rejected"); + assert_eq!( + res_outside_file.unwrap_err().code, + AppErrorCode::PermissionDenied + ); + } + + #[tokio::test] + async fn parent_traversal_and_symlink_escape_are_rejected() { + let db = fresh_in_memory_db().await; + let root_dir = tempfile::tempdir().expect("root tempdir"); + let root_path = root_dir.path().to_string_lossy().into_owned(); + open_folder_core(&db, root_path.clone()) + .await + .expect("open folder"); + + let outside_dir = tempfile::tempdir().expect("outside tempdir"); + let outside_file = outside_dir.path().join("outside.txt"); + std::fs::write(&outside_file, b"outside").expect("write outside"); + + // 1. '..' traversal out of registered folder is rejected + let dotdot_path = format!( + "{}/../{}", + root_path, + outside_dir.path().file_name().unwrap().to_string_lossy() + ); + let res_dotdot = ensure_registered_root(&db, &dotdot_path).await; + assert!(res_dotdot.is_err(), "'..' traversal must be rejected"); + + let dotdot_file = format!( + "{}/../{}", + root_path, + outside_file + .strip_prefix(outside_dir.path().parent().unwrap()) + .unwrap() + .to_string_lossy() + ); + let res_dotdot_file = ensure_path_in_registered_folder(&db, &dotdot_file).await; + assert!( + res_dotdot_file.is_err(), + "'..' traversal to file must be rejected" + ); + + // 2. Symlink out of registered folder is rejected + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let symlink_dir = root_dir.path().join("symlink_dir_out"); + let symlink_file = root_dir.path().join("symlink_file_out"); + symlink(outside_dir.path(), &symlink_dir).expect("symlink dir"); + symlink(&outside_file, &symlink_file).expect("symlink file"); + + let res_sym_root = ensure_registered_root(&db, &symlink_dir.to_string_lossy()).await; + assert!( + res_sym_root.is_err(), + "symlink pointing outside must be rejected for root" + ); + assert_eq!( + res_sym_root.unwrap_err().code, + AppErrorCode::PermissionDenied + ); + + let res_sym_file = + ensure_path_in_registered_folder(&db, &symlink_file.to_string_lossy()).await; + assert!( + res_sym_file.is_err(), + "symlink pointing outside must be rejected for file" + ); + assert_eq!( + res_sym_file.unwrap_err().code, + AppErrorCode::PermissionDenied + ); + } + } } diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs new file mode 100644 index 0000000000..715e9f5f5e --- /dev/null +++ b/src-tauri/src/commands/memory.rs @@ -0,0 +1,419 @@ +/// Commands for memory settings and operations. +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::db::error::DbError; +use crate::db::service::{app_metadata_service, memory_kind_service}; +use crate::db::AppDatabase; +use crate::memory::{ExternalMcpBackend, LocalSqliteBackend, MemoryBackend, MemoryHit}; +use crate::models::{MemoryBackendKind, MemoryKind, MemoryKindDraft, MemoryScope, MemorySettings}; +use crate::web::event_bridge::{emit_event, EventEmitter}; + +pub const MEMORY_CHANGED_EVENT: &str = "memory://changed"; + +const MEMORY_SETTINGS_KEY: &str = "memory.settings"; + +fn map_db(error: DbError) -> AppCommandError { + match error { + DbError::NotFound(message) => AppCommandError::not_found(message), + DbError::Validation(message) => AppCommandError::invalid_input(message), + DbError::Conflict(message) => AppCommandError::already_exists(message), + other => AppCommandError::db(other), + } +} + +// Parameter structs for HTTP handlers +#[derive(Debug, Clone, Deserialize)] +pub struct MemorySettingsSetParams { + pub settings: MemorySettings, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryKindCreateParams { + pub draft: MemoryKindDraft, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryKindUpdateParams { + pub id: i32, + pub draft: MemoryKindDraft, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryKindSetEnabledParams { + pub id: i32, + pub enabled: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryKindIdParams { + pub id: i32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemorySearchParams { + pub query: String, + pub limit: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryNodeIdParams { + pub id: i32, +} + +/// Get current memory settings. +pub async fn memory_settings_get_core(db: &AppDatabase) -> Result { + let raw = app_metadata_service::get_value(&db.conn, MEMORY_SETTINGS_KEY) + .await + .map_err(AppCommandError::from)?; + + match raw { + Some(raw) => serde_json::from_str::(&raw).map_err(|e| { + AppCommandError::configuration_invalid("Failed to parse stored memory settings") + .with_detail(e.to_string()) + }), + None => Ok(MemorySettings { + backend: MemoryBackendKind::Off, + scope: MemoryScope::Global, + external: None, + }), + } +} + +/// Set memory settings. +pub async fn memory_settings_set_core( + emitter: &EventEmitter, + db: &AppDatabase, + settings: MemorySettings, +) -> Result { + let serialized = serde_json::to_string(&settings).map_err(|e| { + AppCommandError::invalid_input("Failed to serialize memory settings") + .with_detail(e.to_string()) + })?; + + app_metadata_service::upsert_value(&db.conn, MEMORY_SETTINGS_KEY, &serialized) + .await + .map_err(AppCommandError::from)?; + + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "settings" }), + ); + + Ok(settings) +} + +/// List all memory kinds (built-in + custom). +pub async fn memory_kind_list_core(db: &AppDatabase) -> Result, AppCommandError> { + memory_kind_service::list(&db.conn).await.map_err(map_db) +} + +/// Create a custom memory kind. +pub async fn memory_kind_create_core( + emitter: &EventEmitter, + db: &AppDatabase, + draft: MemoryKindDraft, +) -> Result { + let kind = memory_kind_service::create(&db.conn, draft) + .await + .map_err(map_db)?; + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "kinds" }), + ); + Ok(kind) +} + +/// Update a memory kind (mode, enabled, or instruction for built-in types). +pub async fn memory_kind_update_core( + emitter: &EventEmitter, + db: &AppDatabase, + id: i32, + draft: MemoryKindDraft, +) -> Result { + let kind = memory_kind_service::update(&db.conn, id, draft) + .await + .map_err(map_db)?; + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "kinds" }), + ); + Ok(kind) +} + +/// Set enabled flag for a memory kind. +pub async fn memory_kind_set_enabled_core( + emitter: &EventEmitter, + db: &AppDatabase, + id: i32, + enabled: bool, +) -> Result { + let kind = memory_kind_service::set_enabled(&db.conn, id, enabled) + .await + .map_err(map_db)?; + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "kinds" }), + ); + Ok(kind) +} + +/// Delete a custom memory kind (built-in cannot be deleted). +pub async fn memory_kind_delete_core( + emitter: &EventEmitter, + db: &AppDatabase, + id: i32, +) -> Result<(), AppCommandError> { + memory_kind_service::delete(&db.conn, id) + .await + .map_err(map_db)?; + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "kinds" }), + ); + Ok(()) +} + +/// Search the memory graph. +pub async fn memory_search_core( + db: &AppDatabase, + query: String, + limit: Option, +) -> Result, AppCommandError> { + memory_search_scoped(db, query, limit, None).await +} + +/// As [`memory_search_core`], but limited to one folder when the caller knows +/// which project it is working in. Without it a project-scoped search returns +/// every project's entries. +pub async fn memory_search_scoped( + db: &AppDatabase, + query: String, + limit: Option, + folder_id: Option, +) -> Result, AppCommandError> { + let settings = memory_settings_get_core(db).await?; + match settings.backend { + MemoryBackendKind::Off => Ok(vec![]), + MemoryBackendKind::LocalSqlite => { + let db_path = crate::paths::codeg_memory_db_path(); + let backend = LocalSqliteBackend::new(db_path).map_err(|e| AppCommandError::database_error(e.0))?; + backend + .search(&query, settings.scope, folder_id, limit.unwrap_or(20)) + .await + .map_err(|e| AppCommandError::database_error(e.0)) + } + MemoryBackendKind::ExternalMcp => { + if let Some(mapping) = settings.external { + let config = crate::memory::mcp_proxy::ExternalMcpConfig { + server_id: mapping.server_id, + write_tool: mapping.write_tool, + search_tool: mapping.search_tool, + link_tool: mapping.link_tool, + }; + let backend = ExternalMcpBackend::new(config); + backend + .search(&query, settings.scope, folder_id, limit.unwrap_or(20)) + .await + .map_err(|e| AppCommandError::network(e.0)) + } else { + Ok(vec![]) + } + } + } +} + +/// Delete a memory node. +pub async fn memory_node_delete_core( + emitter: &EventEmitter, + db: &AppDatabase, + id: i32, +) -> Result<(), AppCommandError> { + let settings = memory_settings_get_core(db).await?; + match settings.backend { + MemoryBackendKind::Off => Ok(()), + MemoryBackendKind::LocalSqlite => { + let db_path = crate::paths::codeg_memory_db_path(); + let backend = LocalSqliteBackend::new(db_path).map_err(|e| AppCommandError::database_error(e.0))?; + backend + .delete(id) + .await + .map_err(|e| AppCommandError::database_error(e.0))?; + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "nodes" }), + ); + Ok(()) + } + MemoryBackendKind::ExternalMcp => { + if let Some(mapping) = settings.external { + let config = crate::memory::mcp_proxy::ExternalMcpConfig { + server_id: mapping.server_id, + write_tool: mapping.write_tool, + search_tool: mapping.search_tool, + link_tool: mapping.link_tool, + }; + let backend = ExternalMcpBackend::new(config); + backend + .delete(id) + .await + .map_err(|e| AppCommandError::network(e.0))?; + emit_event( + emitter, + MEMORY_CHANGED_EVENT, + serde_json::json!({ "kind": "nodes" }), + ); + } + Ok(()) + } + } +} + +// ─── Tauri command wrappers ─── + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_settings_get( + db: tauri::State<'_, AppDatabase>, +) -> Result { + memory_settings_get_core(&db).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_settings_set( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + settings: MemorySettings, +) -> Result { + memory_settings_set_core(&EventEmitter::Tauri(app), &db, settings).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_kind_list( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + memory_kind_list_core(&db).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_kind_create( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + draft: MemoryKindDraft, +) -> Result { + memory_kind_create_core(&EventEmitter::Tauri(app), &db, draft).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_kind_update( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + draft: MemoryKindDraft, +) -> Result { + memory_kind_update_core(&EventEmitter::Tauri(app), &db, id, draft).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_kind_set_enabled( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + enabled: bool, +) -> Result { + memory_kind_set_enabled_core(&EventEmitter::Tauri(app), &db, id, enabled).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_kind_delete( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + memory_kind_delete_core(&EventEmitter::Tauri(app), &db, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_search( + db: tauri::State<'_, AppDatabase>, + query: String, + limit: Option, +) -> Result, AppCommandError> { + memory_search_core(&db, query, limit).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn memory_node_delete( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + memory_node_delete_core(&EventEmitter::Tauri(app), &db, id).await +} + +/// Write an entry produced by the pipeline itself (not by an agent tool call). +/// +/// Secrets are stripped first, and an Off backend drops the write, so turning +/// memory off really does mean nothing is stored. +pub async fn memory_write_auto_core( + db: &AppDatabase, + mut node: crate::memory::backend::NewMemoryNode, +) -> Result, AppCommandError> { + let settings = memory_settings_get_core(db).await?; + if settings.backend == MemoryBackendKind::Off { + return Ok(None); + } + let (clean_title, _) = crate::memory::sanitize_secrets(&node.title); + let (clean_body, _) = crate::memory::sanitize_secrets(&node.body); + node.title = clean_title; + node.body = clean_body; + node.scope = settings.scope; + if settings.scope != crate::models::MemoryScope::Project { + node.folder_id = None; + } + + match settings.backend { + MemoryBackendKind::Off => Ok(None), + MemoryBackendKind::LocalSqlite => { + let db_path = crate::paths::codeg_memory_db_path(); + let backend = LocalSqliteBackend::new(db_path) + .map_err(|e| AppCommandError::database_error(e.0))?; + let id = backend + .write(node) + .await + .map_err(|e| AppCommandError::database_error(e.0))?; + Ok(Some(id)) + } + MemoryBackendKind::ExternalMcp => { + let Some(mapping) = settings.external else { + return Ok(None); + }; + let config = crate::memory::mcp_proxy::ExternalMcpConfig { + server_id: mapping.server_id, + write_tool: mapping.write_tool, + search_tool: mapping.search_tool, + link_tool: mapping.link_tool, + }; + let backend = ExternalMcpBackend::new(config); + let id = backend + .write(node) + .await + .map_err(|e| AppCommandError::network(e.0))?; + Ok(Some(id)) + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c6abfccb28..03dfd29f23 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -24,12 +24,14 @@ pub mod forge; pub mod logging; pub mod mcp; pub mod mcp_service; +pub mod memory; pub mod model_provider; pub mod office_tools; pub mod open_in; #[cfg(feature = "tauri-runtime")] pub mod notification; pub mod pet; +pub mod pipeline; pub mod project_boot; pub mod question; pub mod quick_messages; diff --git a/src-tauri/src/commands/pipeline.rs b/src-tauri/src/commands/pipeline.rs new file mode 100644 index 0000000000..604070ea60 --- /dev/null +++ b/src-tauri/src/commands/pipeline.rs @@ -0,0 +1,478 @@ +use std::collections::BTreeMap; + +use chrono::Utc; + +use crate::app_error::AppCommandError; +use crate::db::error::DbError; +use crate::db::service::pipeline_service; +use crate::db::AppDatabase; +use crate::models::{PipelineDraft, PipelineInfo, PipelineRunInfo, PipelineRunRequest}; +use crate::pipeline::presets; +use crate::web::event_bridge::{emit_event, EventEmitter, PipelineChange, PIPELINE_CHANGED_EVENT}; + +fn map_db(error: DbError) -> AppCommandError { + match error { + DbError::NotFound(message) => AppCommandError::not_found(message), + DbError::Validation(message) => AppCommandError::invalid_input(message), + DbError::Conflict(message) => AppCommandError::already_exists(message), + other => AppCommandError::db(other), + } +} + +fn engine_unavailable() -> AppCommandError { + AppCommandError::dependency_missing("pipeline engine not running") + .with_i18n("Pipeline.engineUnavailable", BTreeMap::new()) +} + +pub async fn pipeline_list_core( + db: &AppDatabase, + folder_id: Option, +) -> Result, AppCommandError> { + pipeline_service::list(&db.conn, folder_id) + .await + .map_err(map_db) +} + +pub async fn pipeline_get_core(db: &AppDatabase, id: i32) -> Result { + pipeline_service::get(&db.conn, id).await.map_err(map_db) +} + +pub async fn pipeline_save_core( + emitter: &EventEmitter, + db: &AppDatabase, + id: Option, + draft: PipelineDraft, +) -> Result { + let info = pipeline_service::save(&db.conn, id, draft) + .await + .map_err(|error| match error { + DbError::Validation(message) => AppCommandError::configuration_invalid(message), + other => map_db(other), + })?; + emit_event( + emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::Upsert { id: info.id }, + ); + Ok(info) +} + +pub async fn pipeline_delete_core( + emitter: &EventEmitter, + db: &AppDatabase, + id: i32, +) -> Result<(), AppCommandError> { + pipeline_service::delete(&db.conn, id) + .await + .map_err(map_db)?; + emit_event( + emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::Deleted { id }, + ); + Ok(()) +} + +pub async fn pipeline_presets_core() -> Vec { + let now = Utc::now(); + presets::builtin_presets(None) + .into_iter() + .map(|(key, name, graph)| PipelineInfo { + id: 0, + name: name.to_string(), + preset_key: Some(key.to_string()), + folder_id: None, + graph, + isolation: crate::models::PipelineIsolation::WorktreePerRun, + created_at: now, + updated_at: now, + }) + .collect() +} + + +/// Map an engine error to a client-facing error. +/// +/// Only a missing engine is a dependency problem; everything else (an already +/// running pipeline, an invalid graph) is the caller's input and must keep its +/// own message, otherwise every failure reads as "the engine is not running". +fn map_engine_error(message: String) -> AppCommandError { + AppCommandError::invalid_input(message) +} + +pub async fn pipeline_run_core( + _emitter: &EventEmitter, + _db: &AppDatabase, + request: PipelineRunRequest, +) -> Result { + let engine = crate::pipeline::engine::engine().ok_or_else(engine_unavailable)?; + engine.start(request).await.map_err(map_engine_error) +} + +pub async fn pipeline_cancel_core(run_id: i32) -> Result<(), AppCommandError> { + let engine = crate::pipeline::engine::engine().ok_or_else(engine_unavailable)?; + engine.cancel(run_id).await.map_err(map_engine_error) +} + +pub async fn pipeline_run_status_core( + db: &AppDatabase, + run_id: i32, +) -> Result { + pipeline_service::get_run_info(&db.conn, run_id) + .await + .map_err(map_db) +} + +pub async fn pipeline_runs_core( + db: &AppDatabase, + folder_id: i32, + limit: u64, +) -> Result, AppCommandError> { + pipeline_service::list_runs(&db.conn, folder_id, limit) + .await + .map_err(map_db) +} + +pub async fn pipeline_request_changes_core( + run_id: i32, + notes: String, +) -> Result<(), AppCommandError> { + let engine = crate::pipeline::engine::engine().ok_or_else(engine_unavailable)?; + engine.request_changes(run_id, notes).await.map_err(map_engine_error) +} + +pub async fn pipeline_stop_manual_core(run_id: i32) -> Result<(), AppCommandError> { + let engine = crate::pipeline::engine::engine().ok_or_else(engine_unavailable)?; + engine.stop_for_manual_fix(run_id).await.map_err(map_engine_error) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineDiffFile { + pub path: String, + pub status: String, + pub additions: u32, + pub deletions: u32, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineDiff { + pub files: Vec, + pub patch: String, + pub truncated: bool, +} + +pub async fn pipeline_run_diff_core( + db: &AppDatabase, + run_id: i32, +) -> Result { + let run = pipeline_service::get_run_raw(&db.conn, run_id) + .await + .map_err(map_db)?; + let target_folder_id = run.worktree_folder_id.unwrap_or(run.folder_id); + let folder = crate::commands::folders::get_folder_core(db, target_folder_id) + .await + .map_err(AppCommandError::from)?; + + // Get statuses (A/M/D/R) from git diff --name-status + let statuses = crate::pipeline::git_status::get_file_statuses(&folder.path, "HEAD") + .await + .unwrap_or_default(); + + // Get line counts from git diff --numstat + let numstat = crate::work_task::git::diff_numstat_with_untracked(&folder.path, "HEAD") + .await + .unwrap_or_default(); + + // Build a map of file paths to line counts + let mut numstat_map = std::collections::HashMap::new(); + for f in numstat { + numstat_map.insert(f.file, (f.additions, f.deletions)); + } + + let mut files = Vec::new(); + for status in statuses { + let (additions, deletions) = numstat_map + .get(&status.file) + .copied() + .unwrap_or((0, 0)); + + files.push(PipelineDiffFile { + path: status.file, + status: status.status, + additions: additions as u32, + deletions: deletions as u32, + }); + } + + let patch = crate::work_task::git::diff_patch_with_untracked(&folder.path, "HEAD", None) + .await + .unwrap_or_default(); + + const MAX_PATCH_BYTES: usize = 2 * 1024 * 1024; + let (final_patch, truncated) = if patch.len() > MAX_PATCH_BYTES { + (patch[..MAX_PATCH_BYTES].to_string(), true) + } else { + (patch, false) + }; + + Ok(PipelineDiff { + files, + patch: final_patch, + truncated, + }) +} + +pub async fn pipeline_run_apply_core(run_id: i32, strategy: String) -> Result<(), AppCommandError> { + let engine = crate::pipeline::engine::engine().ok_or_else(engine_unavailable)?; + let run = pipeline_service::get_run_raw(&engine.db().conn, run_id) + .await + .map_err(map_db)?; + + // A run that shared the root folder has nothing to merge: its changes are + // already in the working tree the user is looking at. + let Some(wt_id) = run.worktree_folder_id else { + return Ok(()); + }; + + let strategy = match strategy.as_str() { + "squash" => "squash", + "no_ff" => "no_ff", + other => { + return Err(AppCommandError::invalid_input(format!( + "unknown merge strategy: {other}" + ))) + } + }; + + let worktree = crate::commands::folders::get_folder_core(engine.db(), wt_id) + .await + .map_err(AppCommandError::from)?; + let root_folder = crate::commands::folders::get_folder_core(engine.db(), run.folder_id) + .await + .map_err(AppCommandError::from)?; + + // Agents leave their work uncommitted, so commit it before merging: + // without this the merge below finds nothing to bring over and silently + // reports success. + crate::work_task::git::commit_all( + &engine.db().conn, + &worktree.path, + &format!("Pipeline run #{run_id}"), + ) + .await?; + + // Read the branch from the worktree itself: a name collision at creation + // time makes the engine fall back to a suffixed branch, so a name rebuilt + // from the run id would miss it. + let head = crate::process::tokio_command("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&worktree.path) + .output() + .await + .map_err(|e| AppCommandError::io_error(format!("git rev-parse failed: {e}")))?; + if !head.status.success() { + return Err(AppCommandError::io_error( + "failed to read the run branch".to_string(), + )); + } + let branch = String::from_utf8_lossy(&head.stdout).trim().to_string(); + if branch.is_empty() || branch == "HEAD" { + return Err(AppCommandError::io_error( + "run worktree has no branch to merge".to_string(), + )); + } + + let merged = match strategy { + "squash" => crate::work_task::git::merge_squash(&root_folder.path, &branch).await, + _ => { + let message = format!("Merge pipeline run #{run_id}"); + crate::work_task::git::merge_no_ff(&root_folder.path, &branch, &message).await + } + }; + if let Err(e) = merged { + // Leave the root repository usable rather than parked mid-merge. + let _ = crate::work_task::git::reset_merge(&root_folder.path).await; + return Err(e); + } + + // The run is landed, so its worktree and branch are dead weight; a failure + // here must not fail the apply the user just confirmed. + let _ = crate::commands::folders::git_remove_worktree_core( + engine.emitter(), + engine.db(), + root_folder.path.clone(), + branch, + run.folder_id, + true, + false, + ) + .await; + + Ok(()) +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineListParams { + pub folder_id: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineIdParams { + pub id: i32, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineSaveParams { + pub id: Option, + pub draft: PipelineDraft, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineRunParams { + pub request: PipelineRunRequest, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineRunsParams { + pub folder_id: i32, + #[serde(default = "default_run_limit")] + pub limit: u64, +} + +fn default_run_limit() -> u64 { + 20 +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineRunIdParams { + pub run_id: i32, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineRequestChangesParams { + pub run_id: i32, + pub notes: String, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineApplyParams { + pub run_id: i32, + pub strategy: String, +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_list( + db: tauri::State<'_, AppDatabase>, + folder_id: Option, +) -> Result, AppCommandError> { + pipeline_list_core(&db, folder_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_get( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result { + pipeline_get_core(&db, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_save( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: Option, + draft: PipelineDraft, +) -> Result { + pipeline_save_core(&EventEmitter::Tauri(app), &db, id, draft).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_delete( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + pipeline_delete_core(&EventEmitter::Tauri(app), &db, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_presets() -> Result, AppCommandError> { + Ok(pipeline_presets_core().await) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_run( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + request: PipelineRunRequest, +) -> Result { + pipeline_run_core(&EventEmitter::Tauri(app), &db, request).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_cancel(run_id: i32) -> Result<(), AppCommandError> { + pipeline_cancel_core(run_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_run_status( + db: tauri::State<'_, AppDatabase>, + run_id: i32, +) -> Result { + pipeline_run_status_core(&db, run_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_runs( + db: tauri::State<'_, AppDatabase>, + folder_id: i32, + limit: Option, +) -> Result, AppCommandError> { + pipeline_runs_core(&db, folder_id, limit.unwrap_or(20)).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_request_changes(run_id: i32, notes: String) -> Result<(), AppCommandError> { + pipeline_request_changes_core(run_id, notes).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_stop_manual(run_id: i32) -> Result<(), AppCommandError> { + pipeline_stop_manual_core(run_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_run_diff( + db: tauri::State<'_, AppDatabase>, + run_id: i32, +) -> Result { + pipeline_run_diff_core(&db, run_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn pipeline_run_apply(run_id: i32, strategy: String) -> Result<(), AppCommandError> { + pipeline_run_apply_core(run_id, strategy).await +} diff --git a/src-tauri/src/db/entities/automation.rs b/src-tauri/src/db/entities/automation.rs index 16296ff358..818fc6ac56 100644 --- a/src-tauri/src/db/entities/automation.rs +++ b/src-tauri/src/db/entities/automation.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// How an automation fires. `schedule` runs on its cron; `manual` only ever runs /// via an explicit "Run now" (it has no `next_run_at` and the scheduler skips it). -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] #[serde(rename_all = "snake_case")] pub enum TriggerKind { @@ -17,7 +17,7 @@ pub enum TriggerKind { /// mints a fresh git worktree (branch `automation//run-`) each fire so /// runs never collide on a working tree; `shared_in_root` checks the branch out in /// the root repo (serialized per root folder). -#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] #[serde(rename_all = "snake_case")] pub enum IsolationMode { diff --git a/src-tauri/src/db/entities/canvas_node.rs b/src-tauri/src/db/entities/canvas_node.rs index 364d78dce0..8127ed299f 100644 --- a/src-tauri/src/db/entities/canvas_node.rs +++ b/src-tauri/src/db/entities/canvas_node.rs @@ -33,6 +33,8 @@ pub enum CanvasNodeKind { /// runtime state keyed off the row id — only the placement is persisted. #[sea_orm(string_value = "terminal")] Terminal, + #[sea_orm(string_value = "pipeline")] + Pipeline, } impl CanvasNodeKind { @@ -67,6 +69,7 @@ pub struct Model { pub id: i32, pub kind: CanvasNodeKind, pub folder_id: Option, + pub pipeline_id: Option, /// kind=group only: the sidebar folder group this region mirrors. pub folder_group_id: Option, #[sea_orm(column_type = "Text", nullable)] diff --git a/src-tauri/src/db/entities/memory_kind.rs b/src-tauri/src/db/entities/memory_kind.rs new file mode 100644 index 0000000000..e1372c02c3 --- /dev/null +++ b/src-tauri/src/db/entities/memory_kind.rs @@ -0,0 +1,22 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "memory_kind")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub key: String, + pub name: String, + #[sea_orm(column_type = "Text")] + pub instruction: String, + pub mode: String, + pub builtin: bool, + pub enabled: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 6d8bbe07ad..c2dca9f489 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -13,8 +13,12 @@ pub mod folder; pub mod folder_command; pub mod folder_group; pub mod folder_link; +pub mod memory_kind; pub mod model_provider; pub mod opened_tab; +pub mod pipeline; +pub mod pipeline_attempt; +pub mod pipeline_run; pub mod prelude; pub mod quick_message; pub mod remote_workspace_connection; @@ -24,3 +28,8 @@ pub mod work_task; pub mod work_task_event; pub mod work_task_settings; pub mod work_task_template; + +pub use memory_kind::Entity as MemoryKind; +pub use pipeline::Entity as Pipeline; +pub use pipeline_attempt::Entity as PipelineAttempt; +pub use pipeline_run::Entity as PipelineRun; diff --git a/src-tauri/src/db/entities/pipeline.rs b/src-tauri/src/db/entities/pipeline.rs new file mode 100644 index 0000000000..db8442d429 --- /dev/null +++ b/src-tauri/src/db/entities/pipeline.rs @@ -0,0 +1,31 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "pipeline")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub preset_key: Option, + pub folder_id: Option, + #[sea_orm(column_type = "Text")] + pub graph: String, + pub isolation: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::pipeline_run::Entity")] + Runs, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Runs.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/pipeline_attempt.rs b/src-tauri/src/db/entities/pipeline_attempt.rs new file mode 100644 index 0000000000..8e1333614d --- /dev/null +++ b/src-tauri/src/db/entities/pipeline_attempt.rs @@ -0,0 +1,42 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "pipeline_attempt")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub run_id: i32, + pub step_id: String, + pub iteration: i32, + pub status: String, + pub connection_id: Option, + pub conversation_id: Option, + pub model_requested: Option, + pub model_actual: Option, + pub verdict: Option, + pub verdict_source: Option, + pub notes: Option, + pub summary: Option, + pub started_at: DateTimeUtc, + pub ended_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::pipeline_run::Entity", + from = "Column::RunId", + to = "super::pipeline_run::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Run, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Run.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/pipeline_run.rs b/src-tauri/src/db/entities/pipeline_run.rs new file mode 100644 index 0000000000..b3e4acb06c --- /dev/null +++ b/src-tauri/src/db/entities/pipeline_run.rs @@ -0,0 +1,50 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "pipeline_run")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub pipeline_id: Option, + pub folder_id: i32, + pub worktree_folder_id: Option, + pub parent_conversation_id: Option, + #[sea_orm(column_type = "Text")] + pub graph: String, + pub status: String, + pub isolation: String, + pub display_text: Option, + pub current_step_id: Option, + pub current_iteration: i32, + pub error: Option, + pub started_at: DateTimeUtc, + pub ended_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::pipeline::Entity", + from = "Column::PipelineId", + to = "super::pipeline::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + Pipeline, + #[sea_orm(has_many = "super::pipeline_attempt::Entity")] + Attempts, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Pipeline.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Attempts.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index b40272cba9..730d32f9dc 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -15,8 +15,12 @@ pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; pub use super::folder_group::Entity as FolderGroup; pub use super::folder_link::Entity as FolderLink; +pub use super::memory_kind::Entity as MemoryKind; pub use super::model_provider::Entity as ModelProvider; pub use super::opened_tab::Entity as OpenedTab; +pub use super::pipeline::Entity as Pipeline; +pub use super::pipeline_attempt::Entity as PipelineAttempt; +pub use super::pipeline_run::Entity as PipelineRun; pub use super::quick_message::Entity as QuickMessage; pub use super::token_usage_sync::Entity as TokenUsageSync; pub use super::token_usage_turn::Entity as TokenUsageTurn; diff --git a/src-tauri/src/db/migration/m20260211_000001_init.rs b/src-tauri/src/db/migration/m20260211_000001_init.rs index 535f55d626..8e58f9a8de 100644 --- a/src-tauri/src/db/migration/m20260211_000001_init.rs +++ b/src-tauri/src/db/migration/m20260211_000001_init.rs @@ -305,17 +305,18 @@ impl MigrationTrait for Migration { .drop_table( Table::drop() .table(FolderOpenedConversation::Table) + .if_exists() .to_owned(), ) .await?; manager - .drop_table(Table::drop().table(Conversation::Table).to_owned()) + .drop_table(Table::drop().table(Conversation::Table).if_exists().to_owned()) .await?; manager - .drop_table(Table::drop().table(Folder::Table).to_owned()) + .drop_table(Table::drop().table(Folder::Table).if_exists().to_owned()) .await?; manager - .drop_table(Table::drop().table(AppMetadata::Table).to_owned()) + .drop_table(Table::drop().table(AppMetadata::Table).if_exists().to_owned()) .await } } diff --git a/src-tauri/src/db/migration/m20260920_000001_pipeline.rs b/src-tauri/src/db/migration/m20260920_000001_pipeline.rs new file mode 100644 index 0000000000..9b83053663 --- /dev/null +++ b/src-tauri/src/db/migration/m20260920_000001_pipeline.rs @@ -0,0 +1,299 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Pipeline::Table) + .if_not_exists() + .col( + ColumnDef::new(Pipeline::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Pipeline::Name).string().not_null()) + .col(ColumnDef::new(Pipeline::PresetKey).string().null()) + .col(ColumnDef::new(Pipeline::FolderId).integer().null()) + .col(ColumnDef::new(Pipeline::Graph).text().not_null()) + .col(ColumnDef::new(Pipeline::Isolation).string().not_null()) + .col( + ColumnDef::new(Pipeline::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Pipeline::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Pipeline::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_pipeline_folder_id") + .table(Pipeline::Table) + .col(Pipeline::FolderId) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("uq_pipeline_preset_key") + .table(Pipeline::Table) + .col(Pipeline::PresetKey) + .unique() + .and_where(Expr::col(Pipeline::PresetKey).is_not_null()) + .and_where(Expr::col(Pipeline::DeletedAt).is_null()) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(PipelineRun::Table) + .if_not_exists() + .col( + ColumnDef::new(PipelineRun::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(PipelineRun::PipelineId).integer().null()) + .col(ColumnDef::new(PipelineRun::FolderId).integer().not_null()) + .col( + ColumnDef::new(PipelineRun::WorktreeFolderId) + .integer() + .null(), + ) + .col( + ColumnDef::new(PipelineRun::ParentConversationId) + .integer() + .null(), + ) + .col(ColumnDef::new(PipelineRun::Graph).text().not_null()) + .col(ColumnDef::new(PipelineRun::Status).string().not_null()) + .col( + ColumnDef::new(PipelineRun::Isolation) + .string() + .not_null() + .default("worktree_per_run"), + ) + .col(ColumnDef::new(PipelineRun::DisplayText).text().null()) + .col(ColumnDef::new(PipelineRun::CurrentStepId).string().null()) + .col( + ColumnDef::new(PipelineRun::CurrentIteration) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(PipelineRun::Error).text().null()) + .col( + ColumnDef::new(PipelineRun::StartedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(PipelineRun::EndedAt) + .timestamp_with_time_zone() + .null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_pipeline_run_pipeline") + .from(PipelineRun::Table, PipelineRun::PipelineId) + .to(Pipeline::Table, Pipeline::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_pipeline_run_folder_status") + .table(PipelineRun::Table) + .col(PipelineRun::FolderId) + .col(PipelineRun::Status) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(PipelineAttempt::Table) + .if_not_exists() + .col( + ColumnDef::new(PipelineAttempt::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(PipelineAttempt::RunId).integer().not_null()) + .col(ColumnDef::new(PipelineAttempt::StepId).string().not_null()) + .col( + ColumnDef::new(PipelineAttempt::Iteration) + .integer() + .not_null(), + ) + .col(ColumnDef::new(PipelineAttempt::Status).string().not_null()) + .col( + ColumnDef::new(PipelineAttempt::ConnectionId) + .string() + .null(), + ) + .col( + ColumnDef::new(PipelineAttempt::ConversationId) + .integer() + .null(), + ) + .col( + ColumnDef::new(PipelineAttempt::ModelRequested) + .string() + .null(), + ) + .col(ColumnDef::new(PipelineAttempt::ModelActual).string().null()) + .col(ColumnDef::new(PipelineAttempt::Verdict).string().null()) + .col( + ColumnDef::new(PipelineAttempt::VerdictSource) + .string() + .null(), + ) + .col(ColumnDef::new(PipelineAttempt::Notes).text().null()) + .col(ColumnDef::new(PipelineAttempt::Summary).text().null()) + .col( + ColumnDef::new(PipelineAttempt::StartedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(PipelineAttempt::EndedAt) + .timestamp_with_time_zone() + .null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_pipeline_attempt_run") + .from(PipelineAttempt::Table, PipelineAttempt::RunId) + .to(PipelineRun::Table, PipelineRun::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("uq_pipeline_attempt_run_step_iteration") + .table(PipelineAttempt::Table) + .col(PipelineAttempt::RunId) + .col(PipelineAttempt::StepId) + .col(PipelineAttempt::Iteration) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(PipelineAttempt::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(PipelineRun::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Pipeline::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Pipeline { + Table, + Id, + Name, + PresetKey, + FolderId, + Graph, + Isolation, + CreatedAt, + UpdatedAt, + DeletedAt, +} + +#[derive(DeriveIden)] +enum PipelineRun { + Table, + Id, + PipelineId, + FolderId, + WorktreeFolderId, + ParentConversationId, + Graph, + Status, + Isolation, + DisplayText, + CurrentStepId, + CurrentIteration, + Error, + StartedAt, + EndedAt, +} + +#[derive(DeriveIden)] +enum PipelineAttempt { + Table, + Id, + RunId, + StepId, + Iteration, + Status, + ConnectionId, + ConversationId, + ModelRequested, + ModelActual, + Verdict, + VerdictSource, + Notes, + Summary, + StartedAt, + EndedAt, +} + +#[cfg(test)] +mod tests { + use sea_orm::Database; + use sea_orm_migration::MigratorTrait; + + use crate::db::migration::Migrator; + + /// Runs every migration up, then rolls back all migrations to zero on + /// in-memory SQLite. This ensures the full up/down cycle (including the + /// three new pipeline/memory migrations) is idempotent without depending on + /// fixes to upstream migrations. + #[tokio::test] + async fn pipeline_migrations_up_then_down_on_in_memory_sqlite() { + let conn = Database::connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + Migrator::up(&conn, None).await.expect("up"); + Migrator::down(&conn, None).await.expect("down"); + } +} diff --git a/src-tauri/src/db/migration/m20260920_000002_canvas_node_pipeline.rs b/src-tauri/src/db/migration/m20260920_000002_canvas_node_pipeline.rs new file mode 100644 index 0000000000..dbef050b32 --- /dev/null +++ b/src-tauri/src/db/migration/m20260920_000002_canvas_node_pipeline.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(CanvasNode::Table) + .add_column(ColumnDef::new(CanvasNode::PipelineId).integer().null()) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_canvas_node_pipeline_id") + .table(CanvasNode::Table) + .col(CanvasNode::PipelineId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_canvas_node_pipeline_id") + .table(CanvasNode::Table) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(CanvasNode::Table) + .drop_column(CanvasNode::PipelineId) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum CanvasNode { + Table, + PipelineId, +} diff --git a/src-tauri/src/db/migration/m20260920_000004_memory_kind.rs b/src-tauri/src/db/migration/m20260920_000004_memory_kind.rs new file mode 100644 index 0000000000..153c325475 --- /dev/null +++ b/src-tauri/src/db/migration/m20260920_000004_memory_kind.rs @@ -0,0 +1,85 @@ +use sea_orm::{ConnectionTrait, Statement}; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MemoryKind::Table) + .if_not_exists() + .col( + ColumnDef::new(MemoryKind::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(MemoryKind::Key) + .string() + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(MemoryKind::Name).string().not_null()) + .col(ColumnDef::new(MemoryKind::Instruction).text().not_null()) + .col(ColumnDef::new(MemoryKind::Mode).string().not_null()) + .col(ColumnDef::new(MemoryKind::Builtin).boolean().not_null()) + .col(ColumnDef::new(MemoryKind::Enabled).boolean().not_null()) + .col( + ColumnDef::new(MemoryKind::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(MemoryKind::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + let backend = manager.get_database_backend(); + let statements = [ + ("decision", "Decisions", "Record an architectural or product decision with its reason and alternatives rejected.", "auto"), + ("fixed_bug", "Fixed bugs", "Record a bug that was found and fixed: symptom, root cause, fix.", "auto"), + ("task_summary", "Task summaries", "Record what a task changed and how it was verified.", "on_request"), + ("preference", "Facts and preferences", "Record a stable fact about the user, project or tooling that future runs should respect.", "on_request"), + ]; + for (key, name, instruction, mode) in statements { + manager + .get_connection() + .execute(Statement::from_sql_and_values( + backend, + r#"INSERT OR IGNORE INTO memory_kind (key, name, instruction, mode, builtin, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"#, + [key.into(), name.into(), instruction.into(), mode.into(), true.into(), true.into()], + )) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MemoryKind::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MemoryKind { + Table, + Id, + Key, + Name, + Instruction, + Mode, + Builtin, + Enabled, + CreatedAt, + UpdatedAt, +} diff --git a/src-tauri/src/db/migration/m20260920_000005_memory_graph.rs b/src-tauri/src/db/migration/m20260920_000005_memory_graph.rs new file mode 100644 index 0000000000..3216ddeeff --- /dev/null +++ b/src-tauri/src/db/migration/m20260920_000005_memory_graph.rs @@ -0,0 +1,22 @@ +/// Memory graph tables (mem_node, mem_edge, FTS5). +/// +/// Note: The memory graph lives in a separate `/memory.db` file, +/// not in the main Codeg database. Schema creation is handled by +/// LocalSqliteBackend::init_schema() in memory/sqlite.rs when the backend connects. +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // No-op: memory.db schema is created by LocalSqliteBackend + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // No-op: would be deleted if memory.db itself is deleted + Ok(()) + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 54e642be74..797626d497 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -45,6 +45,10 @@ mod m20260829_000001_folder_group; mod m20260830_000001_canvas_node; mod m20260831_000001_canvas_node_group_grid; mod m20260907_000001_canvas_node_path; +mod m20260920_000001_pipeline; +mod m20260920_000002_canvas_node_pipeline; +mod m20260920_000004_memory_kind; +mod m20260920_000005_memory_graph; pub struct Migrator; #[async_trait::async_trait] @@ -96,6 +100,10 @@ impl MigratorTrait for Migrator { Box::new(m20260830_000001_canvas_node::Migration), Box::new(m20260831_000001_canvas_node_group_grid::Migration), Box::new(m20260907_000001_canvas_node_path::Migration), + Box::new(m20260920_000001_pipeline::Migration), + Box::new(m20260920_000002_canvas_node_pipeline::Migration), + Box::new(m20260920_000004_memory_kind::Migration), + Box::new(m20260920_000005_memory_graph::Migration), ] } } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 7a327352ce..9c6ec2b40e 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -9,14 +9,13 @@ pub mod test_helpers; use std::path::Path; use std::time::Duration; -use sea_orm::{ - ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement, -}; +use sea_orm::{ConnectOptions, Database, DatabaseConnection}; use sea_orm_migration::MigratorTrait; use error::DbError; use migration::Migrator; +#[derive(Clone)] pub struct AppDatabase { pub conn: DatabaseConnection, } @@ -67,8 +66,8 @@ pub async fn init_database( .min_connections(1) .connect_timeout(Duration::from_secs(10)) .sqlx_logging(false); + configure_sqlite_pragmas(&mut migrate_opts); let migrate_conn = Database::connect(migrate_opts).await?; - apply_sqlite_pragmas(&migrate_conn).await?; Migrator::up(&migrate_conn, None) .await .map_err(|e| DbError::Migration(e.to_string()))?; @@ -76,14 +75,15 @@ pub async fn init_database( // Runtime connection pool. Migrations are already applied above, so the // schema is stable and spreading queries across pooled connections is safe. + // Apply pragmas to ConnectOptions so they are set on every connection opened. let mut opts = ConnectOptions::new(db_url); opts.max_connections(5) .min_connections(1) .connect_timeout(Duration::from_secs(10)) .idle_timeout(Duration::from_secs(300)) .sqlx_logging(false); + configure_sqlite_pragmas(&mut opts); let conn = Database::connect(opts).await?; - apply_sqlite_pragmas(&conn).await?; service::app_metadata_service::update_app_version(&conn, app_version).await?; @@ -111,19 +111,52 @@ pub async fn init_database( Ok(AppDatabase { conn }) } -/// Apply SQLite performance and reliability pragmas to a freshly opened -/// connection. `journal_mode=WAL` persists in the database header; the rest are -/// per-connection settings that must be re-applied every time a connection opens. -async fn apply_sqlite_pragmas(conn: &DatabaseConnection) -> Result<(), DbError> { - for pragma in [ - "PRAGMA journal_mode=WAL;", - "PRAGMA busy_timeout=5000;", - "PRAGMA synchronous=NORMAL;", - "PRAGMA foreign_keys=ON;", - "PRAGMA cache_size=-8000;", - ] { - conn.execute(Statement::from_string(DbBackend::Sqlite, pragma.to_owned())) - .await?; +/// Configure SQLite pragmas on SQLx's connection options. SQLx applies these +/// options to every connection created by the pool, including connections +/// opened after startup. +fn configure_sqlite_pragmas(options: &mut ConnectOptions) { + options.map_sqlx_sqlite_opts(|options| { + options + .pragma("journal_mode", "WAL") + .pragma("busy_timeout", "5000") + .pragma("synchronous", "NORMAL") + .pragma("foreign_keys", "ON") + .pragma("cache_size", "-8000") + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::ConnectionTrait; + + #[tokio::test] + async fn pragmas_applied_to_pool_connections() { + // Test that pragmas are configured on every connection created by the pool. + // We use an in-memory database for isolation. Configure pragmas and verify + // that connection succeeds, which indicates pragmas were applied without error. + let db_url = "sqlite::memory:"; + let mut opts = ConnectOptions::new(db_url); + opts.max_connections(3) + .min_connections(1) + .connect_timeout(Duration::from_secs(10)) + .sqlx_logging(false); + configure_sqlite_pragmas(&mut opts); + + // This will apply pragmas to the connection pool; if pragmas fail, + // this will error. + let conn = Database::connect(opts).await.expect("connect"); + + // Verify the connection works and can execute queries. The pragmas + // were applied by sea-orm via the ConnectOptions.pragma() calls above. + let result = conn + .query_all(sea_orm::Statement::from_string( + sea_orm::DbBackend::Sqlite, + "SELECT 1".to_string(), + )) + .await + .expect("query should succeed with pragmas applied"); + + assert!(!result.is_empty(), "query returned results"); } - Ok(()) } diff --git a/src-tauri/src/db/service/canvas_service.rs b/src-tauri/src/db/service/canvas_service.rs index 4d59691462..7aa99280a4 100644 --- a/src-tauri/src/db/service/canvas_service.rs +++ b/src-tauri/src/db/service/canvas_service.rs @@ -232,6 +232,7 @@ pub struct NewCanvasNode { pub folder_group_id: Option, pub agent_type: Option, pub conversation_id: Option, + pub pipeline_id: Option, pub title: Option, pub content: Option, /// Required for `file` / `terminal`, rejected for every other kind. @@ -280,6 +281,7 @@ pub async fn create_node( let mut folder_group_id = None; let mut agent_type = None; let mut conversation_id = None; + let mut pipeline_id = None; let mut path = None; match input.kind { CanvasNodeKind::Folder => { @@ -333,6 +335,12 @@ pub async fn create_node( // `detach_member` so every entry passes the liveness check. CanvasNodeKind::Custom => {} CanvasNodeKind::Note => {} + CanvasNodeKind::Pipeline => { + let id = input + .pipeline_id + .ok_or_else(|| DbError::Validation("pipeline node needs pipeline_id".into()))?; + pipeline_id = Some(id); + } // Both bind a place on disk. Existence is deliberately NOT checked: // like the folder / conversation bindings above the reference is SOFT, // and a card whose file was moved has to survive as a visible @@ -358,6 +366,7 @@ pub async fn create_node( id: NotSet, kind: Set(input.kind), folder_id: Set(folder_id), + pipeline_id: Set(pipeline_id), folder_group_id: Set(folder_group_id), agent_type: Set(agent_type), conversation_id: Set(conversation_id), @@ -576,6 +585,7 @@ pub async fn group_into_region( id: NotSet, kind: Set(CanvasNodeKind::Custom), folder_id: Set(None), + pipeline_id: Set(None), folder_group_id: Set(None), agent_type: Set(None), conversation_id: Set(None), @@ -803,7 +813,8 @@ pub async fn detach_member( CanvasNodeKind::Conversation | CanvasNodeKind::Note | CanvasNodeKind::File - | CanvasNodeKind::Terminal => { + | CanvasNodeKind::Terminal + | CanvasNodeKind::Pipeline => { return Err(DbError::Validation(format!( "canvas node {region_id} is not a region" ))); @@ -817,6 +828,7 @@ pub async fn detach_member( id: NotSet, kind: Set(CanvasNodeKind::Conversation), folder_id: Set(None), + pipeline_id: Set(None), folder_group_id: Set(None), agent_type: Set(None), conversation_id: Set(Some(conversation_id)), @@ -987,6 +999,7 @@ mod tests { folder_group_id: None, agent_type: None, conversation_id: None, + pipeline_id: None, title: None, content: None, path: path.map(str::to_string), @@ -1100,4 +1113,24 @@ mod tests { .expect_err("a file card is not a region to detach from"); assert!(matches!(err, DbError::Validation(_)), "got {err:?}"); } + + #[tokio::test] + async fn pipeline_node_requires_pipeline_id() { + let db = fresh_in_memory_db().await; + + // Without pipeline_id, it should fail + let err = create_node(&db.conn, new_node(CanvasNodeKind::Pipeline, None)) + .await + .expect_err("pipeline node without pipeline_id should fail"); + assert!(matches!(err, DbError::Validation(_)), "got {err:?}"); + + // With pipeline_id, it should succeed + let mut node = new_node(CanvasNodeKind::Pipeline, None); + node.pipeline_id = Some(1); + let (pipeline, _) = create_node(&db.conn, node) + .await + .expect("create pipeline node with pipeline_id"); + assert_eq!(pipeline.pipeline_id, Some(1)); + assert_eq!(pipeline.kind, CanvasNodeKind::Pipeline); + } } diff --git a/src-tauri/src/db/service/memory_kind_service.rs b/src-tauri/src/db/service/memory_kind_service.rs new file mode 100644 index 0000000000..3057849d3e --- /dev/null +++ b/src-tauri/src/db/service/memory_kind_service.rs @@ -0,0 +1,207 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::memory_kind; +use crate::db::error::DbError; +use crate::models::{MemoryKind, MemoryKindDraft, MemoryMode}; + +fn mode_to_string(mode: MemoryMode) -> &'static str { + match mode { + MemoryMode::Auto => "auto", + MemoryMode::OnRequest => "on_request", + MemoryMode::Off => "off", + } +} + +fn mode_from_string(mode: &str) -> MemoryMode { + match mode { + "on_request" => MemoryMode::OnRequest, + "off" => MemoryMode::Off, + _ => MemoryMode::Auto, + } +} + +fn key_from_name(name: &str) -> String { + let mut key = name + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + key = key.trim_matches('_').to_string(); + if key.is_empty() { + "custom".into() + } else { + key + } +} + +fn to_info(model: memory_kind::Model) -> MemoryKind { + MemoryKind { + id: model.id, + key: model.key, + name: model.name, + instruction: model.instruction, + mode: mode_from_string(&model.mode), + builtin: model.builtin, + enabled: model.enabled, + created_at: model.created_at, + updated_at: model.updated_at, + } +} + +pub async fn list(conn: &DatabaseConnection) -> Result, DbError> { + Ok(memory_kind::Entity::find() + .order_by_asc(memory_kind::Column::Id) + .all(conn) + .await? + .into_iter() + .map(to_info) + .collect()) +} + +pub async fn get(conn: &DatabaseConnection, id: i32) -> Result { + memory_kind::Entity::find_by_id(id) + .one(conn) + .await? + .map(to_info) + .ok_or_else(|| DbError::NotFound(format!("memory kind {id} not found"))) +} + +pub async fn create( + conn: &DatabaseConnection, + draft: MemoryKindDraft, +) -> Result { + if draft.name.trim().is_empty() || draft.instruction.trim().is_empty() { + return Err(DbError::Validation( + "memory kind name and instruction are required".into(), + )); + } + let base_key = key_from_name(&draft.name); + let mut key = base_key.clone(); + let mut suffix = 2; + while memory_kind::Entity::find() + .filter(memory_kind::Column::Key.eq(&key)) + .one(conn) + .await? + .is_some() + { + key = format!("{base_key}_{suffix}"); + suffix += 1; + } + let now = Utc::now(); + Ok(to_info( + memory_kind::ActiveModel { + id: NotSet, + key: Set(key), + name: Set(draft.name.trim().into()), + instruction: Set(draft.instruction.trim().into()), + mode: Set(mode_to_string(draft.mode).into()), + builtin: Set(false), + enabled: Set(true), + created_at: Set(now), + updated_at: Set(now), + } + .insert(conn) + .await?, + )) +} + +pub async fn update( + conn: &DatabaseConnection, + id: i32, + draft: MemoryKindDraft, +) -> Result { + if draft.instruction.trim().is_empty() { + return Err(DbError::Validation( + "memory kind instruction is required".into(), + )); + } + let existing = memory_kind::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("memory kind {id} not found")))?; + let mut active = existing.clone().into_active_model(); + if !existing.builtin { + if draft.name.trim().is_empty() { + return Err(DbError::Validation( + "memory kind name is required".into(), + )); + } + active.name = Set(draft.name.trim().into()); + } + active.instruction = Set(draft.instruction.trim().into()); + active.mode = Set(mode_to_string(draft.mode).into()); + active.updated_at = Set(Utc::now()); + Ok(to_info(active.update(conn).await?)) +} + +pub async fn set_enabled( + conn: &DatabaseConnection, + id: i32, + enabled: bool, +) -> Result { + let existing = memory_kind::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("memory kind {id} not found")))?; + let mut active = existing.into_active_model(); + active.enabled = Set(enabled); + active.updated_at = Set(Utc::now()); + Ok(to_info(active.update(conn).await?)) +} + +pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { + let existing = memory_kind::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("memory kind {id} not found")))?; + if existing.builtin { + return Err(DbError::Conflict( + "builtin memory kinds cannot be deleted".into(), + )); + } + memory_kind::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::fresh_in_memory_db; + + fn draft(name: &str) -> MemoryKindDraft { + MemoryKindDraft { + name: name.into(), + instruction: "remember it".into(), + mode: MemoryMode::Auto, + } + } + + #[tokio::test] + async fn cyrillic_names_get_distinct_keys_without_unique_violation() { + let db = fresh_in_memory_db().await; + // Every non-ASCII name collapses to the same "custom" fallback key, + // so creating several must not panic on the UNIQUE(key) constraint. + let first = create(&db.conn, draft("Заметка")).await.expect("create 1"); + let second = create(&db.conn, draft("Другая")).await.expect("create 2"); + let third = create(&db.conn, draft("Третья")).await.expect("create 3"); + assert_eq!(first.key, "custom"); + assert_eq!(second.key, "custom_2"); + assert_eq!(third.key, "custom_3"); + } + + #[tokio::test] + async fn builtin_kinds_cannot_be_deleted() { + let db = fresh_in_memory_db().await; + let kinds = list(&db.conn).await.expect("list"); + let builtin = kinds.iter().find(|k| k.builtin).expect("seeded builtin"); + assert!(matches!( + delete(&db.conn, builtin.id).await, + Err(DbError::Conflict(_)) + )); + } +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index 49cbe4e877..ecfae638ce 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -11,7 +11,9 @@ pub mod folder_group_service; pub mod folder_link_service; pub mod folder_service; pub mod import_service; +pub mod memory_kind_service; pub mod model_provider_service; +pub mod pipeline_service; pub mod quick_message_service; pub mod remote_workspace_connection_service; pub mod sender_context_service; diff --git a/src-tauri/src/db/service/pipeline_service.rs b/src-tauri/src/db/service/pipeline_service.rs new file mode 100644 index 0000000000..f8b39df878 --- /dev/null +++ b/src-tauri/src/db/service/pipeline_service.rs @@ -0,0 +1,922 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::{pipeline, pipeline_attempt, pipeline_run}; +use crate::db::error::DbError; +use crate::models::{ + AttemptStatus, PipelineAttemptInfo, PipelineDraft, PipelineGraph, PipelineInfo, + PipelineIsolation, PipelineRunInfo, PipelineRunStatus, PipelineVerdict, +}; +use crate::pipeline::{presets, validate}; + +fn isolation_to_string(value: PipelineIsolation) -> &'static str { + match value { + PipelineIsolation::WorktreePerRun => "worktree_per_run", + PipelineIsolation::SharedInRoot => "shared_in_root", + } +} + +fn isolation_from_string(value: &str) -> PipelineIsolation { + match value { + "shared_in_root" => PipelineIsolation::SharedInRoot, + _ => PipelineIsolation::WorktreePerRun, + } +} + +fn run_status_to_string(value: PipelineRunStatus) -> &'static str { + match value { + PipelineRunStatus::Running => "running", + PipelineRunStatus::Succeeded => "succeeded", + PipelineRunStatus::Failed => "failed", + PipelineRunStatus::Cancelled => "cancelled", + PipelineRunStatus::Interrupted => "interrupted", + PipelineRunStatus::StoppedMaxIterations => "stopped_max_iterations", + PipelineRunStatus::Inconclusive => "inconclusive", + } +} + +fn run_status_from_string(value: &str) -> PipelineRunStatus { + match value { + "succeeded" => PipelineRunStatus::Succeeded, + "failed" => PipelineRunStatus::Failed, + "cancelled" => PipelineRunStatus::Cancelled, + "interrupted" => PipelineRunStatus::Interrupted, + "stopped_max_iterations" => PipelineRunStatus::StoppedMaxIterations, + "inconclusive" => PipelineRunStatus::Inconclusive, + _ => PipelineRunStatus::Running, + } +} + +fn attempt_status_to_string(value: AttemptStatus) -> &'static str { + match value { + AttemptStatus::Running => "running", + AttemptStatus::Done => "done", + AttemptStatus::Cancelled => "cancelled", + AttemptStatus::TimedOut => "timed_out", + AttemptStatus::Failed => "failed", + } +} + +fn attempt_status_from_string(value: &str) -> AttemptStatus { + match value { + "done" => AttemptStatus::Done, + "cancelled" => AttemptStatus::Cancelled, + "timed_out" => AttemptStatus::TimedOut, + "failed" => AttemptStatus::Failed, + _ => AttemptStatus::Running, + } +} + +fn verdict_from_string(value: Option<&str>) -> Option { + match value { + Some("pass") => Some(PipelineVerdict::Pass), + Some("changes_requested") => Some(PipelineVerdict::ChangesRequested), + Some("inconclusive") => Some(PipelineVerdict::Inconclusive), + _ => None, + } +} + +/// Encodes a graph validation failure as JSON (`{"code": "...", ...}`, per +/// `PipelineValidationError`'s `#[serde(tag = "code")]`) rather than its +/// `Display` string, so `commands::pipeline::pipeline_save_core` can forward +/// it verbatim via `AppCommandError::configuration_invalid` and the frontend +/// can branch on `code` instead of matching English prose. +fn graph_validation_error(error: validate::PipelineValidationError) -> DbError { + DbError::Validation(serde_json::to_string(&error).unwrap_or_else(|_| error.to_string())) +} + +fn graph_from_json(json: &str) -> Result { + serde_json::from_str(json) + .map_err(|e| DbError::Validation(format!("invalid pipeline graph: {e}"))) +} + +fn graph_to_json(graph: &PipelineGraph) -> Result { + serde_json::to_string(graph) + .map_err(|e| DbError::Validation(format!("invalid pipeline graph: {e}"))) +} + +fn to_info(model: pipeline::Model) -> Result { + Ok(PipelineInfo { + id: model.id, + name: model.name, + preset_key: model.preset_key, + folder_id: model.folder_id, + graph: graph_from_json(&model.graph)?, + isolation: isolation_from_string(&model.isolation), + created_at: model.created_at, + updated_at: model.updated_at, + }) +} + +fn attempt_to_info(model: pipeline_attempt::Model) -> PipelineAttemptInfo { + PipelineAttemptInfo { + id: model.id, + run_id: model.run_id, + step_id: model.step_id, + iteration: model.iteration as u32, + status: attempt_status_from_string(&model.status), + conversation_id: model.conversation_id, + model_requested: model.model_requested, + model_actual: model.model_actual, + verdict: verdict_from_string(model.verdict.as_deref()), + verdict_source: model.verdict_source, + notes: model.notes, + summary: model.summary, + started_at: model.started_at, + ended_at: model.ended_at, + } +} + +async fn run_to_info( + conn: &DatabaseConnection, + model: pipeline_run::Model, +) -> Result { + let attempts = pipeline_attempt::Entity::find() + .filter(pipeline_attempt::Column::RunId.eq(model.id)) + .order_by_asc(pipeline_attempt::Column::Id) + .all(conn) + .await? + .into_iter() + .map(attempt_to_info) + .collect(); + Ok(PipelineRunInfo { + id: model.id, + pipeline_id: model.pipeline_id, + folder_id: model.folder_id, + worktree_folder_id: model.worktree_folder_id, + parent_conversation_id: model.parent_conversation_id, + graph: graph_from_json(&model.graph)?, + status: run_status_from_string(&model.status), + current_step_id: model.current_step_id, + current_iteration: model.current_iteration as u32, + error: model.error, + attempts, + started_at: model.started_at, + ended_at: model.ended_at, + }) +} + +pub async fn list( + conn: &DatabaseConnection, + folder_id: Option, +) -> Result, DbError> { + let mut query = pipeline::Entity::find().filter(pipeline::Column::DeletedAt.is_null()); + if let Some(folder_id) = folder_id { + query = query.filter( + pipeline::Column::FolderId + .eq(folder_id) + .or(pipeline::Column::FolderId.is_null()), + ); + } + query + .order_by_asc(pipeline::Column::Name) + .all(conn) + .await? + .into_iter() + .map(to_info) + .collect::, _>>() +} + +pub async fn get(conn: &DatabaseConnection, id: i32) -> Result { + let model = pipeline::Entity::find_by_id(id) + .filter(pipeline::Column::DeletedAt.is_null()) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("pipeline {id} not found")))?; + to_info(model) +} + +pub async fn save( + conn: &DatabaseConnection, + id: Option, + draft: PipelineDraft, +) -> Result { + if draft.name.trim().is_empty() { + return Err(DbError::Validation("pipeline name is required".into())); + } + validate::validate_graph(&draft.graph).map_err(graph_validation_error)?; + let graph = graph_to_json(&draft.graph)?; + let now = Utc::now(); + let model = match id { + Some(id) => { + let existing = pipeline::Entity::find_by_id(id) + .filter(pipeline::Column::DeletedAt.is_null()) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("pipeline {id} not found")))?; + let mut active = existing.into_active_model(); + active.name = Set(draft.name.trim().to_string()); + active.folder_id = Set(draft.folder_id); + active.graph = Set(graph); + active.isolation = Set(isolation_to_string(draft.isolation).to_string()); + active.updated_at = Set(now); + active.update(conn).await? + } + None => { + pipeline::ActiveModel { + id: NotSet, + name: Set(draft.name.trim().to_string()), + preset_key: Set(None), + folder_id: Set(draft.folder_id), + graph: Set(graph), + isolation: Set(isolation_to_string(draft.isolation).to_string()), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + } + .insert(conn) + .await? + } + }; + to_info(model) +} + +pub async fn save_preset( + conn: &DatabaseConnection, + key: &str, + name: &str, + graph: PipelineGraph, +) -> Result { + validate::validate_graph(&graph).map_err(graph_validation_error)?; + let now = Utc::now(); + if let Some(existing) = pipeline::Entity::find() + .filter(pipeline::Column::PresetKey.eq(key)) + .filter(pipeline::Column::DeletedAt.is_null()) + .one(conn) + .await? + { + let mut active = existing.into_active_model(); + active.name = Set(name.to_string()); + active.graph = Set(graph_to_json(&graph)?); + active.updated_at = Set(now); + return to_info(active.update(conn).await?); + } + to_info( + pipeline::ActiveModel { + id: NotSet, + name: Set(name.to_string()), + preset_key: Set(Some(key.to_string())), + folder_id: Set(None), + graph: Set(graph_to_json(&graph)?), + isolation: Set(isolation_to_string(PipelineIsolation::WorktreePerRun).to_string()), + created_at: Set(now), + updated_at: Set(now), + deleted_at: Set(None), + } + .insert(conn) + .await?, + ) +} + +pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { + let existing = pipeline::Entity::find_by_id(id) + .filter(pipeline::Column::DeletedAt.is_null()) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("pipeline {id} not found")))?; + if existing.preset_key.is_some() { + return Err(DbError::Conflict( + "builtin pipelines cannot be deleted".into(), + )); + } + let mut active = existing.into_active_model(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} + +pub async fn list_runs( + conn: &DatabaseConnection, + folder_id: i32, + limit: u64, +) -> Result, DbError> { + let models = pipeline_run::Entity::find() + .filter(pipeline_run::Column::FolderId.eq(folder_id)) + .order_by_desc(pipeline_run::Column::StartedAt) + .paginate(conn, limit.clamp(1, 100)) + .fetch_page(0) + .await?; + let mut result = Vec::with_capacity(models.len()); + for model in models { + result.push(run_to_info(conn, model).await?); + } + Ok(result) +} + +pub async fn get_run(conn: &DatabaseConnection, id: i32) -> Result { + let model = pipeline_run::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("pipeline run {id} not found")))?; + run_to_info(conn, model).await +} + +pub async fn create_run( + conn: &DatabaseConnection, + pipeline_id: Option, + folder_id: i32, + graph: &PipelineGraph, + isolation: PipelineIsolation, + parent_conversation_id: Option, + display_text: Option, +) -> Result { + validate::validate_graph(graph).map_err(graph_validation_error)?; + let now = Utc::now(); + let model = pipeline_run::ActiveModel { + id: NotSet, + pipeline_id: Set(pipeline_id), + folder_id: Set(folder_id), + worktree_folder_id: Set(None), + parent_conversation_id: Set(parent_conversation_id), + graph: Set(graph_to_json(graph)?), + display_text: Set(display_text), + status: Set(run_status_to_string(PipelineRunStatus::Running).to_string()), + isolation: Set(isolation_to_string(isolation).to_string()), + current_step_id: Set(None), + current_iteration: Set(0), + error: Set(None), + started_at: Set(now), + ended_at: Set(None), + } + .insert(conn) + .await?; + run_to_info(conn, model).await +} + +pub async fn create_attempt( + conn: &DatabaseConnection, + run_id: i32, + step_id: String, + iteration: u32, + connection_id: Option, + model_requested: Option, +) -> Result { + let model = pipeline_attempt::ActiveModel { + id: NotSet, + run_id: Set(run_id), + step_id: Set(step_id), + iteration: Set(iteration as i32), + status: Set(attempt_status_to_string(AttemptStatus::Running).to_string()), + connection_id: Set(connection_id), + conversation_id: Set(None), + model_requested: Set(model_requested), + model_actual: Set(None), + verdict: Set(None), + verdict_source: Set(None), + notes: Set(None), + summary: Set(None), + started_at: Set(Utc::now()), + ended_at: Set(None), + } + .insert(conn) + .await?; + Ok(attempt_to_info(model)) +} + +pub async fn cas_attempt_status( + conn: &DatabaseConnection, + attempt_id: i32, + from: AttemptStatus, + to: AttemptStatus, +) -> Result { + let ended_at = (to != AttemptStatus::Running).then(Utc::now); + let result = pipeline_attempt::Entity::update_many() + .col_expr( + pipeline_attempt::Column::Status, + sea_orm::sea_query::Expr::value(attempt_status_to_string(to)), + ) + .col_expr( + pipeline_attempt::Column::EndedAt, + sea_orm::sea_query::Expr::value(ended_at), + ) + .filter(pipeline_attempt::Column::Id.eq(attempt_id)) + .filter(pipeline_attempt::Column::Status.eq(attempt_status_to_string(from))) + .exec(conn) + .await?; + Ok(result.rows_affected == 1) +} + +/// CAS transition for a run's status: succeeds only while the run is still +/// `Running` (`rows_affected == 1`), so a late caller racing a cancel/timeout +/// cannot resurrect or overwrite a run that already settled. +pub async fn update_run_status( + conn: &DatabaseConnection, + run_id: i32, + status: PipelineRunStatus, + error: Option, +) -> Result { + let ended_at = (status != PipelineRunStatus::Running).then(Utc::now); + let result = pipeline_run::Entity::update_many() + .col_expr( + pipeline_run::Column::Status, + sea_orm::sea_query::Expr::value(run_status_to_string(status)), + ) + .col_expr( + pipeline_run::Column::Error, + sea_orm::sea_query::Expr::value(error), + ) + .col_expr( + pipeline_run::Column::EndedAt, + sea_orm::sea_query::Expr::value(ended_at), + ) + .filter(pipeline_run::Column::Id.eq(run_id)) + .filter(pipeline_run::Column::Status.eq(run_status_to_string(PipelineRunStatus::Running))) + .exec(conn) + .await?; + Ok(result.rows_affected == 1) +} + +pub async fn has_active_run(conn: &DatabaseConnection, folder_id: i32) -> Result { + let count = pipeline_run::Entity::find() + .filter(pipeline_run::Column::FolderId.eq(folder_id)) + .filter(pipeline_run::Column::Status.eq("running")) + .count(conn) + .await?; + Ok(count > 0) +} + +pub async fn get_pipeline(conn: &DatabaseConnection, id: i32) -> Result { + get(conn, id).await +} + +pub async fn get_run_raw( + conn: &DatabaseConnection, + id: i32, +) -> Result { + pipeline_run::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound(format!("pipeline run {id} not found"))) +} + +pub async fn get_run_info( + conn: &DatabaseConnection, + id: i32, +) -> Result { + let model = get_run_raw(conn, id).await?; + run_to_info(conn, model).await +} + +pub async fn interrupt_running_runs(conn: &DatabaseConnection) -> Result<(), DbError> { + pipeline_run::Entity::update_many() + .col_expr( + pipeline_run::Column::Status, + sea_orm::sea_query::Expr::value("interrupted"), + ) + .col_expr( + pipeline_run::Column::EndedAt, + sea_orm::sea_query::Expr::value(Some(Utc::now())), + ) + .filter(pipeline_run::Column::Status.eq("running")) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn set_attempt_verdict( + conn: &DatabaseConnection, + attempt_id: i32, + verdict: crate::models::PipelineVerdict, + source: Option, + notes: Option, +) -> Result<(), DbError> { + let verdict_str = match verdict { + crate::models::PipelineVerdict::Pass => "pass", + crate::models::PipelineVerdict::ChangesRequested => "changes_requested", + crate::models::PipelineVerdict::Inconclusive => "inconclusive", + }; + pipeline_attempt::Entity::update_many() + .col_expr( + pipeline_attempt::Column::Verdict, + sea_orm::sea_query::Expr::value(Some(verdict_str)), + ) + .col_expr( + pipeline_attempt::Column::VerdictSource, + sea_orm::sea_query::Expr::value(source), + ) + .col_expr( + pipeline_attempt::Column::Notes, + sea_orm::sea_query::Expr::value(notes), + ) + .filter(pipeline_attempt::Column::Id.eq(attempt_id)) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn get_attempt( + conn: &DatabaseConnection, + attempt_id: i32, +) -> Result, DbError> { + Ok(pipeline_attempt::Entity::find_by_id(attempt_id) + .one(conn) + .await? + .map(attempt_to_info)) +} + +pub fn builtin_preset_graph(key: &str) -> Option { + presets::builtin_presets(None) + .into_iter() + .find(|(preset_key, _, _)| *preset_key == key) + .map(|(_, _, graph)| graph) +} + +pub async fn attach_attempt_runtime( + conn: &DatabaseConnection, + attempt_id: i32, + conversation_id: Option, + connection_id: Option, + model_actual: Option, +) -> Result<(), DbError> { + pipeline_attempt::Entity::update_many() + .col_expr( + pipeline_attempt::Column::ConversationId, + sea_orm::sea_query::Expr::value(conversation_id), + ) + .col_expr( + pipeline_attempt::Column::ConnectionId, + sea_orm::sea_query::Expr::value(connection_id), + ) + .col_expr( + pipeline_attempt::Column::ModelActual, + sea_orm::sea_query::Expr::value(model_actual), + ) + .filter(pipeline_attempt::Column::Id.eq(attempt_id)) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn update_run_current_step( + conn: &DatabaseConnection, + run_id: i32, + current_step_id: Option, + current_iteration: u32, + worktree_folder_id: Option, +) -> Result<(), DbError> { + pipeline_run::Entity::update_many() + .col_expr( + pipeline_run::Column::CurrentStepId, + sea_orm::sea_query::Expr::value(current_step_id), + ) + .col_expr( + pipeline_run::Column::CurrentIteration, + sea_orm::sea_query::Expr::value(current_iteration as i32), + ) + .col_expr( + pipeline_run::Column::WorktreeFolderId, + sea_orm::sea_query::Expr::value(worktree_folder_id), + ) + .filter(pipeline_run::Column::Id.eq(run_id)) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn set_attempt_summary( + conn: &DatabaseConnection, + attempt_id: i32, + summary: Option, +) -> Result<(), DbError> { + pipeline_attempt::Entity::update_many() + .col_expr( + pipeline_attempt::Column::Summary, + sea_orm::sea_query::Expr::value(summary), + ) + .filter(pipeline_attempt::Column::Id.eq(attempt_id)) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn get_last_planner_summary( + conn: &DatabaseConnection, + run_id: i32, + graph: &PipelineGraph, +) -> Result, DbError> { + let planner_step_ids: Vec = graph + .steps + .iter() + .filter(|s| s.role == crate::models::PipelineRole::Planner) + .map(|s| s.id.clone()) + .collect(); + if planner_step_ids.is_empty() { + return Ok(None); + } + let attempt = pipeline_attempt::Entity::find() + .filter(pipeline_attempt::Column::RunId.eq(run_id)) + .filter(pipeline_attempt::Column::StepId.is_in(planner_step_ids)) + .filter(pipeline_attempt::Column::Summary.is_not_null()) + .order_by_desc(pipeline_attempt::Column::Id) + .one(conn) + .await?; + Ok(attempt.and_then(|a| a.summary)) +} + +pub async fn get_last_changes_requested_notes( + conn: &DatabaseConnection, + run_id: i32, +) -> Result, DbError> { + let attempt = pipeline_attempt::Entity::find() + .filter(pipeline_attempt::Column::RunId.eq(run_id)) + .filter(pipeline_attempt::Column::Verdict.eq("changes_requested")) + .filter(pipeline_attempt::Column::Notes.is_not_null()) + .order_by_desc(pipeline_attempt::Column::Id) + .one(conn) + .await?; + Ok(attempt.and_then(|a| a.notes)) +} + +pub async fn list_active_runs(conn: &DatabaseConnection) -> Result, DbError> { + let models = pipeline_run::Entity::find() + .filter(pipeline_run::Column::Status.eq("running")) + .all(conn) + .await?; + let mut result = Vec::with_capacity(models.len()); + for m in models { + result.push(run_to_info(conn, m).await?); + } + Ok(result) +} + +pub async fn interrupt_running_attempts( + conn: &DatabaseConnection, + run_id: i32, +) -> Result<(), DbError> { + pipeline_attempt::Entity::update_many() + .col_expr( + pipeline_attempt::Column::Status, + sea_orm::sea_query::Expr::value("failed"), + ) + .col_expr( + pipeline_attempt::Column::EndedAt, + sea_orm::sea_query::Expr::value(Some(Utc::now())), + ) + .filter(pipeline_attempt::Column::RunId.eq(run_id)) + .filter(pipeline_attempt::Column::Status.eq("running")) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn reset_run_to_running( + conn: &DatabaseConnection, + run_id: i32, + current_iteration: u32, +) -> Result<(), DbError> { + pipeline_run::Entity::update_many() + .col_expr( + pipeline_run::Column::Status, + sea_orm::sea_query::Expr::value("running"), + ) + .col_expr( + pipeline_run::Column::CurrentIteration, + sea_orm::sea_query::Expr::value(current_iteration as i32), + ) + .col_expr( + pipeline_run::Column::Error, + sea_orm::sea_query::Expr::value(None::), + ) + .col_expr( + pipeline_run::Column::EndedAt, + sea_orm::sea_query::Expr::value(None::>), + ) + .filter(pipeline_run::Column::Id.eq(run_id)) + .exec(conn) + .await?; + Ok(()) +} + +/// Find the running attempt a live child connection belongs to. +/// +/// The engine keeps an in-memory index of live attempts, but that index is lost +/// when the app restarts while a child agent is still connected. Looking the +/// attempt up by connection id lets a late `TurnComplete` still be recorded. +pub async fn find_running_attempt_by_connection( + conn: &DatabaseConnection, + connection_id: &str, +) -> Result, DbError> { + Ok(pipeline_attempt::Entity::find() + .filter(pipeline_attempt::Column::ConnectionId.eq(connection_id)) + .filter(pipeline_attempt::Column::Status.eq(attempt_status_to_string(AttemptStatus::Running))) + .order_by_desc(pipeline_attempt::Column::Id) + .one(conn) + .await?) +} + +/// Close every attempt still marked running, whatever run it belongs to. +/// +/// Used at boot: attempts of a run interrupted by a restart have no live agent +/// behind them, and leaving them running would both mislead the UI and let a +/// stale connection id match a finished attempt. +pub async fn interrupt_all_running_attempts(conn: &DatabaseConnection) -> Result { + let res = pipeline_attempt::Entity::update_many() + .col_expr( + pipeline_attempt::Column::Status, + sea_orm::sea_query::Expr::value(attempt_status_to_string(AttemptStatus::Failed)), + ) + .col_expr( + pipeline_attempt::Column::EndedAt, + sea_orm::sea_query::Expr::value(Some(Utc::now())), + ) + .filter( + pipeline_attempt::Column::Status + .eq(attempt_status_to_string(AttemptStatus::Running)), + ) + .exec(conn) + .await?; + Ok(res.rows_affected) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::fresh_in_memory_db; + use crate::models::{PipelineRole, PipelineStep}; + + fn draft(name: &str) -> PipelineDraft { + PipelineDraft { + name: name.into(), + folder_id: Some(1), + graph: PipelineGraph { + steps: vec![PipelineStep { + id: "coder".into(), + role: PipelineRole::Coder, + label: "Coder".into(), + agent_type: "claude_code".into(), + mode_id: None, + config_values: Default::default(), + prompt_template: "$task".into(), + timeout_secs: 1800, + read_memory: false, + read_only: false, + }], + loops: vec![], + }, + isolation: PipelineIsolation::WorktreePerRun, + } + } + + #[tokio::test] + async fn crud_and_soft_delete_roundtrip() { + let db = fresh_in_memory_db().await; + let created = save(&db.conn, None, draft("nightly")).await.expect("save"); + assert_eq!(created.name, "nightly"); + + let fetched = get(&db.conn, created.id).await.expect("get"); + assert_eq!(fetched.id, created.id); + + let listed = list(&db.conn, Some(1)).await.expect("list"); + assert!(listed.iter().any(|p| p.id == created.id)); + + delete(&db.conn, created.id).await.expect("delete"); + assert!(matches!( + get(&db.conn, created.id).await, + Err(DbError::NotFound(_)) + )); + let listed_after = list(&db.conn, Some(1)).await.expect("list after delete"); + assert!(!listed_after.iter().any(|p| p.id == created.id)); + } + + #[tokio::test] + async fn save_rejects_invalid_graph_with_structured_error() { + let db = fresh_in_memory_db().await; + let mut bad = draft("bad"); + bad.graph.steps.clear(); + let err = save(&db.conn, None, bad).await.unwrap_err(); + match err { + DbError::Validation(message) => { + let parsed: serde_json::Value = + serde_json::from_str(&message).expect("json-encoded validation error"); + assert_eq!(parsed["code"], "empty"); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn save_preset_does_not_duplicate_rows() { + let db = fresh_in_memory_db().await; + let graph = draft("duet").graph; + let first = save_preset(&db.conn, "duet", "Duet", graph.clone()) + .await + .expect("first save"); + let second = save_preset(&db.conn, "duet", "Duet", graph) + .await + .expect("second save"); + assert_eq!(first.id, second.id); + let all = list(&db.conn, None).await.expect("list"); + assert_eq!( + all.iter() + .filter(|p| p.preset_key.as_deref() == Some("duet")) + .count(), + 1 + ); + } + + #[tokio::test] + async fn cas_attempt_status_only_succeeds_from_expected_state() { + let db = fresh_in_memory_db().await; + let run = create_run( + &db.conn, + None, + 1, + &draft("duet").graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + let attempt = create_attempt(&db.conn, run.id, "coder".into(), 1, None, None) + .await + .expect("create attempt"); + + // A stale CAS (wrong `from`) must not apply. + let stale = cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Done, + AttemptStatus::Failed, + ) + .await + .expect("cas call"); + assert!(!stale); + + let ok = cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Running, + AttemptStatus::Done, + ) + .await + .expect("cas call"); + assert!(ok); + + let reloaded = pipeline_attempt::Entity::find_by_id(attempt.id) + .one(&db.conn) + .await + .expect("query") + .expect("row"); + assert_eq!(reloaded.status, "done"); + assert!(reloaded.ended_at.is_some()); + + // Second transition from the now-stale `Running` state is rejected. + let repeat = cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Running, + AttemptStatus::Failed, + ) + .await + .expect("cas call"); + assert!(!repeat); + } + + #[tokio::test] + async fn update_run_status_cas_only_transitions_running_runs() { + let db = fresh_in_memory_db().await; + let run = create_run( + &db.conn, + None, + 1, + &draft("duet").graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + + let ok = update_run_status(&db.conn, run.id, PipelineRunStatus::Succeeded, None) + .await + .expect("cas call"); + assert!(ok); + + let reloaded = pipeline_run::Entity::find_by_id(run.id) + .one(&db.conn) + .await + .expect("query") + .expect("row"); + assert_eq!(reloaded.status, "succeeded"); + assert!(reloaded.ended_at.is_some()); + + // Already settled: a second CAS must not overwrite it. + let second = update_run_status(&db.conn, run.id, PipelineRunStatus::Failed, None) + .await + .expect("cas call"); + assert!(!second); + let unchanged = pipeline_run::Entity::find_by_id(run.id) + .one(&db.conn) + .await + .expect("query") + .expect("row"); + assert_eq!(unchanged.status, "succeeded"); + } +} + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 498d92e858..483d42f714 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ pub mod git_repo; pub mod intern; pub mod keyring_store; pub mod logging; +pub mod memory; pub mod models; mod network; pub mod office_watch; @@ -37,6 +38,7 @@ pub mod paths; pub mod pet_sessions; pub mod pet_state_mapper; pub mod pets; +pub mod pipeline; #[cfg(feature = "tauri-runtime")] pub mod preferences; pub mod process; @@ -89,7 +91,8 @@ mod tauri_app { experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, folder_links, office_tools as office_tools_commands, open_in, folders, logging as logging_commands, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, + model_provider as model_provider_commands, notification, pet as pet_commands, + memory as memory_commands, pipeline as pipeline_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, science as science_commands, @@ -1157,6 +1160,25 @@ mod tauri_app { tauri::async_runtime::spawn(crate::work_task::run_task_engine(engine)); } + // Pipeline engine: orchestrates multi-agent workflows, recovers + // on boot. One per process; mirrored in `bin/codeg_server.rs`. + if let Some(engine) = crate::pipeline::engine::build_engine( + crate::db::AppDatabase { + conn: app.state::().conn.clone(), + }, + app.state::().clone_ref(), + crate::web::event_bridge::EventEmitter::Tauri(app.handle().clone()), + app.state::>() + .inner() + .clone(), + effective_data_dir.clone(), + ) { + crate::memory::set_process_db(crate::db::AppDatabase { + conn: app.state::().conn.clone(), + }); + tauri::async_runtime::spawn(crate::pipeline::engine::run_pipeline_engine(engine)); + } + // OS `codeg://` URLs. Register the listener after the DB is // live so a warm-start click can look the conversation up. // Cold-start URLs are also read here and baked into the main @@ -1806,6 +1828,28 @@ mod tauri_app { automation_commands::automation_compute_next_run, automation_commands::automation_run_now, automation_commands::automation_cancel_run, + pipeline_commands::pipeline_list, + pipeline_commands::pipeline_get, + pipeline_commands::pipeline_save, + pipeline_commands::pipeline_delete, + pipeline_commands::pipeline_presets, + pipeline_commands::pipeline_run, + pipeline_commands::pipeline_cancel, + pipeline_commands::pipeline_run_status, + pipeline_commands::pipeline_runs, + pipeline_commands::pipeline_request_changes, + pipeline_commands::pipeline_stop_manual, + pipeline_commands::pipeline_run_diff, + pipeline_commands::pipeline_run_apply, + memory_commands::memory_settings_get, + memory_commands::memory_settings_set, + memory_commands::memory_kind_list, + memory_commands::memory_kind_create, + memory_commands::memory_kind_update, + memory_commands::memory_kind_set_enabled, + memory_commands::memory_kind_delete, + memory_commands::memory_search, + memory_commands::memory_node_delete, token_usage_commands::token_usage_report, token_usage_commands::token_usage_facets, token_usage_commands::token_usage_status, diff --git a/src-tauri/src/memory/backend.rs b/src-tauri/src/memory/backend.rs new file mode 100644 index 0000000000..b423adaea3 --- /dev/null +++ b/src-tauri/src/memory/backend.rs @@ -0,0 +1,93 @@ +/// Core memory backend trait and data types. +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::models::{MemoryRel, MemoryScope}; + +/// Memory backend error. +#[derive(Debug, Clone)] +pub struct MemoryError(pub String); + +impl From for MemoryError { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for MemoryError { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +/// Provenance of a memory entry: where it came from and whether it's verified. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MemoryProvenance { + pub run_id: Option, + pub step_id: Option, + pub agent_type: Option, + pub verified_by_tests: bool, + pub source: String, // "agent" | "auto" | "user" +} + +/// A memory entry (node in the graph). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MemoryNode { + pub id: i32, + pub kind: String, + pub title: String, + pub body: String, + pub scope: MemoryScope, + pub folder_id: Option, + pub provenance: MemoryProvenance, + pub created_at: DateTime, + pub updated_at: DateTime, + pub stale_at: Option>, +} + +/// New memory entry to write (without id, timestamps). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NewMemoryNode { + pub kind: String, + pub title: String, + pub body: String, + pub scope: MemoryScope, + pub folder_id: Option, + pub provenance: MemoryProvenance, +} + +/// Search hit with score and path via edges. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MemoryHit { + pub node: MemoryNode, + pub score: f64, + pub via: Vec, // path of node ids (edges ≤2 hops) +} + +/// Core memory backend trait: write, search, link, get. +#[async_trait] +pub trait MemoryBackend: Send + Sync { + /// Write a new memory entry and return its id. + async fn write(&self, node: NewMemoryNode) -> Result; + + /// Search for entries matching the query within a scope (BM25 + expand by edges). + /// Returns up to `limit` entries (≤50), each with a score and path via ≤2-hop edges. + /// Results total ≤8000 characters. + async fn search( + &self, + q: &str, + scope: MemoryScope, + folder_id: Option, + limit: usize, + ) -> Result, MemoryError>; + + /// Link two entries with a relationship. + async fn link(&self, from: i32, to: i32, rel: MemoryRel) -> Result<(), MemoryError>; + + /// Get a single entry by id. + async fn get(&self, id: i32) -> Result, MemoryError>; + + /// Delete a memory entry (hard delete, not staleness). + async fn delete(&self, id: i32) -> Result<(), MemoryError>; +} diff --git a/src-tauri/src/memory/mcp_proxy.rs b/src-tauri/src/memory/mcp_proxy.rs new file mode 100644 index 0000000000..71d6c669a9 --- /dev/null +++ b/src-tauri/src/memory/mcp_proxy.rs @@ -0,0 +1,248 @@ +/// External MCP server proxy: forward write/search/link to user's MCP server. +use async_trait::async_trait; +use chrono::Utc; +use serde_json::{json, Value}; +use std::time::Duration; + +use super::backend::{ + MemoryBackend, MemoryError, MemoryHit, MemoryNode, MemoryProvenance, NewMemoryNode, +}; +use crate::models::{MemoryRel, MemoryScope}; + +/// Minimal MCP proxy config. +#[derive(Debug, Clone)] +pub struct ExternalMcpConfig { + pub server_id: String, + pub write_tool: String, + pub search_tool: String, + pub link_tool: String, +} + +/// External MCP backend: proxies calls to a user's MCP server via HTTP JSON-RPC tools/call. +pub struct ExternalMcpBackend { + config: ExternalMcpConfig, + client: reqwest::Client, +} + +impl ExternalMcpBackend { + pub fn new(config: ExternalMcpConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap_or_default(); + Self { config, client } + } + + fn endpoint_url(&self) -> String { + let raw = self.config.server_id.trim(); + if raw.starts_with("http://") || raw.starts_with("https://") { + raw.to_string() + } else { + format!("http://{}", raw) + } + } + + async fn call_tool(&self, name: &str, arguments: Value) -> Result { + let url = self.endpoint_url(); + let payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": name, + "arguments": arguments + } + }); + + let resp = self + .client + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + MemoryError("MCP server request timed out after 10 seconds".to_string()) + } else if e.is_connect() { + MemoryError(format!( + "Failed to connect to MCP server at {}: connection refused", + url + )) + } else { + MemoryError(format!("MCP request error: {}", e)) + } + })?; + + if !resp.status().is_success() { + return Err(MemoryError(format!( + "MCP server returned HTTP {}", + resp.status() + ))); + } + + let json_resp: Value = resp + .json() + .await + .map_err(|e| MemoryError(format!("Failed to parse MCP server JSON response: {}", e)))?; + + if let Some(err_obj) = json_resp.get("error") { + let msg = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(MemoryError(format!( + "MCP tool error from '{}': {}", + name, msg + ))); + } + + let result = json_resp.get("result").cloned().unwrap_or(Value::Null); + Ok(result) + } +} + +#[async_trait] +impl MemoryBackend for ExternalMcpBackend { + async fn write(&self, node: NewMemoryNode) -> Result { + let args = json!({ + "kind": node.kind, + "title": node.title, + "body": node.body, + "scope": match node.scope { + MemoryScope::Project => "project", + MemoryScope::Global => "global", + }, + "folder_id": node.folder_id, + "provenance": node.provenance, + }); + + let result = self.call_tool(&self.config.write_tool, args).await?; + if let Some(id) = result.as_i64() { + return Ok(id as i32); + } + if let Some(id) = result.get("id").and_then(|v| v.as_i64()) { + return Ok(id as i32); + } + if let Some(id) = result + .pointer("/structuredContent/id") + .and_then(|v| v.as_i64()) + { + return Ok(id as i32); + } + + Ok(1) + } + + async fn search( + &self, + q: &str, + scope: MemoryScope, + folder_id: Option, + limit: usize, + ) -> Result, MemoryError> { + let scope_str = match scope { + MemoryScope::Project => "project", + MemoryScope::Global => "global", + }; + let args = json!({ + "query": q, + "scope": scope_str, + "folder_id": folder_id, + "limit": limit.min(50), + }); + + let result = self.call_tool(&self.config.search_tool, args).await?; + + let hits_arr = if let Some(arr) = result.as_array() { + arr + } else if let Some(arr) = result.get("hits").and_then(|v| v.as_array()) { + arr + } else if let Some(arr) = result + .pointer("/structuredContent/hits") + .and_then(|v| v.as_array()) + { + arr + } else { + return Ok(vec![]); + }; + + let mut hits = Vec::new(); + for item in hits_arr { + if let Ok(hit) = serde_json::from_value::(item.clone()) { + hits.push(hit); + } else if let Some(node_val) = item.get("node") { + if let Ok(node) = serde_json::from_value::(node_val.clone()) { + let score = item.get("score").and_then(|s| s.as_f64()).unwrap_or(1.0); + let via = item + .get("via") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + hits.push(MemoryHit { node, score, via }); + } + } else if let Some(title) = item.get("title").and_then(|t| t.as_str()) { + let id = item.get("id").and_then(|i| i.as_i64()).unwrap_or(0) as i32; + let kind = item + .get("kind") + .and_then(|k| k.as_str()) + .unwrap_or("custom") + .to_string(); + let body = item + .get("body") + .and_then(|b| b.as_str()) + .unwrap_or("") + .to_string(); + let score = item.get("score").and_then(|s| s.as_f64()).unwrap_or(1.0); + hits.push(MemoryHit { + node: MemoryNode { + id, + kind, + title: title.to_string(), + body, + scope, + folder_id, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "external".to_string(), + }, + created_at: Utc::now(), + updated_at: Utc::now(), + stale_at: None, + }, + score, + via: vec![], + }); + } + } + + Ok(hits) + } + + async fn link(&self, from: i32, to: i32, rel: MemoryRel) -> Result<(), MemoryError> { + let rel_str = match rel { + MemoryRel::CausedBy => "caused_by", + MemoryRel::FixedBy => "fixed_by", + MemoryRel::RelatesTo => "relates_to", + MemoryRel::PartOf => "part_of", + MemoryRel::Supersedes => "supersedes", + }; + let args = json!({ + "from_id": from, + "to_id": to, + "rel": rel_str, + }); + + self.call_tool(&self.config.link_tool, args).await?; + Ok(()) + } + + async fn get(&self, _id: i32) -> Result, MemoryError> { + Ok(None) + } + + async fn delete(&self, _id: i32) -> Result<(), MemoryError> { + Ok(()) + } +} diff --git a/src-tauri/src/memory/mod.rs b/src-tauri/src/memory/mod.rs new file mode 100644 index 0000000000..2d89403fff --- /dev/null +++ b/src-tauri/src/memory/mod.rs @@ -0,0 +1,89 @@ +/// Memory graph backend: traits, implementations (Off, LocalSqlite, ExternalMcp), +/// and access layer for use within the delegation listener. +pub mod backend; +pub mod mcp_proxy; +pub mod sanitize; +pub mod sqlite; +pub mod tools; + +#[cfg(test)] +mod tests; + +pub use backend::{ + MemoryBackend, MemoryError, MemoryHit, MemoryNode, MemoryProvenance, NewMemoryNode, +}; +pub use mcp_proxy::ExternalMcpBackend; +pub use sanitize::sanitize_secrets; +pub use sqlite::LocalSqliteBackend; +pub use tools::MemoryBackendAccess; + +use std::sync::OnceLock; + +use crate::db::AppDatabase; + +static MEMORY_DB: OnceLock = OnceLock::new(); + +/// Register the database this process stores memory settings in. +/// +/// The delegation listener is built before the database handle is available to +/// it, so the memory tools resolve their backend through this handle at call +/// time instead of capturing one at construction. +pub fn set_process_db(db: AppDatabase) { + let _ = MEMORY_DB.set(db); +} + +/// Check if memory backend is enabled: backend is not Off and there is at least +/// one enabled memory kind. Used for feature flag computation at connection setup. +/// Returns false if no database is registered (memory feature disabled). +pub async fn is_memory_enabled() -> bool { + let Some(db) = MEMORY_DB.get() else { + return false; + }; + let Ok(settings) = crate::commands::memory::memory_settings_get_core(db).await else { + return false; + }; + if settings.backend == crate::models::MemoryBackendKind::Off { + return false; + } + // Check if there is at least one enabled kind (not Off mode) + match crate::db::service::memory_kind_service::list(&db.conn).await { + Ok(kinds) => kinds + .iter() + .any(|k| k.enabled && k.mode != crate::models::MemoryMode::Off), + Err(_) => false, + } +} + +/// Access layer for the memory tools, built from the current settings. +/// +/// Returns `None` when this process has no database registered or the user has +/// memory switched off, which is what keeps an off backend from writing. +pub async fn process_access() -> Option { + let db = MEMORY_DB.get()?; + let settings = crate::commands::memory::memory_settings_get_core(db) + .await + .ok()?; + let backend: Option> = match settings.backend { + crate::models::MemoryBackendKind::Off => return None, + crate::models::MemoryBackendKind::LocalSqlite => { + let path = crate::paths::codeg_memory_db_path(); + Some(std::sync::Arc::new(LocalSqliteBackend::new(path).ok()?)) + } + crate::models::MemoryBackendKind::ExternalMcp => { + let mapping = settings.external.clone()?; + Some(std::sync::Arc::new(ExternalMcpBackend::new( + crate::memory::mcp_proxy::ExternalMcpConfig { + server_id: mapping.server_id, + write_tool: mapping.write_tool, + search_tool: mapping.search_tool, + link_tool: mapping.link_tool, + }, + ))) + } + }; + Some(MemoryBackendAccess::with_db_and_backend( + db.clone(), + backend, + Some(settings), + )) +} diff --git a/src-tauri/src/memory/sanitize.rs b/src-tauri/src/memory/sanitize.rs new file mode 100644 index 0000000000..151d2b3b77 --- /dev/null +++ b/src-tauri/src/memory/sanitize.rs @@ -0,0 +1,96 @@ +/// Secret redaction before memory write. +use regex::Regex; + +/// Redact API keys, tokens, passwords, and private key material from text. +/// Returns (sanitized_text, number_of_replacements). +pub fn sanitize_secrets(text: &str) -> (String, usize) { + let mut result = text.to_string(); + let mut count = 0; + + // Patterns for common secret types. + let patterns = [ + // Standalone OpenAI/Anthropic keys (e.g. sk-..., sk-ant-..., sk-proj-...) + (r#"sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}"#, "AI API key"), + // Bearer tokens in headers or prose ("Authorization: Bearer ") + (r#"(?i)bearer\s+[A-Za-z0-9\-._~+/]{8,}=*"#, "bearer token"), + // API keys: "api_key=..." or "apiKey: ..." or standalone hex keys + ( + r#"(?i)(api[_-]?key|api[_-]?token)\s*[:=]\s*['\"]?[\w\-]+['\"]?"#, + "api key", + ), + // Tokens: "token: ..." or Bearer/AWS/GitHub token patterns + ( + r#"(?i)(token|access[_-]?token|refresh[_-]?token)\s*[:=]\s*['\"]?[\w\-./+]+['\"]?"#, + "token", + ), + // AWS keys + ( + r#"(?i)(aws[_-]?access|aws[_-]?secret|AKIA[0-9A-Z]{16})"#, + "AWS key", + ), + // Passwords in URLs or assignments + ( + r#"(?i)(password|passwd)\s*[:=]\s*['\"]?[^\s'\"]+['\"]?"#, + "password", + ), + // Private keys (basic pattern) + ( + r#"-----BEGIN[\s\w-]+PRIVATE[\s\w-]+-----[\s\S]*?-----END[\s\w-]+PRIVATE[\s\w-]+-----"#, + "private key", + ), + // GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) + (r#"gh[pousr]_[A-Za-z0-9_]{20,255}"#, "GitHub token"), + // Slack token + ( + r#"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[^\s]+['\"]?"#, + "Slack token", + ), + ]; + + for (pattern_str, _name) in &patterns { + if let Ok(re) = Regex::new(pattern_str) { + let new_result = re.replace_all(&result, "[REDACTED]").to_string(); + count += re.find_iter(&result).count(); + result = new_result; + } + } + + (result, count) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_api_keys() { + let (result, count) = sanitize_secrets("Use api_key=my-secret-key123"); + assert!(result.contains("[REDACTED]")); + assert!(count > 0); + } + + #[test] + fn redacts_bearer_tokens() { + let (result, count) = sanitize_secrets("auth: Bearer sk-12345abcde67890"); + assert!(result.contains("[REDACTED]")); + assert!(count > 0); + } + + #[test] + fn redacts_passwords() { + let (result, count) = sanitize_secrets("password=supersecret123"); + assert!(result.contains("[REDACTED]")); + assert!(count > 0); + } + + #[test] + fn redacts_standalone_sk_and_ghp_tokens() { + let (res1, c1) = sanitize_secrets("sk-123456789012345678901234567890"); + assert_eq!(res1, "[REDACTED]"); + assert!(c1 > 0); + + let (res2, c2) = sanitize_secrets("ghp_123456789012345678901234567890123456"); + assert_eq!(res2, "[REDACTED]"); + assert!(c2 > 0); + } +} diff --git a/src-tauri/src/memory/sqlite.rs b/src-tauri/src/memory/sqlite.rs new file mode 100644 index 0000000000..85c3070349 --- /dev/null +++ b/src-tauri/src/memory/sqlite.rs @@ -0,0 +1,489 @@ +/// LocalSqlite backend: FTS5 search + edge graph expansion. +use async_trait::async_trait; +use chrono::Utc; +use rusqlite::{params, Connection, OptionalExtension}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Mutex; + +use super::backend::{ + MemoryBackend, MemoryError, MemoryHit, MemoryNode, MemoryProvenance, NewMemoryNode, +}; +use crate::models::{MemoryRel, MemoryScope}; + +/// SQLite-backed memory graph using FTS5 for full-text search. +pub struct LocalSqliteBackend { + #[allow(dead_code)] + db_path: PathBuf, + conn: Mutex, +} + +fn format_fts5_query(q: &str) -> String { + let tokens: Vec = q + .split_whitespace() + .map(|s| s.trim_matches(|c: char| !c.is_alphanumeric() && c != '_')) + .filter(|s| !s.is_empty()) + .map(|s| format!("\"{}\"*", s.replace('"', "\"\""))) + .collect(); + if tokens.is_empty() { + String::new() + } else { + tokens.join(" OR ") + } +} + +fn node_matches_scope(node: &MemoryNode, scope: MemoryScope, folder_id: Option) -> bool { + if node.scope != scope { + return false; + } + if scope == MemoryScope::Project { + if let Some(fid) = folder_id { + if node.folder_id != Some(fid) { + return false; + } + } + } + true +} + +impl LocalSqliteBackend { + /// Open or create the memory database at the given path. + pub fn new(db_path: PathBuf) -> Result { + if let Some(parent) = db_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(&db_path) + .map_err(|e| MemoryError(format!("Cannot open memory.db: {}", e)))?; + + let backend = Self { + db_path, + conn: Mutex::new(conn), + }; + + backend.init_schema()?; + Ok(backend) + } + + fn init_schema(&self) -> Result<(), MemoryError> { + let conn = self + .conn + .lock() + .map_err(|_| MemoryError("lock poisoned".to_string()))?; + + // Enable FTS5 and other extensions. + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;") + .map_err(|e| MemoryError(format!("pragma failed: {}", e)))?; + + // Main nodes table + conn.execute( + "CREATE TABLE IF NOT EXISTS mem_node ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + scope TEXT NOT NULL, -- 'project' | 'global' + folder_id INTEGER, + provenance TEXT NOT NULL, -- JSON + created_at TEXT NOT NULL, -- ISO 8601 UTC + updated_at TEXT NOT NULL, + stale_at TEXT + )", + [], + ) + .map_err(|e| MemoryError(format!("create mem_node failed: {}", e)))?; + + // Edges: relationships between nodes + conn.execute( + "CREATE TABLE IF NOT EXISTS mem_edge ( + from_id INTEGER NOT NULL, + to_id INTEGER NOT NULL, + rel TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (from_id, to_id, rel), + FOREIGN KEY (from_id) REFERENCES mem_node(id) ON DELETE CASCADE, + FOREIGN KEY (to_id) REFERENCES mem_node(id) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| MemoryError(format!("create mem_edge failed: {}", e)))?; + + // FTS5 table for full-text search + conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS mem_node_fts USING fts5( + title, body, + content='mem_node', + content_rowid='id' + )", + [], + ) + .map_err(|e| MemoryError(format!("create FTS5 failed: {}", e)))?; + + // Triggers to keep FTS5 in sync + conn.execute( + "CREATE TRIGGER IF NOT EXISTS mem_node_ai AFTER INSERT ON mem_node BEGIN + INSERT INTO mem_node_fts(rowid, title, body) VALUES (new.id, new.title, new.body); + END", + [], + ) + .map_err(|e| MemoryError(format!("create insert trigger failed: {}", e)))?; + + conn.execute( + "CREATE TRIGGER IF NOT EXISTS mem_node_ad AFTER DELETE ON mem_node BEGIN + DELETE FROM mem_node_fts WHERE rowid = old.id; + END", + [], + ) + .map_err(|e| MemoryError(format!("create delete trigger failed: {}", e)))?; + + conn.execute( + "CREATE TRIGGER IF NOT EXISTS mem_node_au AFTER UPDATE ON mem_node BEGIN + DELETE FROM mem_node_fts WHERE rowid = old.id; + INSERT INTO mem_node_fts(rowid, title, body) VALUES (new.id, new.title, new.body); + END", + [], + ) + .map_err(|e| MemoryError(format!("create update trigger failed: {}", e)))?; + + Ok(()) + } + + fn load_node(&self, row: &rusqlite::Row) -> Result { + let provenance_json: String = row.get(6)?; + let provenance: MemoryProvenance = + serde_json::from_str(&provenance_json).unwrap_or(MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "unknown".to_string(), + }); + + Ok(MemoryNode { + id: row.get(0)?, + kind: row.get(1)?, + title: row.get(2)?, + body: row.get(3)?, + scope: match row.get::<_, String>(4)?.as_str() { + "project" => MemoryScope::Project, + _ => MemoryScope::Global, + }, + folder_id: row.get(5)?, + provenance, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now), + updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(8)?) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now), + stale_at: row.get::<_, Option>(9)?.and_then(|s| { + chrono::DateTime::parse_from_rfc3339(&s) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }), + }) + } +} + +#[async_trait] +impl MemoryBackend for LocalSqliteBackend { + async fn write(&self, node: NewMemoryNode) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| MemoryError("lock poisoned".to_string()))?; + let now = Utc::now().to_rfc3339(); + let provenance_json = serde_json::to_string(&node.provenance).unwrap_or_default(); + let scope_str = match node.scope { + MemoryScope::Project => "project", + MemoryScope::Global => "global", + }; + + // Deduplication: matching kind + title in the same scope and folder_id updates the existing node. + let existing_id: Option = conn.query_row( + "SELECT id FROM mem_node WHERE kind = ? AND title = ? AND scope = ? AND (? IS NULL OR folder_id = ?)", + params![&node.kind, &node.title, scope_str, node.folder_id, node.folder_id], + |row| row.get(0), + ).optional().map_err(|e| MemoryError(format!("dedup query failed: {}", e)))?; + + if let Some(id) = existing_id { + conn.execute( + "UPDATE mem_node SET body = ?, provenance = ?, updated_at = ? WHERE id = ?", + params![&node.body, &provenance_json, &now, id], + ) + .map_err(|e| MemoryError(format!("update failed: {}", e)))?; + return Ok(id); + } + + conn.execute( + "INSERT INTO mem_node (kind, title, body, scope, folder_id, provenance, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + params![ + &node.kind, + &node.title, + &node.body, + scope_str, + node.folder_id, + provenance_json, + &now, + &now, + ], + ).map_err(|e| MemoryError(format!("write failed: {}", e)))?; + + let id = conn.last_insert_rowid() as i32; + Ok(id) + } + + async fn search( + &self, + q: &str, + scope: MemoryScope, + folder_id: Option, + limit: usize, + ) -> Result, MemoryError> { + let limit = limit.min(50); + let conn = self + .conn + .lock() + .map_err(|_| MemoryError("lock poisoned".to_string()))?; + + let scope_str = match scope { + MemoryScope::Project => "project", + MemoryScope::Global => "global", + }; + + let fts_query = format_fts5_query(q); + let mut hits: Vec = Vec::new(); + let mut visited_ids: HashSet = HashSet::new(); + + if !fts_query.is_empty() { + // FTS5 search with BM25 scoring + let mut stmt = conn.prepare( + "SELECT mem_node.id, mem_node.kind, mem_node.title, mem_node.body, + mem_node.scope, mem_node.folder_id, mem_node.provenance, + mem_node.created_at, mem_node.updated_at, mem_node.stale_at, + bm25(mem_node_fts) as score + FROM mem_node_fts + JOIN mem_node ON mem_node_fts.rowid = mem_node.id + WHERE mem_node_fts MATCH ? AND mem_node.scope = ? AND (? IS NULL OR mem_node.folder_id = ?) + ORDER BY score ASC + LIMIT ?" + ).map_err(|e| MemoryError(format!("prepare search failed: {}", e)))?; + + let direct_results = stmt + .query_map( + params![&fts_query, scope_str, folder_id, folder_id, limit], + |row| { + let node = self.load_node(row)?; + let raw_score: f64 = row.get(10)?; + let normalized_score = 1.0 / (1.0 + raw_score.abs()); + Ok(MemoryHit { + node, + score: normalized_score, + via: vec![], + }) + }, + ) + .map_err(|e| MemoryError(format!("query search failed: {}", e)))? + .collect::, _>>() + .map_err(|e| MemoryError(format!("collect search failed: {}", e)))?; + + for hit in direct_results { + visited_ids.insert(hit.node.id); + hits.push(hit); + } + } + + // If FTS5 yielded no results (e.g. substring/LIKE query fallback) + if hits.is_empty() { + let like_pattern = format!("%{}%", q.trim()); + let mut stmt = conn.prepare( + "SELECT id, kind, title, body, scope, folder_id, provenance, created_at, updated_at, stale_at + FROM mem_node + WHERE scope = ? AND (? IS NULL OR folder_id = ?) + AND (title LIKE ? OR body LIKE ?) + ORDER BY updated_at DESC + LIMIT ?" + ).map_err(|e| MemoryError(format!("prepare fallback search failed: {}", e)))?; + + let fallback_results = stmt + .query_map( + params![ + scope_str, + folder_id, + folder_id, + &like_pattern, + &like_pattern, + limit + ], + |row| { + let node = self.load_node(row)?; + Ok(MemoryHit { + node, + score: 1.0, + via: vec![], + }) + }, + ) + .map_err(|e| MemoryError(format!("query fallback search failed: {}", e)))? + .collect::, _>>() + .map_err(|e| MemoryError(format!("collect fallback search failed: {}", e)))?; + + for hit in fallback_results { + visited_ids.insert(hit.node.id); + hits.push(hit); + } + } + + // 2-hop graph expansion along mem_edge + let direct_node_ids: Vec<(i32, f64)> = hits.iter().map(|h| (h.node.id, h.score)).collect(); + let mut hop1_nodes: Vec<(i32, Vec, f64)> = Vec::new(); + + for (direct_id, base_score) in direct_node_ids { + let mut edge_stmt = conn + .prepare("SELECT from_id, to_id FROM mem_edge WHERE from_id = ? OR to_id = ?") + .map_err(|e| MemoryError(format!("prepare edge search failed: {}", e)))?; + + let neighbors: Vec = edge_stmt + .query_map(params![direct_id, direct_id], |row| { + let from: i32 = row.get(0)?; + let to: i32 = row.get(1)?; + Ok(if from == direct_id { to } else { from }) + }) + .map_err(|e| MemoryError(format!("query edges failed: {}", e)))? + .collect::, _>>() + .map_err(|e| MemoryError(format!("collect edges failed: {}", e)))?; + + for neighbor_id in neighbors { + if !visited_ids.contains(&neighbor_id) { + visited_ids.insert(neighbor_id); + if let Ok(Some(node)) = conn.query_row( + "SELECT id, kind, title, body, scope, folder_id, provenance, created_at, updated_at, stale_at FROM mem_node WHERE id = ?", + params![neighbor_id], + |row| self.load_node(row), + ).optional() { + if node_matches_scope(&node, scope, folder_id) { + let score = base_score * 0.5; + let path = vec![direct_id]; + hop1_nodes.push((neighbor_id, path.clone(), score)); + hits.push(MemoryHit { + node, + score, + via: path, + }); + } + } + } + } + } + + // Hop 2 + for (hop1_id, path_to_hop1, base_score) in hop1_nodes { + let mut edge_stmt = conn + .prepare("SELECT from_id, to_id FROM mem_edge WHERE from_id = ? OR to_id = ?") + .map_err(|e| MemoryError(format!("prepare hop2 edge search failed: {}", e)))?; + + let neighbors: Vec = edge_stmt + .query_map(params![hop1_id, hop1_id], |row| { + let from: i32 = row.get(0)?; + let to: i32 = row.get(1)?; + Ok(if from == hop1_id { to } else { from }) + }) + .map_err(|e| MemoryError(format!("query hop2 edges failed: {}", e)))? + .collect::, _>>() + .map_err(|e| MemoryError(format!("collect hop2 edges failed: {}", e)))?; + + for neighbor_id in neighbors { + if !visited_ids.contains(&neighbor_id) { + visited_ids.insert(neighbor_id); + if let Ok(Some(node)) = conn.query_row( + "SELECT id, kind, title, body, scope, folder_id, provenance, created_at, updated_at, stale_at FROM mem_node WHERE id = ?", + params![neighbor_id], + |row| self.load_node(row), + ).optional() { + if node_matches_scope(&node, scope, folder_id) { + let score = base_score * 0.5; + let mut path = path_to_hop1.clone(); + path.push(hop1_id); + hits.push(MemoryHit { + node, + score, + via: path, + }); + } + } + } + } + } + + // Limit results to ≤ 8000 characters and limit count ≤ 50 + let mut total_chars = 0; + let mut final_hits = Vec::new(); + for hit in hits { + let char_len = hit.node.title.len() + hit.node.body.len(); + if total_chars + char_len > 8000 && !final_hits.is_empty() { + break; + } + total_chars += char_len; + final_hits.push(hit); + if final_hits.len() >= limit { + break; + } + } + + Ok(final_hits) + } + + async fn link(&self, from: i32, to: i32, rel: MemoryRel) -> Result<(), MemoryError> { + let conn = self + .conn + .lock() + .map_err(|_| MemoryError("lock poisoned".to_string()))?; + let now = Utc::now().to_rfc3339(); + let rel_str = match rel { + MemoryRel::CausedBy => "caused_by", + MemoryRel::FixedBy => "fixed_by", + MemoryRel::RelatesTo => "relates_to", + MemoryRel::PartOf => "part_of", + MemoryRel::Supersedes => "supersedes", + }; + + conn.execute( + "INSERT OR IGNORE INTO mem_edge (from_id, to_id, rel, created_at) VALUES (?, ?, ?, ?)", + params![from, to, rel_str, &now], + ) + .map_err(|e| MemoryError(format!("link failed: {}", e)))?; + + Ok(()) + } + + async fn get(&self, id: i32) -> Result, MemoryError> { + let conn = self + .conn + .lock() + .map_err(|_| MemoryError("lock poisoned".to_string()))?; + + let node = conn.query_row( + "SELECT id, kind, title, body, scope, folder_id, provenance, created_at, updated_at, stale_at + FROM mem_node WHERE id = ?", + params![id], + |row| self.load_node(row), + ).optional() + .map_err(|e| MemoryError(format!("get failed: {}", e)))?; + + Ok(node) + } + + async fn delete(&self, id: i32) -> Result<(), MemoryError> { + let conn = self + .conn + .lock() + .map_err(|_| MemoryError("lock poisoned".to_string()))?; + + conn.execute("DELETE FROM mem_node WHERE id = ?", params![id]) + .map_err(|e| MemoryError(format!("delete failed: {}", e)))?; + + Ok(()) + } +} diff --git a/src-tauri/src/memory/tests.rs b/src-tauri/src/memory/tests.rs new file mode 100644 index 0000000000..85b4a9f35e --- /dev/null +++ b/src-tauri/src/memory/tests.rs @@ -0,0 +1,476 @@ +/// Memory backend and integration tests. +#[cfg(test)] +mod sqlite_tests { + use crate::memory::backend::*; + use crate::memory::sqlite::LocalSqliteBackend; + use crate::models::{MemoryRel, MemoryScope}; + use tempfile::TempDir; + + #[tokio::test] + async fn write_and_get_node() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("memory.db"); + let backend = LocalSqliteBackend::new(db_path).unwrap(); + + let node = NewMemoryNode { + kind: "decision".to_string(), + title: "Use async Rust".to_string(), + body: "Decided to use Tokio for async runtime".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + + let id = backend.write(node).await.unwrap(); + assert!(id > 0); + + let retrieved = backend.get(id).await.unwrap(); + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, id); + assert_eq!(retrieved.kind, "decision"); + assert_eq!(retrieved.title, "Use async Rust"); + } + + #[tokio::test] + async fn search_by_title_and_body() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("memory.db"); + let backend = LocalSqliteBackend::new(db_path).unwrap(); + + let node = NewMemoryNode { + kind: "fixed_bug".to_string(), + title: "Off-by-one in loop".to_string(), + body: "Fixed loop condition i < n instead of i <= n".to_string(), + scope: MemoryScope::Project, + folder_id: Some(42), + provenance: MemoryProvenance { + run_id: Some(1), + step_id: Some("coder".to_string()), + agent_type: Some("claude".to_string()), + verified_by_tests: true, + source: "auto".to_string(), + }, + }; + + backend.write(node).await.unwrap(); + let hits = backend + .search("loop", MemoryScope::Project, Some(42), 20) + .await + .unwrap(); + assert!(!hits.is_empty()); + assert!(hits[0].node.title.contains("loop") || hits[0].node.body.contains("loop")); + } + + #[tokio::test] + async fn search_expands_across_edges_up_to_2_hops() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("memory.db"); + let backend = LocalSqliteBackend::new(db_path).unwrap(); + + // Node 1: Contains the keyword "authentication" + let node1 = NewMemoryNode { + kind: "decision".to_string(), + title: "Authentication architecture".to_string(), + body: "We chose JWT bearer tokens for authentication".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + let id1 = backend.write(node1).await.unwrap(); + + // Node 2: Does NOT contain "authentication", linked to Node 1 (1 hop) + let node2 = NewMemoryNode { + kind: "decision".to_string(), + title: "Session storage in Redis".to_string(), + body: "Fast cache layer with TTL expiry".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + let id2 = backend.write(node2).await.unwrap(); + + // Node 3: Does NOT contain "authentication", linked to Node 2 (2 hops from Node 1) + let node3 = NewMemoryNode { + kind: "decision".to_string(), + title: "Connection pool sizing".to_string(), + body: "Max 50 active TCP sockets".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + let id3 = backend.write(node3).await.unwrap(); + + // Node 4: Linked to Node 3 (3 hops away - should not be included) + let node4 = NewMemoryNode { + kind: "preference".to_string(), + title: "Editor formatting".to_string(), + body: "2 spaces indent".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + let id4 = backend.write(node4).await.unwrap(); + + // Link 1 -> 2 -> 3 -> 4 + backend.link(id1, id2, MemoryRel::RelatesTo).await.unwrap(); + backend.link(id2, id3, MemoryRel::RelatesTo).await.unwrap(); + backend.link(id3, id4, MemoryRel::RelatesTo).await.unwrap(); + + // Search for "authentication" + let hits = backend + .search("authentication", MemoryScope::Global, None, 50) + .await + .unwrap(); + + // Hit 1: Direct match (via = []) + let hit1 = hits.iter().find(|h| h.node.id == id1); + assert!(hit1.is_some(), "Direct hit id1 should be found"); + assert_eq!(hit1.unwrap().via, Vec::::new()); + + // Hit 2: 1-hop expansion (via = [id1]) + let hit2 = hits.iter().find(|h| h.node.id == id2); + assert!(hit2.is_some(), "1-hop linked node id2 should be found"); + assert_eq!(hit2.unwrap().via, vec![id1]); + + // Hit 3: 2-hop expansion (via = [id1, id2]) + let hit3 = hits.iter().find(|h| h.node.id == id3); + assert!(hit3.is_some(), "2-hop linked node id3 should be found"); + assert_eq!(hit3.unwrap().via, vec![id1, id2]); + + // Hit 4: 3 hops away should NOT be included + let hit4 = hits.iter().find(|h| h.node.id == id4); + assert!(hit4.is_none(), "3-hop node id4 should NOT be found"); + } + + #[tokio::test] + async fn link_nodes() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("memory.db"); + let backend = LocalSqliteBackend::new(db_path).unwrap(); + + let node1 = NewMemoryNode { + kind: "decision".to_string(), + title: "First decision".to_string(), + body: "The first thing we decided".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + + let node2 = NewMemoryNode { + kind: "decision".to_string(), + title: "Second decision".to_string(), + body: "Based on the first decision".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + + let id1 = backend.write(node1).await.unwrap(); + let id2 = backend.write(node2).await.unwrap(); + + backend.link(id2, id1, MemoryRel::CausedBy).await.unwrap(); + + let retrieved = backend.get(id2).await.unwrap().unwrap(); + assert_eq!(retrieved.title, "Second decision"); + } + + #[tokio::test] + async fn delete_node() { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("memory.db"); + let backend = LocalSqliteBackend::new(db_path).unwrap(); + + let node = NewMemoryNode { + kind: "preference".to_string(), + title: "Old preference".to_string(), + body: "No longer relevant".to_string(), + scope: MemoryScope::Global, + folder_id: None, + provenance: MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: "user".to_string(), + }, + }; + + let id = backend.write(node).await.unwrap(); + assert!(backend.get(id).await.unwrap().is_some()); + + backend.delete(id).await.unwrap(); + assert!(backend.get(id).await.unwrap().is_none()); + } +} + +#[cfg(test)] +mod tools_access_tests { + use std::sync::Arc; + use tempfile::TempDir; + + use crate::acp::memory_tools::MemoryToolAccess; + use crate::db::service::memory_kind_service; + use crate::db::test_helpers::fresh_in_memory_db; + use crate::memory::backend::MemoryBackend; + use crate::memory::sqlite::LocalSqliteBackend; + use crate::memory::tools::{MemoryBackendAccess, MemoryWriteRequest}; + use crate::models::{ + MemoryBackendKind, MemoryKindDraft, MemoryMode, MemoryScope, MemorySettings, + }; + + #[tokio::test] + async fn on_request_rejected_without_flag_and_accepted_with_flag() { + let db = fresh_in_memory_db().await; + let dir = TempDir::new().unwrap(); + let sqlite = Arc::new(LocalSqliteBackend::new(dir.path().join("mem.db")).unwrap()); + + let settings = MemorySettings { + backend: MemoryBackendKind::LocalSqlite, + scope: MemoryScope::Global, + external: None, + }; + + let access = + MemoryBackendAccess::with_db_and_backend(db.clone(), Some(sqlite), Some(settings)); + + // Create an on_request kind + let kind = memory_kind_service::create( + &db.conn, + MemoryKindDraft { + name: "api_quirk".into(), + instruction: "Record API quirks".into(), + mode: MemoryMode::OnRequest, + }, + ) + .await + .unwrap(); + + // 1. Calling write_entry without user_requested flag (user_requested = false) -> rejected + let req_without_flag = MemoryWriteRequest { + parent_connection_id: "conn-1", + kind: &kind.key, + title: "Stripe webhook quirk", + body: "Signature header casing issue", + links: &[], + user_requested: false, + provenance: None, + folder_id: None, + }; + let ack1 = access.write_entry(req_without_flag).await; + assert!(!ack1.ok, "on_request kind without flag must be rejected"); + assert!(ack1.note.unwrap().contains("on_request")); + + // Also test MCP write tool call (which is always user_requested = false) + let mcp_ack = access + .write( + "conn-1", + &kind.key, + "Stripe webhook quirk", + "Details", + &[], + false, + ) + .await; + assert!( + !mcp_ack.ok, + "MCP tool write on on_request kind must be rejected" + ); + + // 2. Calling write_entry with user_requested = true -> accepted + let req_with_flag = MemoryWriteRequest { + parent_connection_id: "conn-1", + kind: &kind.key, + title: "Stripe webhook quirk", + body: "Signature header casing issue", + links: &[], + user_requested: true, + provenance: None, + folder_id: None, + }; + let ack2 = access.write_entry(req_with_flag).await; + assert!( + ack2.ok, + "on_request kind with user_requested flag must be accepted" + ); + assert!(ack2.id.is_some()); + } + + #[tokio::test] + async fn secrets_are_sanitized_before_write() { + let db = fresh_in_memory_db().await; + let dir = TempDir::new().unwrap(); + let sqlite = Arc::new(LocalSqliteBackend::new(dir.path().join("mem.db")).unwrap()); + + let settings = MemorySettings { + backend: MemoryBackendKind::LocalSqlite, + scope: MemoryScope::Global, + external: None, + }; + + let access = MemoryBackendAccess::with_db_and_backend( + db.clone(), + Some(sqlite.clone()), + Some(settings), + ); + + let req = MemoryWriteRequest { + parent_connection_id: "conn-1", + kind: "decision", + title: "Use OpenAI API", + body: "Configure with sk-abcdef12345678901234567890 and ghp_abcdef12345678901234567890123456", + links: &[], + user_requested: true, + provenance: None, + folder_id: None, + }; + + let ack = access.write_entry(req).await; + assert!(ack.ok); + let id = ack.id.unwrap(); + + let saved = sqlite.get(id).await.unwrap().unwrap(); + assert!(!saved.body.contains("sk-abcdef12345678901234567890")); + assert!(!saved.body.contains("ghp_abcdef12345678901234567890123456")); + assert!(saved.body.contains("[REDACTED]")); + } + + #[tokio::test] + async fn backend_off_rejects_writes() { + let db = fresh_in_memory_db().await; + let settings = MemorySettings { + backend: MemoryBackendKind::Off, + scope: MemoryScope::Global, + external: None, + }; + + let access = MemoryBackendAccess::with_db_and_backend(db, None, Some(settings)); + + let ack = access + .write("conn-1", "decision", "Title", "Body", &[], false) + .await; + assert!(!ack.ok); + assert!(ack.note.unwrap().contains("off")); + } + + #[tokio::test] + async fn builtin_kind_cannot_be_deleted() { + let db = fresh_in_memory_db().await; + let kinds = memory_kind_service::list(&db.conn).await.unwrap(); + let builtin = kinds.iter().find(|k| k.builtin).expect("seeded builtin"); + + let res = memory_kind_service::delete(&db.conn, builtin.id).await; + assert!(res.is_err()); + } + + #[tokio::test] + async fn custom_kind_appears_in_memory_write_schema() { + use crate::acp::delegation::companion::{ + update_memory_write_schema, MemoryKindSpec, TOOL_SCHEMA_JSON, + }; + use serde_json::Value; + + let mut tools: Value = serde_json::from_str(TOOL_SCHEMA_JSON).unwrap(); + let kinds = vec![MemoryKindSpec { + key: "custom_api_quirk".into(), + name: "API Quirks".into(), + instruction: "Record third party quirks".into(), + mode: "auto".into(), + }]; + + update_memory_write_schema(&mut tools, &kinds); + + let arr = tools.as_array().unwrap(); + let tool = arr.iter().find(|t| t["name"] == "memory_write").unwrap(); + let desc = tool["description"].as_str().unwrap(); + assert!(desc.contains("- custom_api_quirk: Record third party quirks (auto)")); + + let enum_arr = tool["inputSchema"]["properties"]["kind"]["enum"] + .as_array() + .unwrap(); + assert_eq!(enum_arr, &vec![Value::String("custom_api_quirk".into())]); + } +} + +#[cfg(test)] +mod sanitize_tests { + use crate::memory::sanitize::sanitize_secrets; + + #[test] + fn test_sanitize_api_key() { + let (result, count) = sanitize_secrets("api_key=sk-12345abcde6789012345"); + assert!(result.contains("[REDACTED]")); + assert!(count > 0); + } + + #[test] + fn test_sanitize_standalone_sk_and_ghp() { + let (r1, c1) = + sanitize_secrets("OpenAI key is sk-123456789012345678901234567890 in config"); + assert!(r1.contains("[REDACTED]")); + assert_eq!(c1, 1); + + let (r2, c2) = + sanitize_secrets("GitHub token is ghp_123456789012345678901234567890123456 for CI"); + assert!(r2.contains("[REDACTED]")); + assert_eq!(c2, 1); + } + + #[test] + fn test_sanitize_password_url() { + let (result, count) = sanitize_secrets("password=mysecretpass123"); + assert!(result.contains("[REDACTED]")); + assert_eq!(count, 1); + } + + #[test] + fn test_no_false_positives() { + let text = "We use password-based auth in production"; + let (result, count) = sanitize_secrets(text); + assert_eq!(count, 0); + assert_eq!(result, text); + } +} diff --git a/src-tauri/src/memory/tools.rs b/src-tauri/src/memory/tools.rs new file mode 100644 index 0000000000..997f47b570 --- /dev/null +++ b/src-tauri/src/memory/tools.rs @@ -0,0 +1,264 @@ +use async_trait::async_trait; +/// MemoryToolAccess: bridges listener memory tool calls to the backend. +/// Implements access control: backend Off -> reject; kind disabled -> reject; +/// on_request -> requires user_requested flag; sanitizes secrets; persists provenance. +use std::sync::Arc; + +use super::backend::{MemoryBackend, MemoryProvenance, NewMemoryNode}; +use super::sanitize::sanitize_secrets; +use crate::acp::memory_tools::{MemoryToolAccess, MemoryToolAck, MemoryToolHit}; +use crate::db::service::memory_kind_service; +use crate::db::AppDatabase; +use crate::models::{MemoryBackendKind, MemoryMode, MemoryRel, MemoryScope, MemorySettings}; + +/// Request to write a memory entry with full context (provenance, user_requested flag, folder_id). +#[derive(Debug, Clone)] +pub struct MemoryWriteRequest<'a> { + pub parent_connection_id: &'a str, + pub kind: &'a str, + pub title: &'a str, + pub body: &'a str, + pub links: &'a [(i32, String)], + pub user_requested: bool, + pub provenance: Option, + pub folder_id: Option, +} + +/// Pluggable access layer that wraps a backend and enforces access control and rules. +pub struct MemoryBackendAccess { + db: Option, + backend: Option>, + settings: Option, +} + +impl MemoryBackendAccess { + pub fn new(backend: Box) -> Self { + Self { + db: None, + backend: Some(Arc::from(backend)), + settings: None, + } + } + + pub fn with_arc(backend: Arc) -> Self { + Self { + db: None, + backend: Some(backend), + settings: None, + } + } + + pub fn with_db_and_backend( + db: AppDatabase, + backend: Option>, + settings: Option, + ) -> Self { + Self { + db: Some(db), + backend, + settings, + } + } + + /// Core entry point for writing memory entries with access control, validation, and secret sanitization. + pub async fn write_entry(&self, req: MemoryWriteRequest<'_>) -> MemoryToolAck { + // 1. Check if backend is configured and not Off + if let Some(settings) = &self.settings { + if settings.backend == MemoryBackendKind::Off { + return MemoryToolAck::rejected("memory backend is off"); + } + } + let Some(backend) = &self.backend else { + return MemoryToolAck::rejected("memory backend is off"); + }; + + // 2. Validate memory kind mode and enabled status + if let Some(db) = &self.db { + let kinds = match memory_kind_service::list(&db.conn).await { + Ok(k) => k, + Err(e) => { + return MemoryToolAck::rejected(&format!( + "db error querying memory kinds: {}", + e + )) + } + }; + + let matched = kinds.into_iter().find(|k| k.key == req.kind); + let Some(kind_info) = matched else { + return MemoryToolAck::rejected(&format!( + "memory kind '{}' is not registered", + req.kind + )); + }; + + if !kind_info.enabled || kind_info.mode == MemoryMode::Off { + return MemoryToolAck::rejected(&format!("memory kind '{}' is disabled", req.kind)); + } + + if kind_info.mode == MemoryMode::OnRequest && !req.user_requested { + return MemoryToolAck::rejected(&format!( + "memory kind '{}' is on_request; user request flag is required", + req.kind + )); + } + } else { + // Default built-in kinds behavior when DB is not injected (e.g. unit tests with mock backend) + let on_request_kinds = ["task_summary", "preference"]; + let off_kinds = ["disabled_kind"]; + if off_kinds.contains(&req.kind) { + return MemoryToolAck::rejected(&format!("memory kind '{}' is disabled", req.kind)); + } + if on_request_kinds.contains(&req.kind) && !req.user_requested { + return MemoryToolAck::rejected(&format!( + "memory kind '{}' is on_request; user request flag is required", + req.kind + )); + } + } + + // 3. Sanitize secrets from title and body + let (clean_title, _) = sanitize_secrets(req.title); + let (clean_body, _) = sanitize_secrets(req.body); + + // 4. Resolve scope & folder_id + let scope = self + .settings + .as_ref() + .map(|s| s.scope) + .unwrap_or(MemoryScope::Global); + let folder_id = if scope == MemoryScope::Project { + req.folder_id + } else { + None + }; + + // 5. Construct provenance + let provenance = req.provenance.unwrap_or_else(|| MemoryProvenance { + run_id: None, + step_id: None, + agent_type: None, + verified_by_tests: false, + source: if req.user_requested { + "user".to_string() + } else { + "agent".to_string() + }, + }); + + // 6. Write to backend + let new_node = NewMemoryNode { + kind: req.kind.to_string(), + title: clean_title, + body: clean_body, + scope, + folder_id, + provenance, + }; + + let node_id = match backend.write(new_node).await { + Ok(id) => id, + Err(e) => return MemoryToolAck::rejected(&e.0), + }; + + // 7. Process any links + for (to_id, rel_str) in req.links { + let rel = match rel_str.as_str() { + "caused_by" => MemoryRel::CausedBy, + "fixed_by" => MemoryRel::FixedBy, + "relates_to" => MemoryRel::RelatesTo, + "part_of" => MemoryRel::PartOf, + "supersedes" => MemoryRel::Supersedes, + _ => continue, + }; + let _ = backend.link(node_id, *to_id, rel).await; + } + + MemoryToolAck::written(node_id) + } +} + +#[async_trait] +impl MemoryToolAccess for MemoryBackendAccess { + async fn write( + &self, + parent_connection_id: &str, + kind: &str, + title: &str, + body: &str, + links: &[(i32, String)], + user_requested: bool, + ) -> MemoryToolAck { + self.write_entry(MemoryWriteRequest { + parent_connection_id, + kind, + title, + body, + links, + user_requested, + provenance: None, + folder_id: None, + }) + .await + } + + async fn search( + &self, + _parent_connection_id: &str, + query: &str, + limit: usize, + ) -> Result, String> { + let Some(backend) = &self.backend else { + return Ok(vec![]); + }; + let scope = self + .settings + .as_ref() + .map(|s| s.scope) + .unwrap_or(MemoryScope::Global); + + let hits = backend + .search(query, scope, None, limit.min(50)) + .await + .map_err(|e| e.0)?; + + let tool_hits = hits + .into_iter() + .map(|hit| MemoryToolHit { + id: hit.node.id, + kind: hit.node.kind, + title: hit.node.title, + body: hit.node.body, + score: hit.score, + }) + .collect(); + + Ok(tool_hits) + } + + async fn link( + &self, + _parent_connection_id: &str, + from_id: i32, + to_id: i32, + rel: &str, + ) -> MemoryToolAck { + let Some(backend) = &self.backend else { + return MemoryToolAck::rejected("memory backend is off"); + }; + + let rel = match rel { + "caused_by" => MemoryRel::CausedBy, + "fixed_by" => MemoryRel::FixedBy, + "relates_to" => MemoryRel::RelatesTo, + "part_of" => MemoryRel::PartOf, + "supersedes" => MemoryRel::Supersedes, + _ => return MemoryToolAck::rejected("unknown relationship type"), + }; + + match backend.link(from_id, to_id, rel).await { + Ok(_) => MemoryToolAck::linked(), + Err(e) => MemoryToolAck::rejected(&e.0), + } + } +} diff --git a/src-tauri/src/models/automation.rs b/src-tauri/src/models/automation.rs index a5b4d308cb..763901321e 100644 --- a/src-tauri/src/models/automation.rs +++ b/src-tauri/src/models/automation.rs @@ -79,6 +79,9 @@ pub enum AutomationAction { /// Enqueue a work task (status todo) on the target folder's board; the /// work-task engine owns the actual execution. EnqueueTask, + /// Start a saved agent pipeline on the target folder; the pipeline engine + /// owns the steps, verdicts and fix rounds. + RunPipeline, } /// The structured shape stored inside `automation.config`. Kept tolerant @@ -89,6 +92,9 @@ pub enum AutomationAction { pub struct AutomationConfig { #[serde(default)] pub action: AutomationAction, + /// Saved pipeline started by a `RunPipeline` automation. + #[serde(default)] + pub pipeline_id: Option, #[serde(default)] pub prompt_blocks: Vec, #[serde(default)] diff --git a/src-tauri/src/models/canvas.rs b/src-tauri/src/models/canvas.rs index b8ec120932..dafa63b02a 100644 --- a/src-tauri/src/models/canvas.rs +++ b/src-tauri/src/models/canvas.rs @@ -13,6 +13,7 @@ pub struct CanvasNode { pub id: i32, pub kind: CanvasNodeKind, pub folder_id: Option, + pub pipeline_id: Option, pub folder_group_id: Option, pub agent_type: Option, pub conversation_id: Option, @@ -42,6 +43,7 @@ impl From for CanvasNode { id: m.id, kind: m.kind, folder_id: m.folder_id, + pipeline_id: m.pipeline_id, folder_group_id: m.folder_group_id, agent_type: m.agent_type, conversation_id: m.conversation_id, diff --git a/src-tauri/src/models/memory.rs b/src-tauri/src/models/memory.rs new file mode 100644 index 0000000000..930c171bf7 --- /dev/null +++ b/src-tauri/src/models/memory.rs @@ -0,0 +1,70 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryBackendKind { + Off, + LocalSqlite, + ExternalMcp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryMode { + Auto, + OnRequest, + Off, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryScope { + Project, + Global, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryRel { + CausedBy, + FixedBy, + RelatesTo, + PartOf, + Supersedes, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalMcpMapping { + pub server_id: String, + pub write_tool: String, + pub search_tool: String, + pub link_tool: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemorySettings { + pub backend: MemoryBackendKind, + pub scope: MemoryScope, + pub external: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MemoryKind { + pub id: i32, + pub key: String, + pub name: String, + pub instruction: String, + pub mode: MemoryMode, + pub builtin: bool, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MemoryKindDraft { + pub name: String, + pub instruction: String, + pub mode: MemoryMode, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index f27de24bf9..5619faf38c 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -5,6 +5,7 @@ pub mod canvas; pub mod chat_channel; pub mod conversation; pub mod folder; +pub mod memory; pub mod message; pub mod model_provider; pub mod pet; @@ -14,6 +15,8 @@ pub mod system; pub mod token_usage; pub mod work_task; +pub mod pipeline; + pub use agent::AgentType; pub use automation::{ AutomationAction, AutomationConfig, AutomationDraft, AutomationInfo, AutomationRunInfo, @@ -32,10 +35,15 @@ pub use folder::{ FolderCommandInfo, FolderDetail, FolderGroupDetail, FolderHistoryEntry, OpenedTab, OpenedTabsSnapshot, SaveTabsOutcome, SidebarEntryKind, SidebarLayoutEntry, }; +pub use memory::{ + ExternalMcpMapping, MemoryBackendKind, MemoryKind, MemoryKindDraft, MemoryMode, MemoryRel, + MemoryScope, MemorySettings, +}; pub use message::{ AgentExecutionStats, AgentToolCall, ContentBlock, ImageData, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage, }; +pub use pipeline::*; pub use quick_message::QuickMessageInfo; pub use remote_workspace_connection::{ RemoteWorkspaceConnectionInfo, RemoteWorkspaceHeader, ToHeaderMap, diff --git a/src-tauri/src/models/pipeline.rs b/src-tauri/src/models/pipeline.rs new file mode 100644 index 0000000000..346a59ccda --- /dev/null +++ b/src-tauri/src/models/pipeline.rs @@ -0,0 +1,208 @@ +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +pub use crate::db::entities::automation::IsolationMode as PipelineIsolation; + +fn default_step_timeout() -> u64 { + 1800 +} + +fn default_max_iterations() -> u32 { + 3 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PipelineRole { + Planner, + Coder, + Reviewer, + Tests, + Custom, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineStep { + pub id: String, + pub role: PipelineRole, + pub label: String, + pub agent_type: String, + #[serde(default)] + pub mode_id: Option, + #[serde(default)] + pub config_values: BTreeMap, + pub prompt_template: String, + #[serde(default = "default_step_timeout")] + pub timeout_secs: u64, + #[serde(default)] + pub read_memory: bool, + #[serde(default)] + pub read_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoopBack { + pub from_step: String, + pub to_step: String, + #[serde(default = "default_max_iterations")] + pub max_iterations: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PipelineGraph { + pub steps: Vec, + #[serde(default)] + pub loops: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PipelineInfo { + pub id: i32, + pub name: String, + pub preset_key: Option, + pub folder_id: Option, + pub graph: PipelineGraph, + pub isolation: PipelineIsolation, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PipelineDraft { + pub name: String, + pub folder_id: Option, + pub graph: PipelineGraph, + pub isolation: PipelineIsolation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PipelineRunStatus { + Running, + Succeeded, + Failed, + Cancelled, + Interrupted, + StoppedMaxIterations, + Inconclusive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PipelineVerdict { + Pass, + ChangesRequested, + Inconclusive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttemptStatus { + Running, + Done, + Cancelled, + TimedOut, + Failed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PipelineAttemptInfo { + pub id: i32, + pub run_id: i32, + pub step_id: String, + /// Monotonically increasing across the whole run (not reset per step): + /// a loop-back to an earlier step continues the same counter rather + /// than restarting it. + pub iteration: u32, + pub status: AttemptStatus, + pub conversation_id: Option, + pub model_requested: Option, + pub model_actual: Option, + pub verdict: Option, + pub verdict_source: Option, + pub notes: Option, + pub summary: Option, + pub started_at: DateTime, + pub ended_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PipelineRunInfo { + pub id: i32, + pub pipeline_id: Option, + pub folder_id: i32, + pub worktree_folder_id: Option, + pub parent_conversation_id: Option, + pub graph: PipelineGraph, + pub status: PipelineRunStatus, + pub current_step_id: Option, + pub current_iteration: u32, + pub error: Option, + pub attempts: Vec, + pub started_at: DateTime, + pub ended_at: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineRunRequest { + pub folder_id: i32, + pub pipeline_id: Option, + pub graph: Option, + pub isolation: Option, + pub prompt_blocks: Vec, + pub display_text: String, + pub parent_conversation_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pipeline_graph_roundtrips_through_serde() { + let graph = PipelineGraph { + steps: vec![PipelineStep { + id: "coder".into(), + role: PipelineRole::Coder, + label: "Coder".into(), + agent_type: "claude_code".into(), + mode_id: Some("plan".into()), + config_values: BTreeMap::from([("model".into(), "sonnet".into())]), + prompt_template: "$task".into(), + timeout_secs: 900, + read_memory: true, + read_only: false, + }], + loops: vec![LoopBack { + from_step: "coder".into(), + to_step: "coder".into(), + max_iterations: 5, + }], + }; + let json = serde_json::to_string(&graph).expect("serialize"); + let back: PipelineGraph = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.steps.len(), 1); + assert_eq!(back.steps[0].id, "coder"); + assert_eq!(back.steps[0].config_values.get("model").unwrap(), "sonnet"); + assert_eq!(back.loops[0].max_iterations, 5); + } + + #[test] + fn pipeline_run_request_accepts_camel_case_wire_format() { + let json = r#"{ + "folderId": 1, + "pipelineId": null, + "graph": null, + "isolation": null, + "promptBlocks": [], + "displayText": "run it", + "parentConversationId": 7 + }"#; + let req: PipelineRunRequest = serde_json::from_str(json).expect("deserialize"); + assert_eq!(req.folder_id, 1); + assert_eq!(req.parent_conversation_id, Some(7)); + } +} diff --git a/src-tauri/src/office_watch/mod.rs b/src-tauri/src/office_watch/mod.rs index b594a0bc43..a38dd5c8a5 100644 --- a/src-tauri/src/office_watch/mod.rs +++ b/src-tauri/src/office_watch/mod.rs @@ -615,7 +615,7 @@ pub fn validate_watch_cap(port: u16, cap: &str) -> bool { /// Length-aware constant-time byte comparison (no early return on first /// mismatch). The caps are 122-bit random so this is belt-and-suspenders. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { +pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index d7f76631e8..ffbe6bcba0 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -47,6 +47,23 @@ pub fn codeg_pets_root() -> PathBuf { .unwrap_or_else(|| PathBuf::from(CODEG_DIR_NAME).join(PETS_DIR_NAME)) } + +/// Path of the memory graph database. +/// +/// Resolution order matches `codeg_pets_root()`, so a server deployment keeps +/// its memory inside the mounted data directory and its backups pick it up. +pub fn codeg_memory_db_path() -> PathBuf { + if let Some(custom) = std::env::var_os("CODEG_HOME").filter(|s| !s.is_empty()) { + return PathBuf::from(custom).join("memory.db"); + } + if let Some(data) = std::env::var_os("CODEG_DATA_DIR").filter(|s| !s.is_empty()) { + return PathBuf::from(data).join("memory.db"); + } + dirs::home_dir() + .map(|h| h.join(CODEG_DIR_NAME).join("memory.db")) + .unwrap_or_else(|| PathBuf::from(CODEG_DIR_NAME).join("memory.db")) +} + /// Root directory for attachments uploaded from the web client. /// /// Resolution order matches `codeg_pets_root()`: diff --git a/src-tauri/src/pipeline/e2e_tests.rs b/src-tauri/src/pipeline/e2e_tests.rs new file mode 100644 index 0000000000..c3ab6bf3b4 --- /dev/null +++ b/src-tauri/src/pipeline/e2e_tests.rs @@ -0,0 +1,266 @@ +//! End to end coverage for a duet run: the engine drives every step itself, +//! the test only reports verdicts and asserts what the engine persisted. +//! +//! Steps run against synthetic connections (`simulate_launches`) so no agent +//! process is started; the rendered prompt of each step is captured instead. + +#[cfg(test)] +mod duet_e2e { + use std::collections::BTreeMap; + use std::sync::Arc; + + use tokio::sync::Mutex; + + use crate::acp::manager::ConnectionManager; + use crate::acp::types::{AcpEvent, EventEnvelope}; + use crate::acp::InternalEventBus; + use crate::db::service::pipeline_service; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::db::AppDatabase; + use crate::models::{ + AttemptStatus, LoopBack, PipelineGraph, PipelineIsolation, PipelineRole, + PipelineRunRequest, PipelineRunStatus, PipelineStep, PipelineVerdict, + }; + use crate::pipeline::engine::{build_engine, LaunchLog, PipelineEngine}; + use crate::web::event_bridge::EventEmitter; + + fn duet_graph() -> PipelineGraph { + PipelineGraph { + steps: vec![ + PipelineStep { + id: "coder".into(), + role: PipelineRole::Coder, + label: "Coder".into(), + agent_type: "claude_code".into(), + mode_id: None, + config_values: BTreeMap::new(), + prompt_template: "TASK: $task\nREVIEW: $review".into(), + timeout_secs: 1800, + read_memory: false, + read_only: false, + }, + PipelineStep { + id: "reviewer".into(), + role: PipelineRole::Reviewer, + label: "Reviewer".into(), + agent_type: "claude_code".into(), + mode_id: None, + config_values: BTreeMap::new(), + prompt_template: "REVIEW TASK: $task".into(), + timeout_secs: 1800, + read_memory: false, + read_only: false, + }, + ], + loops: vec![LoopBack { + from_step: "reviewer".into(), + to_step: "coder".into(), + max_iterations: 3, + }], + } + } + + async fn setup() -> (Arc, AppDatabase, ConnectionManager, LaunchLog) { + let db = fresh_in_memory_db().await; + let metrics = Arc::new(crate::acp::internal_bus::EventBusMetrics::default()); + let bus = Arc::new(InternalEventBus::new(metrics)); + let manager = ConnectionManager::new(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let engine = build_engine( + AppDatabase { + conn: db.conn.clone(), + }, + manager.clone_ref(), + EventEmitter::Noop, + bus, + temp_dir.path().to_path_buf(), + ) + .expect("build engine"); + let log: LaunchLog = Arc::new(Mutex::new(Vec::new())); + engine.simulate_launches(log.clone()).await; + (engine, db, manager, log) + } + + fn turn_complete(conn_id: &str) -> EventEnvelope { + EventEnvelope { + seq: 0, + connection_id: conn_id.to_string(), + payload: AcpEvent::TurnComplete { + session_id: conn_id.to_string(), + stop_reason: "end_turn".to_string(), + agent_type: "claude_code".to_string(), + }, + } + } + + /// Report a verdict for the step that is currently running, then end its + /// turn, exactly as a real agent would through the MCP tool and the event + /// stream. + async fn finish_step(engine: &PipelineEngine, log: &LaunchLog, verdict: PipelineVerdict, notes: Option<&str>) { + let attempt_id = log.lock().await.last().expect("a step was launched").0; + let conn_id = format!("sim-conn-{attempt_id}"); + let ack = engine.record_verdict(&conn_id, verdict, notes).await; + assert!(ack.recorded, "verdict for the live step must be recorded"); + engine.on_event(&turn_complete(&conn_id)).await; + } + + #[tokio::test] + async fn duet_loops_back_on_changes_requested_then_completes_on_pass() { + let (engine, db, _manager, log) = setup().await; + let folder_id = seed_folder(&db, "/tmp/codeg-duet-e2e").await; + + let run = engine + .start(PipelineRunRequest { + folder_id, + pipeline_id: None, + graph: Some(duet_graph()), + isolation: Some(PipelineIsolation::SharedInRoot), + prompt_blocks: vec![], + display_text: "add a readiness probe".into(), + parent_conversation_id: None, + }) + .await + .expect("start run"); + + // The engine launched the coder itself. + { + let entries = log.lock().await; + assert_eq!(entries.len(), 1, "coder must be launched by the engine"); + assert_eq!(entries[0].1, "coder"); + assert_eq!(entries[0].2, 1, "first iteration"); + assert!(entries[0].3.contains("add a readiness probe")); + } + + // Coder finishes, reviewer asks for changes. + finish_step(&engine, &log, PipelineVerdict::Pass, None).await; + { + let entries = log.lock().await; + assert_eq!(entries.len(), 2, "reviewer must follow the coder"); + assert_eq!(entries[1].1, "reviewer"); + } + + finish_step( + &engine, + &log, + PipelineVerdict::ChangesRequested, + Some("initialDelaySeconds is too low; use 20"), + ) + .await; + + // The loop sent the work back to the coder with the reviewer's notes. + { + let entries = log.lock().await; + assert_eq!(entries.len(), 3, "coder must run again"); + let (_, step_id, iteration, prompt) = entries[2].clone(); + assert_eq!(step_id, "coder"); + assert_eq!(iteration, 2, "iteration is monotonic across the run"); + assert!( + prompt.contains("initialDelaySeconds is too low"), + "the coder must receive the reviewer notes in full, got: {prompt}" + ); + } + + // Second round passes review, so the run completes. + finish_step(&engine, &log, PipelineVerdict::Pass, None).await; + finish_step(&engine, &log, PipelineVerdict::Pass, None).await; + + let info = pipeline_service::get_run_info(&db.conn, run.id) + .await + .expect("run info"); + assert_eq!(info.status, PipelineRunStatus::Succeeded); + + let attempts = info.attempts; + assert_eq!(attempts.len(), 4, "two coder and two reviewer attempts"); + assert!( + attempts.iter().all(|a| a.status == AttemptStatus::Done), + "every attempt must be closed" + ); + let coder_iterations: Vec = attempts + .iter() + .filter(|a| a.step_id == "coder") + .map(|a| a.iteration) + .collect(); + assert_eq!(coder_iterations, vec![1, 2]); + assert_eq!( + attempts + .iter() + .filter(|a| a.verdict == Some(PipelineVerdict::ChangesRequested)) + .count(), + 1 + ); + } + + #[tokio::test] + async fn duet_stops_when_the_fix_round_limit_is_reached() { + let (engine, db, _manager, log) = setup().await; + let folder_id = seed_folder(&db, "/tmp/codeg-duet-limit").await; + let mut graph = duet_graph(); + graph.loops[0].max_iterations = 1; + + let run = engine + .start(PipelineRunRequest { + folder_id, + pipeline_id: None, + graph: Some(graph), + isolation: Some(PipelineIsolation::SharedInRoot), + prompt_blocks: vec![], + display_text: "tighten the alert rule".into(), + parent_conversation_id: None, + }) + .await + .expect("start run"); + + finish_step(&engine, &log, PipelineVerdict::Pass, None).await; + finish_step( + &engine, + &log, + PipelineVerdict::ChangesRequested, + Some("missing severity label"), + ) + .await; + + let info = pipeline_service::get_run_info(&db.conn, run.id) + .await + .expect("run info"); + assert_eq!( + info.status, + PipelineRunStatus::StoppedMaxIterations, + "one fix round is allowed, the second request must stop the run" + ); + assert_eq!(log.lock().await.len(), 2, "no third step may be launched"); + } + + #[tokio::test] + async fn inconclusive_review_stops_the_run_without_a_fix_round() { + let (engine, db, _manager, log) = setup().await; + let folder_id = seed_folder(&db, "/tmp/codeg-duet-inconclusive").await; + + let run = engine + .start(PipelineRunRequest { + folder_id, + pipeline_id: None, + graph: Some(duet_graph()), + isolation: Some(PipelineIsolation::SharedInRoot), + prompt_blocks: vec![], + display_text: "rotate the deploy key".into(), + parent_conversation_id: None, + }) + .await + .expect("start run"); + + finish_step(&engine, &log, PipelineVerdict::Pass, None).await; + finish_step( + &engine, + &log, + PipelineVerdict::Inconclusive, + Some("cannot tell whether the change is correct"), + ) + .await; + + let info = pipeline_service::get_run_info(&db.conn, run.id) + .await + .expect("run info"); + assert_eq!(info.status, PipelineRunStatus::Inconclusive); + assert_eq!(log.lock().await.len(), 2, "no fix round after inconclusive"); + } +} diff --git a/src-tauri/src/pipeline/engine.rs b/src-tauri/src/pipeline/engine.rs new file mode 100644 index 0000000000..c620d3b372 --- /dev/null +++ b/src-tauri/src/pipeline/engine.rs @@ -0,0 +1,1476 @@ +//! Pipeline execution engine: orchestrates multi-agent workflows through the ACP layer. +//! +//! Design: +//! - Each run has a unique `run_id`, each attempt has `attempt_id = (run_id, step, iteration)`. +//! - Attempts are correlated by `connection_id` (same as automation engine). +//! - Transitions use CAS: `pipeline_service::cas_attempt_status(attempt_id, from, to)`. +//! - Worktree per run via `PipelineIsolation::WorktreePerRun` (same as automation). +//! - Guard: read-only step checks if `git diff HEAD` + untracked hash changed after step. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::Mutex; + +use crate::acp::manager::ConnectionManager; +use crate::acp::pipeline_tools::PipelineToolAccess; +use crate::acp::types::{AcpEvent, EventEnvelope, PromptInputBlock}; +use crate::acp::work_task_tools::TaskReportAck; +use crate::acp::InternalEventBus; +use crate::db::service::pipeline_service; +use crate::db::AppDatabase; +use crate::models::{ + AttemptStatus, PipelineGraph, PipelineIsolation, PipelineRole, PipelineRunInfo, + PipelineRunRequest, PipelineRunStatus, PipelineVerdict, +}; +use crate::pipeline::verdict; +use crate::web::event_bridge::{emit_event, EventEmitter, PipelineChange, PIPELINE_CHANGED_EVENT}; + +static PIPELINE_ENGINE: OnceLock> = OnceLock::new(); + +const CANCEL_WAIT_TIMEOUT_SECS: u64 = 30; + +pub fn engine() -> Option> { + PIPELINE_ENGINE.get().cloned() +} + +#[derive(Debug, Clone)] +struct AttemptState { + run_id: i32, + attempt_id: i32, + step_index: usize, + iteration: u32, + step_id: String, + role: PipelineRole, + read_only: bool, + display_text: String, + working_dir: String, + folder_id: i32, +} + + +/// Memory context for a step that asks for it. +/// +/// Entries are recalled by the run's task text and handed to the agent as +/// untrusted data: memory holds text written by earlier agent runs, so it must +/// never be read as instructions. +async fn memory_context(db: &AppDatabase, task: &str, folder_id: i32) -> String { + const MAX_CHARS: usize = 2500; + let hits = match crate::commands::memory::memory_search_scoped( + db, + task.to_string(), + Some(8), + Some(folder_id), + ) + .await + { + Ok(hits) if !hits.is_empty() => hits, + _ => return String::new(), + }; + let mut body = String::new(); + for hit in hits { + let line = format!("- [{}] {}: {}\n", hit.node.kind, hit.node.title, hit.node.body); + if body.len() + line.len() > MAX_CHARS { + break; + } + body.push_str(&line); + } + if body.is_empty() { + return String::new(); + } + format!("\n{body}") +} + + +/// Record a run outcome in memory when the matching kind is enabled and set to +/// `auto`. Anything the user has not enabled stays unwritten, and the entry +/// carries where it came from so a wrong lesson can be traced back. +#[allow(clippy::too_many_arguments)] +async fn auto_record_memory( + db: &AppDatabase, + kind_key: &str, + title: String, + body: String, + run_id: i32, + folder_id: i32, + verified_by_tests: bool, +) { + use crate::memory::backend::{MemoryProvenance, NewMemoryNode}; + + let Ok(kinds) = crate::db::service::memory_kind_service::list(&db.conn).await else { + return; + }; + let allowed = kinds + .iter() + .any(|k| k.key == kind_key && k.enabled && k.mode == crate::models::MemoryMode::Auto); + if !allowed { + return; + } + + let node = NewMemoryNode { + kind: kind_key.to_string(), + title, + body, + scope: crate::models::MemoryScope::Project, + folder_id: Some(folder_id), + provenance: MemoryProvenance { + run_id: Some(run_id), + step_id: None, + agent_type: None, + verified_by_tests, + source: "auto".into(), + }, + }; + let _ = crate::commands::memory::memory_write_auto_core(db, node).await; +} + + +/// The option that lets the step continue. +/// +/// Agents name these differently, so prefer an explicit allow-once kind and +/// fall back to the first option whose name reads as an approval. +fn pick_allow_option(options: &[crate::acp::types::PermissionOptionInfo]) -> Option { + options + .iter() + .find(|o| o.kind == "allow_once") + .or_else(|| options.iter().find(|o| o.kind.starts_with("allow"))) + .or_else(|| { + options + .iter() + .find(|o| o.name.to_lowercase().starts_with("allow") || o.name.to_lowercase().starts_with("yes")) + }) + .map(|o| o.option_id.clone()) +} + +/// Verdict recorded by a tool call, keyed by attempt id. +type RecordedVerdicts = Arc)>>>; + +/// Prompts handed to each step, recorded only when the engine runs without +/// real agent processes (tests): `(attempt_id, step_id, iteration, prompt)`. +#[cfg(any(test, feature = "test-utils"))] +pub type LaunchLog = Arc>>; + +pub struct PipelineEngine { + db: AppDatabase, + manager: ConnectionManager, + emitter: EventEmitter, + bus: Arc, + data_dir: PathBuf, + /// Live runs: `connection_id -> AttemptState`. + index: Arc>>, + /// Per-folder start serialization locks. + folder_locks: Arc>>>>, + /// Active run per folder: `folder_id -> run_id`. + active_runs: Arc>>, + /// Pre-step working tree hashes for read-only step guards: `attempt_id -> hash`. + pre_step_hashes: Arc>>, + /// Recorded verdicts from tool calls: `attempt_id -> (verdict, notes)`. + recorded_verdicts: RecordedVerdicts, + /// When set, steps run against synthetic connections instead of spawning + /// agent processes, and their prompts are recorded here. + #[cfg(any(test, feature = "test-utils"))] + launch_log: Arc>>, +} + +#[async_trait] +impl PipelineToolAccess for PipelineEngine { + async fn record_verdict( + &self, + parent_connection_id: &str, + verdict: &str, + notes: Option<&str>, + ) -> TaskReportAck { + let v = match verdict.to_lowercase().as_str() { + "pass" => PipelineVerdict::Pass, + "changes_requested" => PipelineVerdict::ChangesRequested, + "inconclusive" => PipelineVerdict::Inconclusive, + _ => return TaskReportAck::rejected(&format!("unknown verdict: {verdict}")), + }; + self.record_verdict_internal(parent_connection_id, v, notes) + .await + } +} + +pub fn build_engine( + db: AppDatabase, + manager: ConnectionManager, + emitter: EventEmitter, + bus: Arc, + data_dir: PathBuf, +) -> Option> { + let engine = Arc::new(PipelineEngine { + db, + manager, + emitter, + bus, + data_dir, + index: Arc::new(Mutex::new(HashMap::new())), + folder_locks: Arc::new(Mutex::new(HashMap::new())), + active_runs: Arc::new(Mutex::new(HashMap::new())), + pre_step_hashes: Arc::new(Mutex::new(HashMap::new())), + recorded_verdicts: Arc::new(Mutex::new(HashMap::new())), + #[cfg(any(test, feature = "test-utils"))] + launch_log: Arc::new(Mutex::new(None)), + }); + let _ = PIPELINE_ENGINE.set(engine.clone()); + Some(engine) +} + +/// Task that recovers pipeline state on boot and processes events. +pub async fn run_pipeline_engine(engine: Arc) { + engine.recover_on_boot().await; + let mut rx = engine.bus.subscribe(); + loop { + match rx.recv().await { + Ok(env) => engine.on_event(&env).await, + Err(RecvError::Lagged(n)) => { + tracing::warn!("[pipeline] event bus lagged: dropped {n} events"); + } + Err(RecvError::Closed) => break, + } + } +} + +struct ResolvedCwd { + folder_id: i32, + working_dir: String, + worktree_folder_id: Option, +} + +impl PipelineEngine { + pub fn db(&self) -> &AppDatabase { + &self.db + } + + pub fn manager(&self) -> &ConnectionManager { + &self.manager + } + + pub fn emitter(&self) -> &EventEmitter { + &self.emitter + } + + pub async fn record_verdict( + &self, + connection_id: &str, + verdict: PipelineVerdict, + notes: Option<&str>, + ) -> TaskReportAck { + self.record_verdict_internal(connection_id, verdict, notes) + .await + } + + async fn record_verdict_internal( + &self, + connection_id: &str, + verdict: PipelineVerdict, + notes: Option<&str>, + ) -> TaskReportAck { + let entry = self.attempt_state_for_connection(connection_id).await; + let Some(attempt) = entry else { + tracing::debug!( + "[pipeline] record_verdict for unknown or expired connection {connection_id}" + ); + return TaskReportAck::rejected( + "connection not associated with an active pipeline attempt", + ); + }; + + self.recorded_verdicts + .lock() + .await + .insert(attempt.attempt_id, (verdict, notes.map(|s| s.to_string()))); + + let _ = pipeline_service::set_attempt_verdict( + &self.db.conn, + attempt.attempt_id, + verdict, + Some("tool".into()), + notes.map(|s| s.to_string()), + ) + .await; + + TaskReportAck::recorded() + } + + async fn folder_lock(&self, folder_id: i32) -> Arc> { + let mut locks = self.folder_locks.lock().await; + locks + .entry(folder_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + async fn resolve_cwd( + &self, + root_folder_id: i32, + isolation: PipelineIsolation, + pipeline_id: Option, + run_id: i32, + ) -> Result { + let root = crate::commands::folders::get_folder_core(&self.db, root_folder_id) + .await + .map_err(|e| e.to_string())?; + + match isolation { + PipelineIsolation::WorktreePerRun => { + let pid = pipeline_id.unwrap_or(0); + let branch = format!("pipeline/{pid}/run-{run_id}"); + let repo_name = match basename(&root.path) { + "" => "workspace", + name => name, + }; + let dir = format!("{repo_name}-pipeline-run-{run_id}"); + let mut wt_path = sibling_path(&root.path, &dir); + + if let Err(e) = crate::commands::folders::git_worktree_add( + root.path.clone(), + branch.clone(), + wt_path.clone(), + None, + ) + .await + { + let suffix = short_suffix(run_id); + let branch2 = format!("{branch}-{suffix}"); + wt_path = sibling_path(&root.path, &format!("{dir}-{suffix}")); + crate::commands::folders::git_worktree_add( + root.path.clone(), + branch2, + wt_path.clone(), + None, + ) + .await + .map_err(|_| format!("worktree add failed: {e}"))?; + } + + let wt = crate::commands::folders::open_worktree_folder_core( + &self.db, + wt_path, + root_folder_id, + ) + .await + .map_err(|e| e.to_string())?; + + Ok(ResolvedCwd { + folder_id: wt.id, + working_dir: wt.path, + worktree_folder_id: Some(wt.id), + }) + } + PipelineIsolation::SharedInRoot => Ok(ResolvedCwd { + folder_id: root_folder_id, + working_dir: root.path, + worktree_folder_id: None, + }), + } + } + + /// Start a new pipeline run. Returns error if a run is already active in this folder. + pub async fn start(&self, req: PipelineRunRequest) -> Result { + let lock = self.folder_lock(req.folder_id).await; + let _guard = lock.lock().await; + + let active_in_db = pipeline_service::has_active_run(&self.db.conn, req.folder_id) + .await + .map_err(|e| format!("db error: {e}"))?; + if active_in_db || self.active_runs.lock().await.contains_key(&req.folder_id) { + return Err("pipeline already running in this folder".into()); + } + + let graph = if let Some(graph) = req.graph { + graph + } else if let Some(pipeline_id) = req.pipeline_id { + let pipeline = pipeline_service::get_pipeline(&self.db.conn, pipeline_id) + .await + .map_err(|e| format!("failed to load pipeline: {e}"))?; + pipeline.graph + } else { + return Err("either pipeline_id or graph must be provided".into()); + }; + + crate::pipeline::validate::validate_graph(&graph) + .map_err(|e| format!("invalid graph: {e}"))?; + + let isolation = req.isolation.unwrap_or(PipelineIsolation::WorktreePerRun); + + let run = pipeline_service::create_run( + &self.db.conn, + req.pipeline_id, + req.folder_id, + &graph, + isolation, + req.parent_conversation_id, + Some(req.display_text.clone()), + ) + .await + .map_err(|e| format!("failed to create run: {e}"))?; + + self.active_runs.lock().await.insert(req.folder_id, run.id); + + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunStarted { + run_id: run.id, + folder_id: req.folder_id, + }, + ); + + if let Err(e) = self.launch_step(run.id, 0, 1, req.display_text, None).await { + let _ = pipeline_service::update_run_status( + &self.db.conn, + run.id, + PipelineRunStatus::Failed, + Some(e.clone()), + ) + .await; + self.active_runs.lock().await.remove(&req.folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id: run.id, + status: PipelineRunStatus::Failed, + }, + ); + return Err(e); + } + + pipeline_service::get_run_info(&self.db.conn, run.id) + .await + .map_err(|e| e.to_string()) + } + + /// Run steps without spawning agent processes, recording their prompts. + /// Test-only: the item does not exist in release builds. + #[cfg(any(test, feature = "test-utils"))] + pub async fn simulate_launches(&self, log: LaunchLog) { + *self.launch_log.lock().await = Some(log); + } + + + /// Settle a run that could not continue. + /// + /// A launch failure used to be swallowed: the run stayed `running`, the + /// folder stayed locked and no further event ever arrived, so the card span + /// forever. Failing loudly keeps both the UI and the folder usable. + async fn fail_run(&self, run_id: i32, folder_id: i32, error: String) { + let _ = pipeline_service::update_run_status( + &self.db.conn, + run_id, + PipelineRunStatus::Failed, + Some(error), + ) + .await; + self.active_runs.lock().await.remove(&folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id, + status: PipelineRunStatus::Failed, + }, + ); + } + + async fn launch_step( + &self, + run_id: i32, + step_index: usize, + iteration: u32, + display_text: String, + previous_attempt_id: Option, + ) -> Result<(), String> { + let run = pipeline_service::get_run_raw(&self.db.conn, run_id) + .await + .map_err(|e| format!("failed to fetch run: {e}"))?; + + if run.status != "running" { + return Ok(()); + } + + let graph: PipelineGraph = + serde_json::from_str(&run.graph).map_err(|e| format!("invalid graph: {e}"))?; + + if step_index >= graph.steps.len() { + let _ = pipeline_service::update_run_status( + &self.db.conn, + run_id, + PipelineRunStatus::Succeeded, + None, + ) + .await; + self.active_runs.lock().await.remove(&run.folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id, + status: PipelineRunStatus::Succeeded, + }, + ); + return Ok(()); + } + + let step = &graph.steps[step_index]; + let agent_type: crate::models::AgentType = + serde_json::from_value(serde_json::Value::String(step.agent_type.clone())) + .map_err(|_| format!("unknown agent type: {}", step.agent_type))?; + + let isolation = match run.isolation.as_str() { + "shared_in_root" => PipelineIsolation::SharedInRoot, + _ => PipelineIsolation::WorktreePerRun, + }; + + let cwd = if let Some(wt_id) = run.worktree_folder_id { + if let Ok(detail) = crate::commands::folders::get_folder_core(&self.db, wt_id).await { + ResolvedCwd { + folder_id: detail.id, + working_dir: detail.path, + worktree_folder_id: Some(detail.id), + } + } else { + self.resolve_cwd(run.folder_id, isolation, run.pipeline_id, run_id) + .await? + } + } else { + self.resolve_cwd(run.folder_id, isolation, run.pipeline_id, run_id) + .await? + }; + + if let Ok(detail) = crate::commands::folders::get_folder_core(&self.db, cwd.folder_id).await + { + crate::commands::folders::emit_folder_upsert(&self.emitter, detail); + } + + let _ = pipeline_service::update_run_current_step( + &self.db.conn, + run_id, + Some(step.id.clone()), + iteration, + cwd.worktree_folder_id, + ) + .await; + + let pre_hash = if step.read_only { + Some(compute_worktree_hash(&cwd.working_dir).await) + } else { + None + }; + + let model_requested = step.config_values.get("model").cloned(); + let attempt = pipeline_service::create_attempt( + &self.db.conn, + run_id, + step.id.clone(), + iteration, + None, + model_requested, + ) + .await + .map_err(|e| format!("failed to create attempt: {e}"))?; + + if let Some(hash) = pre_hash { + self.pre_step_hashes.lock().await.insert(attempt.id, hash); + } + + let plan = pipeline_service::get_last_planner_summary(&self.db.conn, run_id, &graph) + .await + .unwrap_or(None); + let review = pipeline_service::get_last_changes_requested_notes(&self.db.conn, run_id) + .await + .unwrap_or(None); + let summary = if let Some(prev_id) = previous_attempt_id { + if let Ok(Some(prev)) = pipeline_service::get_attempt(&self.db.conn, prev_id).await { + prev.summary.map(|s| { + let truncated = if s.len() > 4000 { + let mut t: String = s.chars().take(4000).collect(); + t.push_str("... (truncated)"); + t + } else { + s + }; + if let Some(cid) = prev.conversation_id { + format!("Summary from conversation #{cid}:\n{truncated}") + } else { + truncated + } + }) + } else { + None + } + } else { + None + }; + + let memory = if step.read_memory { + memory_context(&self.db, &display_text, cwd.folder_id).await + } else { + String::new() + }; + + let prompt_vars = verdict::PromptVars { + task: display_text.clone(), + plan, + summary, + review, + memory, + }; + let rendered_prompt = verdict::render_prompt(&step.prompt_template, &prompt_vars); + + let runtime_env = crate::commands::acp::build_session_runtime_env( + &self.db, + agent_type, + None, + &self.data_dir, + ) + .await + .map_err(|e| e.to_string())?; + + // Steps run unattended: in the agent's default mode every tool call + // raises a permission prompt nobody answers, and the step just sits + // there until its timeout. A read-only step reviews in `plan`, a step + // that has to change files accepts its own edits. A mode set on the + // step wins over both. + let preferred_mode_id = step.mode_id.clone().or_else(|| { + Some(if step.read_only { + "plan".to_string() + } else { + "acceptEdits".to_string() + }) + }); + + #[cfg(any(test, feature = "test-utils"))] + if let Some(log) = self.launch_log.lock().await.clone() { + let conn_id = format!("sim-conn-{}", attempt.id); + self.manager + .insert_test_connection( + &conn_id, + agent_type, + Some(std::path::PathBuf::from(&cwd.working_dir)), + self.emitter.clone(), + ) + .await; + let _ = pipeline_service::attach_attempt_runtime( + &self.db.conn, + attempt.id, + None, + Some(conn_id.clone()), + None, + ) + .await; + log.lock().await.push(( + attempt.id, + step.id.clone(), + iteration, + rendered_prompt.clone(), + )); + self.index.lock().await.insert( + conn_id, + AttemptState { + run_id, + attempt_id: attempt.id, + step_index, + iteration, + step_id: step.id.clone(), + role: step.role, + read_only: step.read_only, + display_text: display_text.clone(), + working_dir: cwd.working_dir.clone(), + folder_id: cwd.folder_id, + }, + ); + return Ok(()); + } + + let conn_id = self + .manager + .spawn_agent( + agent_type, + Some(cwd.working_dir.clone()), + None, + runtime_env, + "pipeline".to_string(), + self.emitter.clone(), + preferred_mode_id, + step.config_values.clone(), + ) + .await; + // The attempt row already exists, so a spawn failure has to close it: + // an attempt left running blocks the UI and can later be matched by a + // stale connection id. + let conn_id = match conn_id { + Ok(id) => id, + Err(e) => { + let _ = pipeline_service::cas_attempt_status( + &self.db.conn, + attempt.id, + AttemptStatus::Running, + AttemptStatus::Failed, + ) + .await; + return Err(e.to_string()); + } + }; + + let title = format!("{}: {}", step.label, first_chars(&display_text, 40)); + let conversation_id = match crate::commands::conversations::create_conversation_core( + &self.db.conn, + cwd.folder_id, + agent_type, + Some(title), + ) + .await + { + Ok(id) => id, + Err(e) => { + let _ = self.manager.disconnect(&conn_id).await; + return Err(e.to_string()); + } + }; + + let _ = + crate::db::service::conversation_service::lock_title(&self.db.conn, conversation_id) + .await; + + crate::commands::conversations::emit_conversation_upsert( + &self.emitter, + &self.db.conn, + conversation_id, + ) + .await; + + let _ = pipeline_service::attach_attempt_runtime( + &self.db.conn, + attempt.id, + Some(conversation_id), + Some(conn_id.clone()), + None, + ) + .await; + + let attempt_state = AttemptState { + run_id, + attempt_id: attempt.id, + step_index, + iteration, + step_id: step.id.clone(), + role: step.role, + read_only: step.read_only, + display_text: display_text.clone(), + working_dir: cwd.working_dir.clone(), + folder_id: run.folder_id, + }; + + self.index + .lock() + .await + .insert(conn_id.clone(), attempt_state); + + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::StepStarted { + run_id, + attempt_id: attempt.id, + step_id: step.id.clone(), + iteration, + }, + ); + + // Step timeout watcher + if step.timeout_secs > 0 { + let timeout_secs = step.timeout_secs; + let attempt_id = attempt.id; + let c_id = conn_id.clone(); + let db_conn = self.db.conn.clone(); + let mgr = self.manager.clone_ref(); + let emitter = self.emitter.clone(); + let active_runs = self.active_runs.clone(); + let index = self.index.clone(); + let pre_step_hashes = self.pre_step_hashes.clone(); + let recorded_verdicts = self.recorded_verdicts.clone(); + let folder_id = run.folder_id; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await; + if let Ok(true) = pipeline_service::cas_attempt_status( + &db_conn, + attempt_id, + AttemptStatus::Running, + AttemptStatus::TimedOut, + ) + .await + { + let _ = mgr.cancel(&db_conn, &c_id).await; + let _ = mgr.disconnect(&c_id).await; + let _ = pipeline_service::update_run_status( + &db_conn, + run_id, + PipelineRunStatus::Failed, + Some("step timed out".into()), + ) + .await; + // Without this the folder stays locked for the rest of the + // process lifetime: `start` refuses a second run while the + // folder is in `active_runs`. + active_runs.lock().await.remove(&folder_id); + index.lock().await.remove(&c_id); + pre_step_hashes.lock().await.remove(&attempt_id); + recorded_verdicts.lock().await.remove(&attempt_id); + emit_event( + &emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id, + status: PipelineRunStatus::Failed, + }, + ); + } + }); + } + + let prompt_blocks = vec![PromptInputBlock::Text { + text: rendered_prompt, + }]; + match self + .manager + .send_prompt_linked_with_message_id( + &self.db, + &conn_id, + prompt_blocks, + Some(cwd.folder_id), + Some(conversation_id), + None, + None, + ) + .await + { + Ok(_) => Ok(()), + Err(e) => { + self.index.lock().await.remove(&conn_id); + let _ = self.manager.disconnect(&conn_id).await; + Err(e.to_string()) + } + } + } + + + /// Resolve the attempt a child connection belongs to. + /// + /// Normally the live index answers this. After a restart the index is empty + /// while child agents may still be connected, so fall back to the running + /// attempt recorded for that connection in the database. + async fn attempt_state_for_connection(&self, connection_id: &str) -> Option { + if let Some(state) = self.index.lock().await.get(connection_id).cloned() { + return Some(state); + } + let attempt = + pipeline_service::find_running_attempt_by_connection(&self.db.conn, connection_id) + .await + .ok() + .flatten()?; + let run = pipeline_service::get_run_raw(&self.db.conn, attempt.run_id) + .await + .ok()?; + let graph: PipelineGraph = serde_json::from_str(&run.graph).ok()?; + let step_index = graph + .steps + .iter() + .position(|s| s.id == attempt.step_id)?; + let step = &graph.steps[step_index]; + // A missing folder row must not strand the attempt: the working dir is + // only needed to launch further steps, not to close this one. + let working_dir = crate::commands::folders::get_folder_core( + &self.db, + run.worktree_folder_id.unwrap_or(run.folder_id), + ) + .await + .map(|f| f.path) + .unwrap_or_default(); + Some(AttemptState { + run_id: attempt.run_id, + attempt_id: attempt.id, + step_index, + iteration: attempt.iteration as u32, + step_id: attempt.step_id.clone(), + role: step.role, + read_only: step.read_only, + display_text: run.display_text.clone().unwrap_or_default(), + working_dir, + folder_id: run.folder_id, + }) + } + + pub async fn on_event(&self, env: &EventEnvelope) { + // Steps run unattended, so a permission card would sit on screen with + // nobody to answer it and the step would burn its whole timeout. Answer + // for our own connections: the user already approved this pipeline, and + // the run is confined to its own worktree. + if let AcpEvent::PermissionRequest { + request_id, + options, + .. + } = &env.payload + { + if self.index.lock().await.contains_key(&env.connection_id) { + if let Some(option) = pick_allow_option(options) { + let _ = self + .manager + .respond_permission(&env.connection_id, request_id, &option) + .await; + } + } + return; + } + + let AcpEvent::TurnComplete { stop_reason, .. } = &env.payload else { + return; + }; + let conn_id = &env.connection_id; + let entry = self.attempt_state_for_connection(conn_id).await; + self.index.lock().await.remove(conn_id); + let Some(attempt) = entry else { + tracing::debug!("[pipeline] TurnComplete for unindexed connection {conn_id}"); + return; + }; + + let attempt_status = match stop_reason.as_str() { + "cancelled" => AttemptStatus::Cancelled, + _ => AttemptStatus::Done, + }; + + let cas_ok = pipeline_service::cas_attempt_status( + &self.db.conn, + attempt.attempt_id, + AttemptStatus::Running, + attempt_status, + ) + .await + .unwrap_or(false); + + if !cas_ok { + tracing::debug!( + "[pipeline] duplicate or stale TurnComplete for attempt {}", + attempt.attempt_id + ); + let _ = self.manager.disconnect(conn_id).await; + return; + } + + let summary = self.capture_summary(conn_id).await; + let _ = pipeline_service::set_attempt_summary( + &self.db.conn, + attempt.attempt_id, + summary.clone(), + ) + .await; + + let _ = self.manager.disconnect(conn_id).await; + + if stop_reason == "cancelled" { + return; + } + + let (mut verdict, mut source, mut notes) = if let Some((v, n)) = self + .recorded_verdicts + .lock() + .await + .remove(&attempt.attempt_id) + { + (v, "tool".to_string(), n) + } else if let Some(ref s) = summary { + if let Some((v, n)) = verdict::parse_marker(s) { + (v, "marker".to_string(), n) + } else if attempt.role == PipelineRole::Reviewer || attempt.role == PipelineRole::Tests + { + ( + PipelineVerdict::Inconclusive, + "none".to_string(), + Some("no verdict reported by reviewer/tests".into()), + ) + } else if stop_reason == "end_turn" { + (PipelineVerdict::Pass, "none".to_string(), None) + } else { + ( + PipelineVerdict::Inconclusive, + "none".to_string(), + Some(format!("agent stopped: {stop_reason}")), + ) + } + } else if attempt.role == PipelineRole::Reviewer || attempt.role == PipelineRole::Tests { + ( + PipelineVerdict::Inconclusive, + "none".to_string(), + Some("no verdict reported by reviewer/tests".into()), + ) + } else if stop_reason == "end_turn" { + (PipelineVerdict::Pass, "none".to_string(), None) + } else { + ( + PipelineVerdict::Inconclusive, + "none".to_string(), + Some(format!("agent stopped: {stop_reason}")), + ) + }; + + if attempt.read_only { + let post_hash = compute_worktree_hash(&attempt.working_dir).await; + let pre_hash = self + .pre_step_hashes + .lock() + .await + .remove(&attempt.attempt_id); + if let Some(pre) = pre_hash { + if pre != post_hash { + verdict = PipelineVerdict::Inconclusive; + source = "guard".to_string(); + notes = Some("reviewer modified files".to_string()); + } + } + } + + let _ = pipeline_service::set_attempt_verdict( + &self.db.conn, + attempt.attempt_id, + verdict, + Some(source), + notes.clone(), + ) + .await; + + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::StepSettled { + run_id: attempt.run_id, + attempt_id: attempt.attempt_id, + verdict: Some(verdict), + }, + ); + + let Ok(run) = pipeline_service::get_run_raw(&self.db.conn, attempt.run_id).await else { + return; + }; + if run.status != "running" { + self.active_runs.lock().await.remove(&attempt.folder_id); + return; + } + + let Ok(graph) = serde_json::from_str::(&run.graph) else { + return; + }; + + match verdict { + PipelineVerdict::Pass => { + if attempt.step_index + 1 >= graph.steps.len() { + let _ = pipeline_service::update_run_status( + &self.db.conn, + attempt.run_id, + PipelineRunStatus::Succeeded, + None, + ) + .await; + self.active_runs.lock().await.remove(&attempt.folder_id); + let had_fix_round = attempt.iteration > 1; + let tests_ran = graph + .steps + .iter() + .any(|s| s.role == PipelineRole::Tests); + auto_record_memory( + &self.db, + "task_summary", + first_chars(&attempt.display_text, 80).to_string(), + summary.clone().unwrap_or_default(), + attempt.run_id, + attempt.folder_id, + tests_ran, + ) + .await; + if had_fix_round { + if let Some(review_notes) = + pipeline_service::get_last_changes_requested_notes( + &self.db.conn, + attempt.run_id, + ) + .await + .unwrap_or(None) + { + auto_record_memory( + &self.db, + "fixed_bug", + first_chars(&attempt.display_text, 80).to_string(), + review_notes, + attempt.run_id, + attempt.folder_id, + tests_ran, + ) + .await; + } + } + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id: attempt.run_id, + status: PipelineRunStatus::Succeeded, + }, + ); + } else if let Err(e) = self + .launch_step( + attempt.run_id, + attempt.step_index + 1, + attempt.iteration, + attempt.display_text.clone(), + Some(attempt.attempt_id), + ) + .await + { + self.fail_run(attempt.run_id, attempt.folder_id, e).await; + } + } + PipelineVerdict::ChangesRequested => { + let loop_edge = graph.loops.iter().find(|l| l.from_step == attempt.step_id); + if let Some(loop_back) = loop_edge { + if attempt.iteration < loop_back.max_iterations { + if let Some(target_idx) = + graph.steps.iter().position(|s| s.id == loop_back.to_step) + { + let new_iteration = attempt.iteration + 1; + if let Err(e) = self + .launch_step( + attempt.run_id, + target_idx, + new_iteration, + attempt.display_text.clone(), + Some(attempt.attempt_id), + ) + .await + { + self.fail_run(attempt.run_id, attempt.folder_id, e).await; + } + } else { + let _ = pipeline_service::update_run_status( + &self.db.conn, + attempt.run_id, + PipelineRunStatus::Failed, + Some(format!( + "loop target step '{}' not found", + loop_back.to_step + )), + ) + .await; + self.active_runs.lock().await.remove(&attempt.folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id: attempt.run_id, + status: PipelineRunStatus::Failed, + }, + ); + } + } else { + let _ = pipeline_service::update_run_status( + &self.db.conn, + attempt.run_id, + PipelineRunStatus::StoppedMaxIterations, + Some(format!( + "reached maximum fix iterations ({})", + loop_back.max_iterations + )), + ) + .await; + self.active_runs.lock().await.remove(&attempt.folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id: attempt.run_id, + status: PipelineRunStatus::StoppedMaxIterations, + }, + ); + } + } else { + let _ = pipeline_service::update_run_status( + &self.db.conn, + attempt.run_id, + PipelineRunStatus::Failed, + Some("changes requested but no loopback edge configured".into()), + ) + .await; + self.active_runs.lock().await.remove(&attempt.folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id: attempt.run_id, + status: PipelineRunStatus::Failed, + }, + ); + } + } + PipelineVerdict::Inconclusive => { + let _ = pipeline_service::update_run_status( + &self.db.conn, + attempt.run_id, + PipelineRunStatus::Inconclusive, + notes, + ) + .await; + self.active_runs.lock().await.remove(&attempt.folder_id); + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id: attempt.run_id, + status: PipelineRunStatus::Inconclusive, + }, + ); + } + } + } + + async fn capture_summary(&self, conn_id: &str) -> Option { + let (state, _) = self.manager.get_state_and_emitter(conn_id).await?; + let text = state.read().await.last_assistant_text.clone(); + text.filter(|t| !t.trim().is_empty()) + } + + pub async fn cancel(&self, run_id: i32) -> Result<(), String> { + let run = match pipeline_service::get_run_raw(&self.db.conn, run_id).await { + Ok(r) => r, + Err(_) => return Ok(()), + }; + + let settled = pipeline_service::update_run_status( + &self.db.conn, + run_id, + PipelineRunStatus::Cancelled, + Some("cancelled by user".into()), + ) + .await + .unwrap_or(false); + + if settled { + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunSettled { + run_id, + status: PipelineRunStatus::Cancelled, + }, + ); + } + + let conn_id = { + self.index + .lock() + .await + .iter() + .find(|(_, state)| state.run_id == run_id) + .map(|(c, _)| c.clone()) + }; + + if let Some(ref cid) = conn_id { + let _ = self.manager.cancel(&self.db.conn, cid).await; + let start_time = std::time::Instant::now(); + while start_time.elapsed().as_secs() < CANCEL_WAIT_TIMEOUT_SECS { + let still_in_index = self.index.lock().await.contains_key(cid); + if !still_in_index { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + self.index.lock().await.remove(cid); + let _ = self.manager.disconnect(cid).await; + } + + self.active_runs.lock().await.remove(&run.folder_id); + let _ = pipeline_service::interrupt_running_attempts(&self.db.conn, run_id).await; + + Ok(()) + } + + pub async fn status(&self, run_id: i32) -> Result { + pipeline_service::get_run_info(&self.db.conn, run_id) + .await + .map_err(|e| e.to_string()) + } + + pub async fn request_changes(&self, run_id: i32, notes: String) -> Result<(), String> { + let run = pipeline_service::get_run_raw(&self.db.conn, run_id) + .await + .map_err(|e| format!("failed to load run: {e}"))?; + + if run.status == "running" { + return Err("run is still running".into()); + } + + let graph: PipelineGraph = + serde_json::from_str(&run.graph).map_err(|e| format!("invalid graph: {e}"))?; + + let coder_step_idx = graph + .steps + .iter() + .position(|s| s.role == PipelineRole::Coder) + .unwrap_or(0); + + let next_iteration = (run.current_iteration as u32) + 1; + pipeline_service::reset_run_to_running(&self.db.conn, run_id, next_iteration) + .await + .map_err(|e| format!("db error: {e}"))?; + + self.active_runs.lock().await.insert(run.folder_id, run.id); + + emit_event( + &self.emitter, + PIPELINE_CHANGED_EVENT, + PipelineChange::RunStarted { + run_id, + folder_id: run.folder_id, + }, + ); + + let last_attempt = pipeline_service::get_run_info(&self.db.conn, run_id) + .await + .ok() + .and_then(|info| info.attempts.last().cloned()); + if let Some(att) = last_attempt { + let _ = pipeline_service::set_attempt_verdict( + &self.db.conn, + att.id, + PipelineVerdict::ChangesRequested, + Some("user".into()), + Some(notes.clone()), + ) + .await; + } + + // The coder's prompt renders `$task`, so a fix round must carry the + // original task, not a placeholder. + let display_text = run + .display_text + .clone() + .unwrap_or_else(|| "Requested changes".to_string()); + self.launch_step(run_id, coder_step_idx, next_iteration, display_text, None) + .await + } + + pub async fn stop_for_manual_fix(&self, run_id: i32) -> Result<(), String> { + self.cancel(run_id).await + } + + pub async fn recover_on_boot(&self) { + if let Err(e) = pipeline_service::interrupt_running_runs(&self.db.conn).await { + tracing::warn!("[pipeline] failed to interrupt running runs on boot: {e}"); + } + // Attempts outlive their run row otherwise: the UI keeps showing a + // running step, and a stale attempt could still be matched by + // connection id and accept a verdict from a dead agent. + if let Err(e) = pipeline_service::interrupt_all_running_attempts(&self.db.conn).await { + tracing::warn!("[pipeline] failed to close running attempts on boot: {e}"); + } + self.active_runs.lock().await.clear(); + self.index.lock().await.clear(); + } +} + +async fn compute_worktree_hash(working_dir: &str) -> String { + let diff_out = crate::process::tokio_command("git") + .args(["diff", "HEAD"]) + .current_dir(working_dir) + .output() + .await + .map(|o| o.stdout) + .unwrap_or_default(); + + let untracked_out = crate::process::tokio_command("git") + .args(["ls-files", "--others", "--exclude-standard"]) + .current_dir(working_dir) + .output() + .await + .map(|o| o.stdout) + .unwrap_or_default(); + + let mut untracked_lines: Vec<&str> = std::str::from_utf8(&untracked_out) + .unwrap_or("") + .lines() + .collect(); + untracked_lines.sort_unstable(); + + let mut hasher = Sha256::new(); + hasher.update(&diff_out); + hasher.update(b"\n---UNTRACKED---\n"); + for line in untracked_lines { + hasher.update(line.as_bytes()); + hasher.update(b"\n"); + } + format!("{:x}", hasher.finalize()) +} + +fn first_chars(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +fn path_separator(path: &str) -> char { + if path.contains('\\') { + '\\' + } else if path.contains('/') { + '/' + } else if is_drive_designator(path) { + '\\' + } else { + '/' + } +} + +fn is_drive_designator(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn is_root_designator(trimmed: &str) -> bool { + if trimmed.is_empty() || is_drive_designator(trimmed) { + return true; + } + let unc = trimmed + .strip_prefix("\\\\") + .or_else(|| trimmed.strip_prefix("//")); + match unc { + Some(rest) => rest.split(['/', '\\']).filter(|s| !s.is_empty()).count() <= 2, + None => false, + } +} + +fn basename(path: &str) -> &str { + let trimmed = path.trim_end_matches(['/', '\\']); + if is_drive_designator(trimmed) { + return ""; + } + match trimmed.rfind(['/', '\\']) { + Some(idx) => &trimmed[idx + 1..], + None => trimmed, + } +} + +fn sibling_path(root_path: &str, name: &str) -> String { + let trimmed = root_path.trim_end_matches(['/', '\\']); + let separator = path_separator(root_path); + if is_root_designator(trimmed) { + return format!("{trimmed}{separator}{name}"); + } + match trimmed.rfind(['/', '\\']) { + Some(idx) => format!("{}{}{}", &trimmed[..idx], separator, name), + None => name.to_string(), + } +} + +fn short_suffix(run_id: i32) -> String { + format!("r{run_id}p") +} diff --git a/src-tauri/src/pipeline/git_status.rs b/src-tauri/src/pipeline/git_status.rs new file mode 100644 index 0000000000..3e47458774 --- /dev/null +++ b/src-tauri/src/pipeline/git_status.rs @@ -0,0 +1,80 @@ +use crate::app_error::AppCommandError; + +/// File status from git diff --name-status. +#[derive(Debug, Clone)] +pub struct FileStatus { + pub file: String, + pub status: String, // "A", "M", "D", "R" + pub additions: i32, + pub deletions: i32, +} + +/// Get file statuses by combining git diff --name-status and git ls-files. +pub async fn get_file_statuses(path: &str, anchor: &str) -> Result, AppCommandError> { + let mut statuses = get_diff_statuses(path, anchor).await?; + let untracked = get_untracked_files(path).await?; + + for file in untracked { + statuses.push(FileStatus { + file, + status: "A".to_string(), + additions: 0, + deletions: 0, + }); + } + + Ok(statuses) +} + +async fn get_diff_statuses(path: &str, anchor: &str) -> Result, AppCommandError> { + let output = crate::process::tokio_command("git") + .args(["diff", "--name-status", anchor]) + .current_dir(path) + .output() + .await + .map_err(|e| AppCommandError::io_error(format!("{}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AppCommandError::io_error(format!( + "git diff --name-status: {}", + stderr + ))); + } + + let mut statuses = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let mut parts = line.splitn(2, '\t'); + if let (Some(status), Some(file)) = (parts.next(), parts.next()) { + let status = status.trim(); + statuses.push(FileStatus { + file: file.to_string(), + status: status.to_string(), + additions: 0, + deletions: 0, + }); + } + } + + Ok(statuses) +} + +async fn get_untracked_files(path: &str) -> Result, AppCommandError> { + let output = crate::process::tokio_command("git") + .args(["ls-files", "--others", "--exclude-standard"]) + .current_dir(path) + .output() + .await + .map_err(|e| AppCommandError::io_error(format!("{}", e)))?; + + if !output.status.success() { + return Ok(Vec::new()); // Silently fail for untracked, not critical + } + + let files = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|s| s.to_string()) + .collect(); + + Ok(files) +} diff --git a/src-tauri/src/pipeline/mod.rs b/src-tauri/src/pipeline/mod.rs new file mode 100644 index 0000000000..ee042e65d1 --- /dev/null +++ b/src-tauri/src/pipeline/mod.rs @@ -0,0 +1,9 @@ +pub mod engine; +pub mod git_status; +pub mod presets; +pub mod tests; +pub mod validate; +pub mod verdict; + +#[cfg(test)] +mod e2e_tests; diff --git a/src-tauri/src/pipeline/presets.rs b/src-tauri/src/pipeline/presets.rs new file mode 100644 index 0000000000..7e3441f802 --- /dev/null +++ b/src-tauri/src/pipeline/presets.rs @@ -0,0 +1,143 @@ +use std::collections::BTreeMap; + +use crate::models::{AgentType, LoopBack, PipelineGraph, PipelineRole, PipelineStep}; + +/// Instruction appended to reviewer/tests prompts so the agent actually +/// reports a verdict instead of leaving the step `inconclusive` by default. +const VERDICT_INSTRUCTION: &str = + "Call the pipeline_verdict tool exactly once, right before you finish."; + +fn default_agent(agent: Option<&str>) -> String { + agent + .filter(|slug| AgentType::from_wire(slug).is_some()) + .unwrap_or("claude_code") + .to_string() +} + +fn step( + id: &'static str, + role: PipelineRole, + label: &'static str, + read_only: bool, + agent_type: &str, +) -> PipelineStep { + let prompt_template = if matches!(role, PipelineRole::Reviewer | PipelineRole::Tests) { + format!("$task\n\n$plan\n$summary\n$review\n$memory\n\n{VERDICT_INSTRUCTION}") + } else { + "$task\n\n$plan\n$summary\n$review\n$memory".into() + }; + PipelineStep { + id: id.into(), + role, + label: label.into(), + agent_type: agent_type.into(), + mode_id: None, + config_values: BTreeMap::new(), + prompt_template, + timeout_secs: 1800, + read_memory: false, + read_only, + } +} + +/// Built-in preset graphs. `agent` selects the agent slug used for every +/// step, falling back to `claude_code` when absent or unknown (the real +/// per-agent default from `DelegationConfig.agent_defaults` is wired in by +/// the engine in a later phase). +pub fn builtin_presets(agent: Option<&str>) -> Vec<(&'static str, &'static str, PipelineGraph)> { + let agent = default_agent(agent); + vec![ + ( + "duet", + "Duet", + PipelineGraph { + steps: vec![ + step("coder", PipelineRole::Coder, "Coder", false, &agent), + step("reviewer", PipelineRole::Reviewer, "Reviewer", true, &agent), + ], + loops: vec![LoopBack { + from_step: "reviewer".into(), + to_step: "coder".into(), + max_iterations: 3, + }], + }, + ), + ( + "team", + "Team", + PipelineGraph { + steps: vec![ + step("planner", PipelineRole::Planner, "Planner", true, &agent), + step("coder", PipelineRole::Coder, "Coder", false, &agent), + step("reviewer", PipelineRole::Reviewer, "Reviewer", true, &agent), + step("tests", PipelineRole::Tests, "Tests", false, &agent), + ], + loops: vec![ + LoopBack { + from_step: "reviewer".into(), + to_step: "coder".into(), + max_iterations: 3, + }, + LoopBack { + from_step: "tests".into(), + to_step: "coder".into(), + max_iterations: 3, + }, + ], + }, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::validate::validate_graph; + + #[test] + fn builtin_presets_pass_validation() { + for (key, _name, graph) in builtin_presets(None) { + assert_eq!(validate_graph(&graph), Ok(()), "preset {key} is invalid"); + } + } + + #[test] + fn tests_step_is_not_read_only() { + let (_, _, graph) = builtin_presets(None) + .into_iter() + .find(|(key, _, _)| *key == "team") + .expect("team preset"); + let tests_step = graph + .steps + .iter() + .find(|s| s.role == PipelineRole::Tests) + .expect("tests step"); + assert!(!tests_step.read_only); + } + + #[test] + fn reviewer_and_tests_prompts_ask_for_verdict() { + for (_, _, graph) in builtin_presets(None) { + for s in &graph.steps { + if matches!(s.role, PipelineRole::Reviewer | PipelineRole::Tests) { + assert!(s.prompt_template.contains("pipeline_verdict")); + } + } + } + } + + #[test] + fn unknown_agent_falls_back_to_claude_code() { + let (_, _, graph) = builtin_presets(Some("not-a-real-agent")) + .into_iter() + .next() + .unwrap(); + assert!(graph.steps.iter().all(|s| s.agent_type == "claude_code")); + } + + #[test] + fn known_agent_is_used_for_every_step() { + let (_, _, graph) = builtin_presets(Some("codex")).into_iter().next().unwrap(); + assert!(graph.steps.iter().all(|s| s.agent_type == "codex")); + } +} diff --git a/src-tauri/src/pipeline/tests.rs b/src-tauri/src/pipeline/tests.rs new file mode 100644 index 0000000000..118e70fc9b --- /dev/null +++ b/src-tauri/src/pipeline/tests.rs @@ -0,0 +1,843 @@ +//! Unit tests for the pipeline engine using mock connections and event simulation. + +#[cfg(test)] +mod engine_tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use crate::acp::manager::ConnectionManager; + use crate::acp::types::{AcpEvent, EventEnvelope}; + use crate::acp::InternalEventBus; + use crate::db::service::pipeline_service; + use crate::db::test_helpers::fresh_in_memory_db; + use crate::db::AppDatabase; + use crate::models::{ + AgentType, AttemptStatus, LoopBack, PipelineGraph, PipelineIsolation, PipelineRole, + PipelineRunRequest, PipelineRunStatus, PipelineStep, PipelineVerdict, + }; + use crate::pipeline::engine::{build_engine, PipelineEngine}; + use crate::web::event_bridge::EventEmitter; + + fn test_graph() -> PipelineGraph { + PipelineGraph { + steps: vec![ + PipelineStep { + id: "coder".into(), + role: PipelineRole::Coder, + label: "Coder".into(), + agent_type: "claude_code".into(), + mode_id: None, + config_values: BTreeMap::new(), + prompt_template: "$task".into(), + timeout_secs: 1800, + read_memory: false, + read_only: false, + }, + PipelineStep { + id: "reviewer".into(), + role: PipelineRole::Reviewer, + label: "Reviewer".into(), + agent_type: "claude_code".into(), + mode_id: None, + config_values: BTreeMap::new(), + prompt_template: "$task\n$review".into(), + timeout_secs: 1800, + read_memory: false, + read_only: true, + }, + ], + loops: vec![LoopBack { + from_step: "reviewer".into(), + to_step: "coder".into(), + max_iterations: 3, + }], + } + } + + async fn setup_engine() -> ( + Arc, + AppDatabase, + ConnectionManager, + Arc, + ) { + let db = fresh_in_memory_db().await; + let metrics = Arc::new(crate::acp::internal_bus::EventBusMetrics::default()); + let bus = Arc::new(InternalEventBus::new(metrics)); + let emitter = EventEmitter::Noop; + let manager = ConnectionManager::new(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let engine = build_engine( + AppDatabase { + conn: db.conn.clone(), + }, + manager.clone_ref(), + emitter, + bus.clone(), + temp_dir.path().to_path_buf(), + ) + .expect("build engine"); + (engine, db, manager, bus) + } + + fn turn_complete_env(conn_id: &str, stop_reason: &str) -> EventEnvelope { + EventEnvelope { + seq: 0, + connection_id: conn_id.to_string(), + payload: AcpEvent::TurnComplete { + session_id: conn_id.to_string(), + stop_reason: stop_reason.to_string(), + agent_type: "claude_code".to_string(), + }, + } + } + + #[tokio::test] + async fn create_run_initializes_properly() { + let db = fresh_in_memory_db().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + + assert_eq!(run.status, PipelineRunStatus::Running); + assert_eq!(run.folder_id, 1); + assert_eq!(run.current_step_id, None); + assert_eq!(run.attempts.len(), 0); + } + + #[tokio::test] + async fn create_attempt_tracks_connection() { + let db = fresh_in_memory_db().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + + let attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "coder".into(), + 1, + Some("conn-123".into()), + Some("sonnet".into()), + ) + .await + .expect("create attempt"); + + assert_eq!(attempt.status, AttemptStatus::Running); + assert_eq!(attempt.step_id, "coder"); + assert_eq!(attempt.iteration, 1); + assert_eq!(attempt.model_requested, Some("sonnet".into())); + } + + #[tokio::test] + async fn cas_attempt_status_only_succeeds_from_expected_state() { + let db = fresh_in_memory_db().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + + let attempt = + pipeline_service::create_attempt(&db.conn, run.id, "coder".into(), 1, None, None) + .await + .expect("create attempt"); + + // Wrong `from` state: CAS must fail + let failed = pipeline_service::cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Done, + AttemptStatus::Failed, + ) + .await + .expect("cas"); + assert!(!failed); + + // Correct `from` state: CAS succeeds + let ok = pipeline_service::cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Running, + AttemptStatus::Done, + ) + .await + .expect("cas"); + assert!(ok); + + // Now it's in Done state, second transition from Running fails + let repeat = pipeline_service::cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Running, + AttemptStatus::Failed, + ) + .await + .expect("cas"); + assert!(!repeat); + } + + #[tokio::test] + async fn update_run_status_cas_settles_status_once() { + let db = fresh_in_memory_db().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + + let ok = pipeline_service::update_run_status( + &db.conn, + run.id, + PipelineRunStatus::Succeeded, + None, + ) + .await + .expect("cas"); + assert!(ok); + + let repeat = + pipeline_service::update_run_status(&db.conn, run.id, PipelineRunStatus::Failed, None) + .await + .expect("cas"); + assert!(!repeat); + + let reloaded = pipeline_service::get_run(&db.conn, run.id) + .await + .expect("get"); + assert_eq!(reloaded.status, PipelineRunStatus::Succeeded); + } + + #[tokio::test] + async fn duplicate_verdict_for_same_attempt_ignored() { + let db = fresh_in_memory_db().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::WorktreePerRun, + None, + None, + ) + .await + .expect("create run"); + + let attempt = + pipeline_service::create_attempt(&db.conn, run.id, "reviewer".into(), 1, None, None) + .await + .expect("create attempt"); + + pipeline_service::set_attempt_verdict( + &db.conn, + attempt.id, + PipelineVerdict::Pass, + Some("tool".into()), + Some("all good".into()), + ) + .await + .expect("set verdict"); + + let first = pipeline_service::get_attempt(&db.conn, attempt.id) + .await + .expect("get") + .expect("found"); + assert_eq!(first.verdict, Some(PipelineVerdict::Pass)); + assert_eq!(first.notes, Some("all good".into())); + } + + #[tokio::test] + async fn step_order_advances_on_pass() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let attempt1 = pipeline_service::create_attempt( + &db.conn, + run.id, + "coder".into(), + 1, + Some("conn-coder".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-coder", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + // Simulate engine index + { + let (state, _) = manager + .get_state_and_emitter("conn-coder") + .await + .expect("state"); + state.write().await.last_assistant_text = + Some("Implemented feature.\n\nVERDICT: PASS".into()); + } + + // Trigger on_event for step 0 ("coder") + engine + .record_verdict("conn-coder", PipelineVerdict::Pass, Some("code looks good")) + .await; + + // Manually trigger turn complete + engine + .on_event(&turn_complete_env("conn-coder", "end_turn")) + .await; + + // Verify attempt 1 was marked Done with Pass + let att1 = pipeline_service::get_attempt(&db.conn, attempt1.id) + .await + .expect("query") + .expect("found"); + assert_eq!(att1.status, AttemptStatus::Done); + assert_eq!(att1.verdict, Some(PipelineVerdict::Pass)); + } + + #[tokio::test] + async fn changes_requested_loops_back_to_coder_with_full_notes() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "reviewer".into(), + 1, + Some("conn-rev".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-rev", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + // Record verdict with notes + engine + .record_verdict( + "conn-rev", + PipelineVerdict::ChangesRequested, + Some("Fix line 42: null pointer"), + ) + .await; + + let notes = pipeline_service::get_last_changes_requested_notes(&db.conn, run.id) + .await + .expect("notes"); + assert_eq!(notes, Some("Fix line 42: null pointer".into())); + + // Turn complete settles attempt + engine + .on_event(&turn_complete_env("conn-rev", "end_turn")) + .await; + + let att = pipeline_service::get_attempt(&db.conn, attempt.id) + .await + .expect("query") + .expect("found"); + assert_eq!(att.status, AttemptStatus::Done); + assert_eq!(att.verdict, Some(PipelineVerdict::ChangesRequested)); + assert_eq!(att.notes, Some("Fix line 42: null pointer".into())); + } + + #[tokio::test] + async fn iteration_limit_stops_max_iterations() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let _attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "reviewer".into(), + 3, // 3rd iteration = max_iterations + Some("conn-rev-3".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-rev-3", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + engine + .record_verdict( + "conn-rev-3", + PipelineVerdict::ChangesRequested, + Some("Still broken"), + ) + .await; + + engine + .on_event(&turn_complete_env("conn-rev-3", "end_turn")) + .await; + + let reloaded_run = pipeline_service::get_run(&db.conn, run.id) + .await + .expect("get run"); + assert_eq!(reloaded_run.status, PipelineRunStatus::StoppedMaxIterations); + } + + #[tokio::test] + async fn pass_on_last_step_completes_run() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let _attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "reviewer".into(), + 1, + Some("conn-rev-pass".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-rev-pass", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + engine + .record_verdict("conn-rev-pass", PipelineVerdict::Pass, Some("All clean!")) + .await; + + engine + .on_event(&turn_complete_env("conn-rev-pass", "end_turn")) + .await; + + let reloaded_run = pipeline_service::get_run(&db.conn, run.id) + .await + .expect("get run"); + assert_eq!(reloaded_run.status, PipelineRunStatus::Succeeded); + } + + #[tokio::test] + async fn inconclusive_stops_pipeline_run() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let _attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "reviewer".into(), + 1, + Some("conn-rev-inc".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-rev-inc", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + engine + .record_verdict( + "conn-rev-inc", + PipelineVerdict::Inconclusive, + Some("Ambiguous requirements"), + ) + .await; + + engine + .on_event(&turn_complete_env("conn-rev-inc", "end_turn")) + .await; + + let reloaded_run = pipeline_service::get_run(&db.conn, run.id) + .await + .expect("get run"); + assert_eq!(reloaded_run.status, PipelineRunStatus::Inconclusive); + } + + #[tokio::test] + async fn late_verdict_of_past_attempt_ignored() { + let (engine, db, _, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "coder".into(), + 1, + Some("conn-expired".into()), + None, + ) + .await + .expect("create attempt"); + + // A verdict that arrives after its attempt already finished belongs to a + // superseded connection and must be dropped. + pipeline_service::cas_attempt_status( + &db.conn, + attempt.id, + AttemptStatus::Running, + AttemptStatus::Done, + ) + .await + .expect("finish attempt"); + + let ack = engine + .record_verdict("conn-expired", PipelineVerdict::Pass, None) + .await; + assert!( + !ack.recorded, + "verdict for an already finished attempt must be rejected" + ); + + // A still running attempt that is missing from the live index (the app + // restarted while the child agent stayed connected) is resolved from the + // database, so its verdict is kept. + let live = pipeline_service::create_attempt( + &db.conn, + run.id, + "coder".into(), + 2, + Some("conn-live".into()), + None, + ) + .await + .expect("create attempt"); + let ack = engine + .record_verdict("conn-live", PipelineVerdict::Pass, None) + .await; + assert!(ack.recorded, "verdict for a running attempt must be kept"); + let stored = pipeline_service::get_attempt(&db.conn, live.id) + .await + .expect("query") + .expect("found"); + assert_eq!(stored.verdict, Some(PipelineVerdict::Pass)); + } + + #[tokio::test] + async fn duplicate_turn_complete_handled() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "coder".into(), + 1, + Some("conn-dup".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-dup", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + // First TurnComplete + engine + .on_event(&turn_complete_env("conn-dup", "end_turn")) + .await; + + // Second duplicate TurnComplete + engine + .on_event(&turn_complete_env("conn-dup", "end_turn")) + .await; + + let att = pipeline_service::get_attempt(&db.conn, attempt.id) + .await + .expect("query") + .expect("found"); + assert_eq!(att.status, AttemptStatus::Done); + } + + #[tokio::test] + async fn cancel_waits_and_does_not_launch_next_step() { + let (engine, db, _, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + engine.cancel(run.id).await.expect("cancel"); + + let reloaded_run = pipeline_service::get_run(&db.conn, run.id) + .await + .expect("get run"); + assert_eq!(reloaded_run.status, PipelineRunStatus::Cancelled); + } + + #[tokio::test] + async fn second_run_in_same_folder_rejected() { + let (engine, db, _, _) = setup_engine().await; + let graph = test_graph(); + let _run = pipeline_service::create_run( + &db.conn, + None, + 42, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + // Attempting to start another run in folder 42 + let req = PipelineRunRequest { + folder_id: 42, + pipeline_id: None, + graph: Some(graph), + isolation: Some(PipelineIsolation::SharedInRoot), + prompt_blocks: vec![], + display_text: "new task".into(), + parent_conversation_id: None, + }; + + let err = engine.start(req).await.unwrap_err(); + assert!(err.contains("pipeline already running in this folder")); + } + + #[tokio::test] + async fn modified_tree_on_reviewer_yields_inconclusive_guard() { + let (engine, db, manager, _) = setup_engine().await; + let graph = test_graph(); + let run = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run"); + + let attempt = pipeline_service::create_attempt( + &db.conn, + run.id, + "reviewer".into(), + 1, + Some("conn-guard-test".into()), + None, + ) + .await + .expect("create attempt"); + + manager + .insert_test_connection_live( + "conn-guard-test", + AgentType::ClaudeCode, + None, + EventEmitter::Noop, + ) + .await; + + // Set verdict Pass + engine + .record_verdict("conn-guard-test", PipelineVerdict::Pass, None) + .await; + + // Even though Pass was recorded, if the reviewer modified files and hash differs, + // it triggers guard + engine + .on_event(&turn_complete_env("conn-guard-test", "end_turn")) + .await; + + let att = pipeline_service::get_attempt(&db.conn, attempt.id) + .await + .expect("query") + .expect("found"); + assert_eq!(att.status, AttemptStatus::Done); + } + + #[tokio::test] + async fn recover_on_boot_interrupts_running_runs() { + let (engine, db, _, _) = setup_engine().await; + let graph = test_graph(); + let run1 = pipeline_service::create_run( + &db.conn, + None, + 1, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run 1"); + + let run2 = pipeline_service::create_run( + &db.conn, + None, + 2, + &graph, + PipelineIsolation::SharedInRoot, + None, + None, + ) + .await + .expect("create run 2"); + + assert_eq!(run1.status, PipelineRunStatus::Running); + assert_eq!(run2.status, PipelineRunStatus::Running); + + engine.recover_on_boot().await; + + let r1 = pipeline_service::get_run(&db.conn, run1.id) + .await + .expect("get run 1"); + let r2 = pipeline_service::get_run(&db.conn, run2.id) + .await + .expect("get run 2"); + + assert_eq!(r1.status, PipelineRunStatus::Interrupted); + assert_eq!(r2.status, PipelineRunStatus::Interrupted); + } +} diff --git a/src-tauri/src/pipeline/validate.rs b/src-tauri/src/pipeline/validate.rs new file mode 100644 index 0000000000..59734429c5 --- /dev/null +++ b/src-tauri/src/pipeline/validate.rs @@ -0,0 +1,344 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::models::{AgentType, PipelineGraph, PipelineRole}; + +#[derive(Debug, Clone, Serialize, Deserialize, Error, PartialEq, Eq)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum PipelineValidationError { + #[error("pipeline must contain at least one step")] + Empty, + #[error("pipeline cannot contain more than 8 steps")] + TooManySteps, + #[error("duplicate step id: {id}")] + DuplicateStepId { id: String }, + #[error("invalid step id: {id}")] + BadStepId { id: String }, + #[error("unknown agent for step {id}")] + UnknownAgent { id: String }, + #[error("loop source has an invalid role: {from}")] + LoopFromWrongRole { from: String }, + #[error("loop target is not earlier: {from} -> {to}")] + LoopTargetNotEarlier { from: String, to: String }, + #[error("duplicate loop from step: {from}")] + DuplicateLoop { from: String }, + #[error("invalid max iterations for loop from step: {from}")] + BadMaxIterations { from: String }, + #[error("step prompt is empty: {id}")] + EmptyPrompt { id: String }, + #[error("invalid timeout for step: {id}")] + BadTimeout { id: String }, +} + +pub fn validate_graph(graph: &PipelineGraph) -> Result<(), PipelineValidationError> { + if graph.steps.is_empty() { + return Err(PipelineValidationError::Empty); + } + if graph.steps.len() > 8 { + return Err(PipelineValidationError::TooManySteps); + } + + let mut ids = HashSet::new(); + for step in &graph.steps { + let valid_id = !step.id.is_empty() + && step.id.len() <= 32 + && step.id.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }); + if !valid_id { + return Err(PipelineValidationError::BadStepId { + id: step.id.clone(), + }); + } + if !ids.insert(step.id.clone()) { + return Err(PipelineValidationError::DuplicateStepId { + id: step.id.clone(), + }); + } + if AgentType::from_wire(&step.agent_type).is_none() { + return Err(PipelineValidationError::UnknownAgent { + id: step.id.clone(), + }); + } + if step.prompt_template.trim().is_empty() { + return Err(PipelineValidationError::EmptyPrompt { + id: step.id.clone(), + }); + } + if !(1..=86_400).contains(&step.timeout_secs) { + return Err(PipelineValidationError::BadTimeout { + id: step.id.clone(), + }); + } + } + + let positions = graph + .steps + .iter() + .enumerate() + .map(|(index, step)| (step.id.as_str(), index)) + .collect::>(); + let mut sources = HashSet::new(); + for loop_back in &graph.loops { + if !sources.insert(loop_back.from_step.clone()) { + return Err(PipelineValidationError::DuplicateLoop { + from: loop_back.from_step.clone(), + }); + } + let source_index = positions.get(loop_back.from_step.as_str()).copied(); + let target_index = positions.get(loop_back.to_step.as_str()).copied(); + let Some(source_index) = source_index else { + return Err(PipelineValidationError::LoopFromWrongRole { + from: loop_back.from_step.clone(), + }); + }; + let source_role = graph.steps[source_index].role; + if !matches!(source_role, PipelineRole::Reviewer | PipelineRole::Tests) { + return Err(PipelineValidationError::LoopFromWrongRole { + from: loop_back.from_step.clone(), + }); + } + let Some(target_index) = target_index else { + return Err(PipelineValidationError::LoopTargetNotEarlier { + from: loop_back.from_step.clone(), + to: loop_back.to_step.clone(), + }); + }; + if target_index >= source_index { + return Err(PipelineValidationError::LoopTargetNotEarlier { + from: loop_back.from_step.clone(), + to: loop_back.to_step.clone(), + }); + } + if !(1..=10).contains(&loop_back.max_iterations) { + return Err(PipelineValidationError::BadMaxIterations { + from: loop_back.from_step.clone(), + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::PipelineStep; + + fn step(id: &str, role: PipelineRole) -> PipelineStep { + PipelineStep { + id: id.to_string(), + role, + label: id.to_string(), + agent_type: "claude_code".to_string(), + mode_id: None, + config_values: Default::default(), + prompt_template: "$task".to_string(), + timeout_secs: 1800, + read_memory: false, + read_only: false, + } + } + + #[test] + fn validates_loops_and_defaults() { + let graph = PipelineGraph { + steps: vec![ + step("coder", PipelineRole::Coder), + step("review", PipelineRole::Reviewer), + ], + loops: vec![crate::models::LoopBack { + from_step: "review".into(), + to_step: "coder".into(), + max_iterations: 3, + }], + }; + assert_eq!(validate_graph(&graph), Ok(())); + assert_eq!(serde_json::from_str::(r#"{"id":"x","role":"coder","label":"x","agent_type":"codex","prompt_template":"$task"}"#).unwrap().timeout_secs, 1800); + } + + #[test] + fn rejects_bad_inputs() { + assert_eq!( + validate_graph(&PipelineGraph::default()), + Err(PipelineValidationError::Empty) + ); + let mut graph = PipelineGraph { + steps: vec![step("Coder", PipelineRole::Coder)], + loops: vec![], + }; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::BadStepId { .. }) + )); + graph.steps[0].id = "coder".into(); + graph.steps[0].agent_type = "unknown".into(); + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::UnknownAgent { .. }) + )); + } + + #[test] + fn rejects_too_many_steps() { + let graph = PipelineGraph { + steps: (0..9) + .map(|i| step(&format!("s{i}"), PipelineRole::Coder)) + .collect(), + loops: vec![], + }; + assert_eq!( + validate_graph(&graph), + Err(PipelineValidationError::TooManySteps) + ); + } + + #[test] + fn rejects_duplicate_step_id() { + let graph = PipelineGraph { + steps: vec![ + step("coder", PipelineRole::Coder), + step("coder", PipelineRole::Reviewer), + ], + loops: vec![], + }; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::DuplicateStepId { .. }) + )); + } + + #[test] + fn rejects_empty_prompt() { + let mut graph = PipelineGraph { + steps: vec![step("coder", PipelineRole::Coder)], + loops: vec![], + }; + graph.steps[0].prompt_template = " ".into(); + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::EmptyPrompt { .. }) + )); + } + + #[test] + fn rejects_bad_timeout() { + let mut graph = PipelineGraph { + steps: vec![step("coder", PipelineRole::Coder)], + loops: vec![], + }; + graph.steps[0].timeout_secs = 0; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::BadTimeout { .. }) + )); + } + + #[test] + fn rejects_loop_from_wrong_role() { + let graph = PipelineGraph { + steps: vec![ + step("coder", PipelineRole::Coder), + step("coder2", PipelineRole::Coder), + ], + loops: vec![crate::models::LoopBack { + from_step: "coder2".into(), + to_step: "coder".into(), + max_iterations: 3, + }], + }; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::LoopFromWrongRole { .. }) + )); + } + + #[test] + fn rejects_loop_target_not_earlier() { + let graph = PipelineGraph { + steps: vec![ + step("review", PipelineRole::Reviewer), + step("coder", PipelineRole::Coder), + ], + loops: vec![crate::models::LoopBack { + from_step: "review".into(), + to_step: "coder".into(), + max_iterations: 3, + }], + }; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::LoopTargetNotEarlier { .. }) + )); + } + + #[test] + fn rejects_duplicate_loop() { + let graph = PipelineGraph { + steps: vec![ + step("coder", PipelineRole::Coder), + step("review", PipelineRole::Reviewer), + step("tests", PipelineRole::Tests), + ], + loops: vec![ + crate::models::LoopBack { + from_step: "review".into(), + to_step: "coder".into(), + max_iterations: 3, + }, + crate::models::LoopBack { + from_step: "review".into(), + to_step: "coder".into(), + max_iterations: 2, + }, + ], + }; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::DuplicateLoop { .. }) + )); + } + + #[test] + fn rejects_bad_max_iterations() { + let graph = PipelineGraph { + steps: vec![ + step("coder", PipelineRole::Coder), + step("review", PipelineRole::Reviewer), + ], + loops: vec![crate::models::LoopBack { + from_step: "review".into(), + to_step: "coder".into(), + max_iterations: 0, + }], + }; + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::BadMaxIterations { .. }) + )); + } + + #[test] + fn accepts_custom_agent_with_nonempty_id() { + let mut graph = PipelineGraph { + steps: vec![step("coder", PipelineRole::Coder)], + loops: vec![], + }; + graph.steps[0].agent_type = "custom:my-agent".into(); + assert_eq!(validate_graph(&graph), Ok(())); + } + + #[test] + fn rejects_custom_agent_with_empty_id() { + let mut graph = PipelineGraph { + steps: vec![step("coder", PipelineRole::Coder)], + loops: vec![], + }; + graph.steps[0].agent_type = "custom:".into(); + assert!(matches!( + validate_graph(&graph), + Err(PipelineValidationError::UnknownAgent { .. }) + )); + } +} diff --git a/src-tauri/src/pipeline/verdict.rs b/src-tauri/src/pipeline/verdict.rs new file mode 100644 index 0000000000..b73c9b6a08 --- /dev/null +++ b/src-tauri/src/pipeline/verdict.rs @@ -0,0 +1,143 @@ +//! Parse `VERDICT:` markers and template variable substitution for pipeline prompts. + +use crate::models::PipelineVerdict; + +/// Attempt to parse a `VERDICT: ...` marker from text. +/// Returns `(verdict, notes)` on success. +/// Notes = text after the marker (if non-empty) or before (if after is empty). +pub fn parse_marker(text: &str) -> Option<(PipelineVerdict, Option)> { + let lines: Vec<&str> = text.lines().collect(); + for (idx, line) in lines.iter().enumerate().rev() { + let trimmed = line.trim(); + let clean = trimmed.trim_matches(|c: char| c == '*' || c == '_' || c == '`' || c == '#'); + if clean.starts_with("VERDICT:") { + let verdict_part = clean.strip_prefix("VERDICT:")?.trim(); + let verdict_token = + verdict_part.trim_matches(|c: char| !c.is_alphanumeric() && c != '_'); + let verdict = match verdict_token.to_uppercase().as_str() { + "PASS" => PipelineVerdict::Pass, + "CHANGES_REQUESTED" | "FAIL" => PipelineVerdict::ChangesRequested, + "INCONCLUSIVE" => PipelineVerdict::Inconclusive, + _ => return None, + }; + + // Notes: text after marker (if non-empty), else all text before marker + let notes = if idx + 1 < lines.len() { + let after = lines[idx + 1..].join("\n").trim().to_string(); + if !after.is_empty() { + Some(after) + } else { + let before = lines[..idx].join("\n").trim().to_string(); + if !before.is_empty() { + Some(before) + } else { + None + } + } + } else { + let before = lines[..idx].join("\n").trim().to_string(); + if !before.is_empty() { + Some(before) + } else { + None + } + }; + + return Some((verdict, notes)); + } + } + None +} + +pub struct PromptVars { + pub task: String, + pub plan: Option, + pub summary: Option, + pub review: Option, + pub memory: String, +} + +/// Substitute `$` placeholders in a prompt template. +pub fn render_prompt(template: &str, vars: &PromptVars) -> String { + template + .replace("$task", &vars.task) + .replace("$plan", vars.plan.as_deref().unwrap_or("")) + .replace("$summary", vars.summary.as_deref().unwrap_or("")) + .replace("$review", vars.review.as_deref().unwrap_or("")) + .replace("$memory", &vars.memory) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_marker_recognizes_pass() { + let text = "All looks good!\n\nVERDICT: PASS"; + let (verdict, notes) = parse_marker(text).expect("parse"); + assert_eq!(verdict, PipelineVerdict::Pass); + assert_eq!(notes, Some("All looks good!".into())); + } + + #[test] + fn parse_marker_recognizes_fail_as_changes_requested() { + let text = "Line 5 is wrong.\n\nVERDICT: FAIL"; + let (verdict, notes) = parse_marker(text).expect("parse"); + assert_eq!(verdict, PipelineVerdict::ChangesRequested); + assert_eq!(notes, Some("Line 5 is wrong.".into())); + } + + #[test] + fn parse_marker_recognizes_inconclusive() { + let text = "Not sure what's happening here.\n\nVERDICT: INCONCLUSIVE"; + let (verdict, notes) = parse_marker(text).expect("parse"); + assert_eq!(verdict, PipelineVerdict::Inconclusive); + assert_eq!(notes, Some("Not sure what's happening here.".into())); + } + + #[test] + fn parse_marker_uses_text_after_if_present() { + let text = "Context before.\n\nVERDICT: PASS\n\nNotes after marker."; + let (verdict, notes) = parse_marker(text).expect("parse"); + assert_eq!(verdict, PipelineVerdict::Pass); + assert_eq!(notes, Some("Notes after marker.".into())); + } + + #[test] + fn parse_marker_returns_none_for_missing_verdict() { + let text = "No verdict here."; + assert_eq!(parse_marker(text), None); + } + + #[test] + fn render_prompt_substitutes_all_placeholders() { + let template = "Task: $task\nPlan: $plan\nSummary: $summary\nReview: $review\nMemory: $memory"; + let vars = PromptVars { + task: "write code".into(), + plan: Some("plan text".into()), + summary: Some("summary text".into()), + review: Some("review text".into()), + memory: "memory block".into(), + }; + let result = render_prompt(template, &vars); + assert!(result.contains("Task: write code")); + assert!(result.contains("Plan: plan text")); + assert!(result.contains("Summary: summary text")); + assert!(result.contains("Review: review text")); + assert!(result.contains("Memory: memory block")); + } + + #[test] + fn render_prompt_handles_missing_optional_vars() { + let template = "Plan: $plan"; + let vars = PromptVars { + task: "x".into(), + plan: None, + summary: None, + review: None, + memory: "".into(), + }; + let result = render_prompt(template, &vars); + assert_eq!(result, "Plan: "); + } +} diff --git a/src-tauri/src/web/auth.rs b/src-tauri/src/web/auth.rs index 4e4d9630f1..1d24149ff6 100644 --- a/src-tauri/src/web/auth.rs +++ b/src-tauri/src/web/auth.rs @@ -6,6 +6,8 @@ use axum::{ }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use crate::office_watch::constant_time_eq; + pub const WS_EVENT_PROTOCOL: &str = "codeg-events"; const WS_TOKEN_PROTOCOL_PREFIX: &str = "codeg-token."; @@ -27,7 +29,10 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo if let Some(auth_header) = request.headers().get("authorization") { if let Ok(auth_str) = auth_header.to_str() { - if auth_str.strip_prefix("Bearer ").is_some_and(|t| t == token) { + if auth_str + .strip_prefix("Bearer ") + .is_some_and(|t| constant_time_eq(t.as_bytes(), token.as_bytes())) + { return next.run(request).await; } } @@ -35,7 +40,9 @@ pub async fn require_token(request: Request, next: Next, token: String) -> Respo if let Some(protocol_header) = request.headers().get("sec-websocket-protocol") { if let Ok(protocols) = protocol_header.to_str() { - if token_from_ws_protocols(protocols).is_some_and(|t| t == token) { + if token_from_ws_protocols(protocols) + .is_some_and(|t| constant_time_eq(t.as_bytes(), token.as_bytes())) + { return next.run(request).await; } } @@ -63,4 +70,68 @@ mod tests { fn ignores_invalid_ws_protocol_token() { assert!(token_from_ws_protocols("codeg-events, codeg-token.not-valid-@@@@").is_none()); } + + #[test] + fn constant_time_eq_uses_timing_safe_comparison() { + // Verify constant_time_eq is used for Bearer token matching. + // Bearer token comparison on line 34 uses constant_time_eq to prevent + // timing side-channel attacks. + assert!(constant_time_eq(b"secret-token", b"secret-token")); + assert!(!constant_time_eq(b"secret-token", b"wrong-token")); + assert!(!constant_time_eq(b"secret-token", b"secret-othe")); + assert!(!constant_time_eq(b"secret", b"secret-token")); + } + + #[tokio::test] + async fn require_token_middleware_verifies_bearer() { + use axum::body::Body; + use axum::http::Request; + use axum::middleware; + use axum::routing::get; + use axum::Router; + use tower::util::ServiceExt; + + let token = "correct-secret-token-12345".to_string(); + let token_for_mw = token.clone(); + let app = Router::new() + .route("/test", get(|| async { "ok" })) + .layer(middleware::from_fn(move |req, next| { + require_token(req, next, token_for_mw.clone()) + })); + + // Valid Bearer -> 200 + let req = Request::builder() + .uri("/test") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // Wrong token of same length -> 401 + let wrong_same_len = "wrong-secret-token-1234567"; + assert_eq!(wrong_same_len.len(), token.len()); + let req = Request::builder() + .uri("/test") + .header("authorization", format!("Bearer {wrong_same_len}")) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + // Prefix of valid token -> 401 + let prefix_token = &token[..token.len() / 2]; + let req = Request::builder() + .uri("/test") + .header("authorization", format!("Bearer {prefix_token}")) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + // Missing authorization header -> 401 + let req = Request::builder().uri("/test").body(Body::empty()).unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + } } diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs index eee7cf4e48..23bec2c1c3 100644 --- a/src-tauri/src/web/event_bridge.rs +++ b/src-tauri/src/web/event_bridge.rs @@ -5,6 +5,7 @@ use serde::{ser::SerializeStruct, Serialize, Serializer}; use tokio::sync::{broadcast, RwLock}; use crate::acp::{AcpEvent, EventBusMetrics, EventEnvelope, InternalEventBus, SessionState}; +use crate::models::{PipelineRunStatus, PipelineVerdict}; /// Broadcast-delivered event. /// @@ -348,6 +349,38 @@ pub struct TabsChanged { /// started or settled. pub const AUTOMATION_CHANGED_EVENT: &str = "automation://changed"; +pub const PIPELINE_CHANGED_EVENT: &str = "pipeline://changed"; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PipelineChange { + Upsert { + id: i32, + }, + Deleted { + id: i32, + }, + RunStarted { + run_id: i32, + folder_id: i32, + }, + StepStarted { + run_id: i32, + attempt_id: i32, + step_id: String, + iteration: u32, + }, + StepSettled { + run_id: i32, + attempt_id: i32, + verdict: Option, + }, + RunSettled { + run_id: i32, + status: PipelineRunStatus, + }, +} + /// Payload for [`AUTOMATION_CHANGED_EVENT`]. Carries only ids — clients refetch /// the affected automation / its runs. All variants are small, so no boxing is /// needed (unlike [`ConversationChange`]). diff --git a/src-tauri/src/web/handlers/backup.rs b/src-tauri/src/web/handlers/backup.rs index 9c6d7991ec..b7adf5ca55 100644 --- a/src-tauri/src/web/handlers/backup.rs +++ b/src-tauri/src/web/handlers/backup.rs @@ -33,6 +33,8 @@ use crate::workspace_transfer::{DownloadKind, DownloadTicketIssued, DownloadTick const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Grace period before an undownloaded export archive is reaped. const EXPORT_REAP_SECS: u64 = 120; +/// Default maximum bytes for backup upload. Set via `CODEG_BACKUP_UPLOAD_MAX_BYTES` env. +const BACKUP_UPLOAD_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024; // 4 GiB #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -159,16 +161,17 @@ pub async fn backup_upload( let id = uuid::Uuid::new_v4().simple().to_string(); let dest = upload_dir.join(format!("{id}.bin")); - // Optional hard size cap (default unlimited, matching the attachment-upload - // convention). Operators on shared deployments can bound it via env. + // Hard size cap defaults to BACKUP_UPLOAD_MAX_BYTES. Operators on shared + // deployments can override it via `CODEG_BACKUP_UPLOAD_MAX_BYTES` env. let max_bytes = std::env::var("CODEG_BACKUP_UPLOAD_MAX_BYTES") .ok() .and_then(|v| v.trim().parse::().ok()) - .filter(|v| *v > 0); + .filter(|v| *v > 0) + .unwrap_or(BACKUP_UPLOAD_MAX_BYTES); // Stream into the temp file; on ANY failure (read error, write error, over // cap) delete the partial file so a failed/aborted upload doesn't linger. - match receive_upload(&mut multipart, &dest, max_bytes).await { + match receive_upload(&mut multipart, &dest, Some(max_bytes)).await { Ok(file_name) => Ok(Json(UploadResult { upload_id: id, file_name, @@ -416,3 +419,28 @@ fn resolve_upload(state: &AppState, upload_id: &str) -> Result().ok().filter(|v| *v > 0); + assert_eq!(result, expected, "parsing '{}' failed", input); + } + } +} diff --git a/src-tauri/src/web/handlers/files.rs b/src-tauri/src/web/handlers/files.rs index 9c5bee6fe8..148de133a1 100644 --- a/src-tauri/src/web/handlers/files.rs +++ b/src-tauri/src/web/handlers/files.rs @@ -1,7 +1,8 @@ -use axum::extract::Multipart; +use axum::extract::{Extension, Multipart}; use axum::http::HeaderMap; use axum::Json; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tokio::io::AsyncWriteExt; use std::collections::BTreeMap; @@ -10,6 +11,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::app_error::{ AppCommandError, UPLOAD_I18N_KEY_QUOTA_EXCEEDED, UPLOAD_I18N_KEY_TOO_LARGE, }; +use crate::app_state::AppState; use crate::commands::folders as folder_commands; use crate::paths::{codeg_uploads_root, simplify_verbatim_path}; @@ -106,22 +108,28 @@ pub struct CreateFileTreeEntryParams { // --------------------------------------------------------------------------- pub async fn read_file_preview( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::read_file_preview(params.root_path, params.path).await?; Ok(Json(result)) } pub async fn read_file_base64( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_path_in_registered_folder(&state.db, ¶ms.path).await?; let result = folder_commands::read_file_base64(params.path, params.max_bytes).await?; Ok(Json(result)) } pub async fn read_workspace_file_base64( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::read_workspace_file_base64( params.root_path, params.path, @@ -132,15 +140,19 @@ pub async fn read_workspace_file_base64( } pub async fn read_file_for_edit( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::read_file_for_edit(params.root_path, params.path).await?; Ok(Json(result)) } pub async fn save_file_content( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::save_file_content( params.root_path, params.path, @@ -152,16 +164,20 @@ pub async fn save_file_content( } pub async fn save_file_copy( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::save_file_copy(params.root_path, params.path, params.content).await?; Ok(Json(result)) } pub async fn rename_file_tree_entry( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::rename_file_tree_entry(params.root_path, params.path, params.new_name) .await?; @@ -169,8 +185,10 @@ pub async fn rename_file_tree_entry( } pub async fn move_file_tree_entry( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::move_file_tree_entry( params.root_path, params.source_path, @@ -181,15 +199,19 @@ pub async fn move_file_tree_entry( } pub async fn delete_file_tree_entry( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; folder_commands::delete_file_tree_entry(params.root_path, params.path).await?; Ok(Json(())) } pub async fn create_file_tree_entry( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { + folder_commands::ensure_registered_root(&state.db, ¶ms.root_path).await?; let result = folder_commands::create_file_tree_entry( params.root_path, params.path, diff --git a/src-tauri/src/web/handlers/memory.rs b/src-tauri/src/web/handlers/memory.rs new file mode 100644 index 0000000000..acc7d7269a --- /dev/null +++ b/src-tauri/src/web/handlers/memory.rs @@ -0,0 +1,92 @@ +use axum::{extract::Extension, Json}; +/// HTTP handlers for memory API. +use std::sync::Arc; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::memory as core; +use crate::memory::MemoryHit; +use crate::models::{MemoryKind, MemorySettings}; + +/// Get memory settings. +pub async fn get_memory_settings( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(core::memory_settings_get_core(&state.db).await?)) +} + +/// Set memory settings. +pub async fn set_memory_settings( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::memory_settings_set_core(&state.emitter, &state.db, params.settings).await?, + )) +} + +/// List memory kinds. +pub async fn list_memory_kinds( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + Ok(Json(core::memory_kind_list_core(&state.db).await?)) +} + +/// Create a custom memory kind. +pub async fn create_memory_kind( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::memory_kind_create_core(&state.emitter, &state.db, params.draft).await?, + )) +} + +/// Update a memory kind. +pub async fn update_memory_kind( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::memory_kind_update_core(&state.emitter, &state.db, params.id, params.draft).await?, + )) +} + +/// Set enabled flag for a memory kind. +pub async fn set_enabled_memory_kind( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::memory_kind_set_enabled_core(&state.emitter, &state.db, params.id, params.enabled) + .await?, + )) +} + +/// Delete a memory kind. +pub async fn delete_memory_kind( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::memory_kind_delete_core(&state.emitter, &state.db, params.id).await?; + Ok(Json(())) +} + +/// Search memory. +pub async fn search_memory( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::memory_search_core(&state.db, params.query, params.limit).await?, + )) +} + +/// Delete a memory node. +pub async fn delete_memory_node( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::memory_node_delete_core(&state.emitter, &state.db, params.id).await?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 3c96367a10..73fd3b2074 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -23,10 +23,12 @@ pub mod git; pub mod logging; pub mod mcp; pub mod mcp_service; +pub mod memory; pub mod model_provider; pub mod office_tools; pub mod office_watch_proxy; pub mod pet; +pub mod pipeline; pub mod project_boot; pub mod question; pub mod quick_messages; diff --git a/src-tauri/src/web/handlers/pipeline.rs b/src-tauri/src/web/handlers/pipeline.rs new file mode 100644 index 0000000000..3d40581686 --- /dev/null +++ b/src-tauri/src/web/handlers/pipeline.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::pipeline as core; +use crate::models::{PipelineInfo, PipelineRunInfo}; + +pub async fn pipeline_list( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::pipeline_list_core(&state.db, params.folder_id).await?, + )) +} + +pub async fn pipeline_get( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json(core::pipeline_get_core(&state.db, params.id).await?)) +} + +pub async fn pipeline_save( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::pipeline_save_core(&state.emitter, &state.db, params.id, params.draft).await?, + )) +} + +pub async fn pipeline_delete( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + core::pipeline_delete_core(&state.emitter, &state.db, params.id).await?; + Ok(Json(())) +} + +pub async fn pipeline_presets() -> Result>, AppCommandError> { + Ok(Json(core::pipeline_presets_core().await)) +} + +pub async fn pipeline_run( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::pipeline_run_core(&state.emitter, &state.db, params.request).await?, + )) +} + +pub async fn pipeline_cancel( + Json(params): Json, +) -> Result, AppCommandError> { + core::pipeline_cancel_core(params.run_id).await?; + Ok(Json(())) +} + +pub async fn pipeline_run_status( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::pipeline_run_status_core(&state.db, params.run_id).await?, + )) +} + +pub async fn pipeline_runs( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::pipeline_runs_core(&state.db, params.folder_id, params.limit).await?, + )) +} + +pub async fn pipeline_request_changes( + Json(params): Json, +) -> Result, AppCommandError> { + core::pipeline_request_changes_core(params.run_id, params.notes).await?; + Ok(Json(())) +} + +pub async fn pipeline_stop_manual( + Json(params): Json, +) -> Result, AppCommandError> { + core::pipeline_stop_manual_core(params.run_id).await?; + Ok(Json(())) +} + +pub async fn pipeline_run_diff( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::pipeline_run_diff_core(&state.db, params.run_id).await?, + )) +} + +pub async fn pipeline_run_apply( + Json(params): Json, +) -> Result, AppCommandError> { + core::pipeline_run_apply_core(params.run_id, params.strategy).await?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 4875810a42..ab5a20ac96 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -24,7 +24,9 @@ pub fn build_router( static_dir: std::path::PathBuf, shutdown_signal: Arc, ) -> Router { - let cors = CorsLayer::new() + // CORS for public endpoints only (office-watch-proxy iframe and static downloads). + // Other endpoints are same-origin (web client on the same origin). + let public_cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); @@ -1447,6 +1449,78 @@ pub fn build_router( "/automation_cancel_run", post(handlers::automation::automation_cancel_run), ) + // ─── Pipelines ─── + .route("/pipeline_list", post(handlers::pipeline::pipeline_list)) + .route("/pipeline_get", post(handlers::pipeline::pipeline_get)) + .route("/pipeline_save", post(handlers::pipeline::pipeline_save)) + .route( + "/pipeline_delete", + post(handlers::pipeline::pipeline_delete), + ) + .route( + "/pipeline_presets", + post(handlers::pipeline::pipeline_presets), + ) + .route("/pipeline_run", post(handlers::pipeline::pipeline_run)) + .route( + "/pipeline_cancel", + post(handlers::pipeline::pipeline_cancel), + ) + .route( + "/pipeline_run_status", + post(handlers::pipeline::pipeline_run_status), + ) + .route("/pipeline_runs", post(handlers::pipeline::pipeline_runs)) + .route( + "/pipeline_request_changes", + post(handlers::pipeline::pipeline_request_changes), + ) + .route( + "/pipeline_stop_manual", + post(handlers::pipeline::pipeline_stop_manual), + ) + .route( + "/pipeline_run_diff", + post(handlers::pipeline::pipeline_run_diff), + ) + .route( + "/pipeline_run_apply", + post(handlers::pipeline::pipeline_run_apply), + ) + // ─── Memory ─── + .route( + "/memory_settings_get", + post(handlers::memory::get_memory_settings), + ) + .route( + "/memory_settings_set", + post(handlers::memory::set_memory_settings), + ) + .route( + "/memory_kind_list", + post(handlers::memory::list_memory_kinds), + ) + .route( + "/memory_kind_create", + post(handlers::memory::create_memory_kind), + ) + .route( + "/memory_kind_update", + post(handlers::memory::update_memory_kind), + ) + .route( + "/memory_kind_set_enabled", + post(handlers::memory::set_enabled_memory_kind), + ) + .route( + "/memory_kind_delete", + post(handlers::memory::delete_memory_kind), + ) + .route("/memory_search", post(handlers::memory::search_memory)) + .route( + "/memory_node_delete", + post(handlers::memory::delete_memory_node), + ) // ─── Token usage dashboard ─── .route( "/token_usage_report", @@ -1745,7 +1819,7 @@ pub fn build_router( auth::require_token(req, next, token.clone()) })); - // Public endpoints — no token required. + // Public endpoints — no token required. CORS enabled for office-watch-proxy iframe. // The login page needs to read the user's preferred language before // authenticating so it can render in their chosen locale. let public_api = Router::new() @@ -1777,7 +1851,8 @@ pub fn build_router( .route( "/office-watch-proxy/{port}/{*rest}", any(handlers::office_watch_proxy::proxy), - ); + ) + .layer(public_cors); // Wrap every API request in an `http` span (method, path, request id) so a // single request's logs — including auth rejections — are correlatable in @@ -1849,7 +1924,6 @@ pub fn build_router( .merge(ws_route) .fallback_service(fallback) .layer(html_rewrite) - .layer(cors) .layer(Extension(state)) .layer(Extension(shutdown_signal)) // Outermost: compress API JSON and static text assets. Allowlist diff --git a/src/app/settings/memory/page.tsx b/src/app/settings/memory/page.tsx new file mode 100644 index 0000000000..8da766ec51 --- /dev/null +++ b/src/app/settings/memory/page.tsx @@ -0,0 +1,5 @@ +import { MemorySettings } from "@/components/settings/memory-settings" + +export default function SettingsMemoryPage() { + return +} diff --git a/src/components/automations/automation-editor.test.tsx b/src/components/automations/automation-editor.test.tsx new file mode 100644 index 0000000000..0b9f9c3070 --- /dev/null +++ b/src/components/automations/automation-editor.test.tsx @@ -0,0 +1,376 @@ +import { render, screen, waitFor, cleanup } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import enMessages from "@/i18n/messages/en.json" +import { AutomationEditor } from "./automation-editor" +import * as api from "@/lib/api" +import type { Automation, Pipeline } from "@/lib/types" +import { + resetAppWorkspaceStore, + useAppWorkspaceStore, +} from "@/stores/app-workspace-store" + +vi.mock("@/lib/platform", () => ({ + isDesktop: () => false, + openFileDialog: vi.fn(), + openUrl: vi.fn(), + subscribe: vi.fn().mockResolvedValue(() => {}), + onTransportReconnect: vi.fn(() => () => {}), +})) + +vi.mock("@/lib/transport", () => ({ + getActiveRemoteConnectionId: () => null, + isDesktop: () => false, + getTransport: () => ({ + call: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + }), +})) + +vi.mock("@/lib/api", () => ({ + automationComputeNextRun: vi.fn().mockResolvedValue("2026-06-02T09:00:00Z"), + describeAgentOptions: vi.fn().mockResolvedValue({ + modes: null, + available_commands: [], + config_options: [], + }), + pipelineList: vi.fn().mockResolvedValue([]), + scanSkillsDir: vi.fn().mockResolvedValue([]), +})) + +const MOCK_PIPELINES: Pipeline[] = [ + { + id: 1, + name: "Duet (Coder + Reviewer)", + preset_key: "duet", + folder_id: 1, + graph: { steps: [], loops: [] }, + isolation: "worktree_per_run", + created_at: "2026-09-20T00:00:00Z", + updated_at: "2026-09-20T00:00:00Z", + }, + { + id: 2, + name: "Team (Planner + Coder + Reviewer + Tests)", + preset_key: "team", + folder_id: null, + graph: { steps: [], loops: [] }, + isolation: "worktree_per_run", + created_at: "2026-09-20T00:00:00Z", + updated_at: "2026-09-20T00:00:00Z", + }, +] + +describe("AutomationEditor - Pipeline Action & Regression", () => { + beforeEach(() => { + resetAppWorkspaceStore() + useAppWorkspaceStore.setState({ + folders: [ + { + id: 1, + name: "repo", + path: "/path/to/repo", + kind: "regular", + } as never, + { + id: 2, + name: "repo2", + path: "/path/to/repo2", + kind: "regular", + } as never, + ], + }) + vi.clearAllMocks() + vi.mocked(api.pipelineList).mockResolvedValue(MOCK_PIPELINES) + vi.mocked(api.describeAgentOptions).mockResolvedValue({ + modes: null, + available_commands: [], + config_options: [], + }) + }) + + afterEach(() => { + cleanup() + }) + + function renderEditor( + automation: Automation | null = null, + onSubmit = vi.fn().mockResolvedValue(undefined), + onCancel = vi.fn() + ) { + return { + onSubmit, + onCancel, + ...render( + + + + ), + } + } + + it("renders all three action buttons including 'Run pipeline'", () => { + renderEditor() + expect( + screen.getByRole("button", { + name: enMessages.Automations.actionLaunchSession, + }) + ).toBeInTheDocument() + expect( + screen.getByRole("button", { + name: enMessages.Automations.actionEnqueueTask, + }) + ).toBeInTheDocument() + expect( + screen.getByRole("button", { name: enMessages.Pipeline.automationAction }) + ).toBeInTheDocument() + }) + + it("defaults to launch_session and shows session-specific controls", () => { + renderEditor() + const sessionBtn = screen.getByRole("button", { + name: enMessages.Automations.actionLaunchSession, + }) + expect(sessionBtn).toHaveAttribute("aria-pressed", "true") + expect( + screen.getByLabelText(enMessages.Automations.isolationWorktree) + ).toBeInTheDocument() + expect( + screen.queryByLabelText(enMessages.Pipeline.automationPipeline) + ).not.toBeInTheDocument() + }) + + it("switches to 'Run pipeline' and displays the pipeline selector", async () => { + const user = userEvent.setup() + renderEditor() + + const pipelineActionBtn = screen.getByRole("button", { + name: enMessages.Pipeline.automationAction, + }) + await user.click(pipelineActionBtn) + + expect(pipelineActionBtn).toHaveAttribute("aria-pressed", "true") + await waitFor(() => { + expect(vi.mocked(api.pipelineList)).toHaveBeenCalled() + }) + + expect( + screen.getByLabelText(enMessages.Pipeline.automationPipeline) + ).toBeInTheDocument() + // Session isolation checkbox is hidden for pipeline action + expect( + screen.queryByLabelText(enMessages.Automations.isolationWorktree) + ).not.toBeInTheDocument() + }) + + it("submits 'run_pipeline' action with selected pipeline_id", async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + renderEditor(null, onSubmit) + + // Fill name + const nameInput = screen.getByLabelText(enMessages.Automations.name) + await user.type(nameInput, "My Pipeline Automation") + + // Type prompt into rich composer + const promptInput = screen.getByLabelText(enMessages.Automations.prompt) + await user.type(promptInput, "Run daily audit pipeline") + + // Switch action to run_pipeline + const pipelineActionBtn = screen.getByRole("button", { + name: enMessages.Pipeline.automationAction, + }) + await user.click(pipelineActionBtn) + + // Select pipeline from dropdown + const pipelineTrigger = screen.getByLabelText( + enMessages.Pipeline.automationPipeline + ) + await user.click(pipelineTrigger) + const option = await screen.findByText("Duet (Coder + Reviewer)") + await user.click(option) + + // Submit + const saveBtn = screen.getByRole("button", { + name: enMessages.Automations.save, + }) + await user.click(saveBtn) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + const draft = onSubmit.mock.calls[0][0] + expect(draft.name).toBe("My Pipeline Automation") + expect(draft.config.action).toBe("run_pipeline") + expect(draft.config.pipeline_id).toBe(1) + expect(draft.isolation).toBe("worktree_per_run") + }) + + it("initializes existing automation with action='run_pipeline' and preselected pipeline_id", async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + const existingAutomation: Automation = { + id: 42, + name: "Existing Pipeline Automation", + enabled: true, + trigger_kind: "manual", + cron: null, + timezone: "UTC", + next_run_at: null, + agent_type: "claude_code", + root_folder_id: 1, + isolation: "worktree_per_run", + branch: null, + is_remote_branch: false, + config: { + action: "run_pipeline", + pipeline_id: 2, + prompt_blocks: [{ type: "text", text: "Existing pipeline prompt" }], + display_text: "Existing pipeline prompt", + config_values: {}, + }, + last_run_at: null, + last_run_status: null, + last_run_conversation_id: null, + unseen_failures: 0, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + } + + renderEditor(existingAutomation, onSubmit) + + const pipelineActionBtn = screen.getByRole("button", { + name: enMessages.Pipeline.automationAction, + }) + expect(pipelineActionBtn).toHaveAttribute("aria-pressed", "true") + + // The selector should display the selected pipeline name + await waitFor(() => { + expect( + screen.getByLabelText(enMessages.Pipeline.automationPipeline) + ).toHaveTextContent("Team (Planner + Coder + Reviewer + Tests)") + }) + + // Submit and verify pipeline_id is preserved + const saveBtn = screen.getByRole("button", { + name: enMessages.Automations.save, + }) + await user.click(saveBtn) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + const draft = onSubmit.mock.calls[0][0] + expect(draft.config.action).toBe("run_pipeline") + expect(draft.config.pipeline_id).toBe(2) + }) + + it("regression: submits 'launch_session' action without pipeline_id", async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + renderEditor(null, onSubmit) + + const nameInput = screen.getByLabelText(enMessages.Automations.name) + await user.type(nameInput, "Session Automation") + + const promptInput = screen.getByLabelText(enMessages.Automations.prompt) + await user.type(promptInput, "Run session prompt") + + const saveBtn = screen.getByRole("button", { + name: enMessages.Automations.save, + }) + await user.click(saveBtn) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + const draft = onSubmit.mock.calls[0][0] + expect(draft.config.action).toBe("launch_session") + expect(draft.config.pipeline_id).toBeUndefined() + }) + + it("regression: submits 'enqueue_task' action without pipeline_id and forced worktree_per_run", async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + renderEditor(null, onSubmit) + + const nameInput = screen.getByLabelText(enMessages.Automations.name) + await user.type(nameInput, "Enqueue Automation") + + const promptInput = screen.getByLabelText(enMessages.Automations.prompt) + await user.type(promptInput, "Run task prompt") + + const enqueueBtn = screen.getByRole("button", { + name: enMessages.Automations.actionEnqueueTask, + }) + await user.click(enqueueBtn) + + const saveBtn = screen.getByRole("button", { + name: enMessages.Automations.save, + }) + await user.click(saveBtn) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + const draft = onSubmit.mock.calls[0][0] + expect(draft.config.action).toBe("enqueue_task") + expect(draft.config.pipeline_id).toBeUndefined() + expect(draft.isolation).toBe("worktree_per_run") + }) + + it("updates pipeline options and selection when picking a different pipeline", async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + renderEditor(null, onSubmit) + + const nameInput = screen.getByLabelText(enMessages.Automations.name) + await user.type(nameInput, "Pipeline Switch Automation") + + const promptInput = screen.getByLabelText(enMessages.Automations.prompt) + await user.type(promptInput, "Switch pipeline prompt") + + const pipelineActionBtn = screen.getByRole("button", { + name: enMessages.Pipeline.automationAction, + }) + await user.click(pipelineActionBtn) + + // Open select, pick Team + const pipelineTrigger = screen.getByLabelText( + enMessages.Pipeline.automationPipeline + ) + await user.click(pipelineTrigger) + const teamOption = await screen.findByText( + "Team (Planner + Coder + Reviewer + Tests)" + ) + await user.click(teamOption) + + // Open select again, pick Duet + await user.click(pipelineTrigger) + const duetOption = await screen.findByText("Duet (Coder + Reviewer)") + await user.click(duetOption) + + const saveBtn = screen.getByRole("button", { + name: enMessages.Automations.save, + }) + await user.click(saveBtn) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + const draft = onSubmit.mock.calls[0][0] + expect(draft.config.action).toBe("run_pipeline") + expect(draft.config.pipeline_id).toBe(1) + }) +}) diff --git a/src/components/automations/automation-editor.tsx b/src/components/automations/automation-editor.tsx index 0bedc37e84..c06685851d 100644 --- a/src/components/automations/automation-editor.tsx +++ b/src/components/automations/automation-editor.tsx @@ -30,9 +30,16 @@ import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { FolderSelect } from "@/components/shared/folder-select" import { cn } from "@/lib/utils" -import { automationComputeNextRun } from "@/lib/api" +import { automationComputeNextRun, pipelineList } from "@/lib/api" import type { AgentType, Automation, @@ -40,6 +47,7 @@ import type { AutomationDraft, AutomationIsolation, AutomationTriggerKind, + Pipeline, PromptInputBlock, } from "@/lib/types" @@ -76,12 +84,17 @@ export function AutomationEditor({ onBackToTemplates, }: AutomationEditorProps) { const t = useTranslations("Automations") + const tPipeline = useTranslations("Pipeline") const folders = useAppWorkspaceStore((s) => s.folders) const [name, setName] = useState(automation?.name ?? "") const [action, setAction] = useState( automation?.config?.action ?? "launch_session" ) + const [pipelineId, setPipelineId] = useState( + automation?.config?.pipeline_id ?? null + ) + const [pipelines, setPipelines] = useState([]) const [agentType, setAgentType] = useState( automation?.agent_type ?? "claude_code" ) @@ -119,6 +132,22 @@ export function AutomationEditor({ const [nextRun, setNextRun] = useState(null) const [cronBuilderOpen, setCronBuilderOpen] = useState(false) + useEffect(() => { + let cancelled = false + pipelineList(folderId ?? undefined) + .then((list) => { + if (!cancelled) { + setPipelines(list) + } + }) + .catch(() => { + if (!cancelled) setPipelines([]) + }) + return () => { + cancelled = true + } + }, [folderId]) + const editorRef = useRef(null) // The composer's outer box, so the `@` panel spans it like the `/` menu does. const composerBoxRef = useRef(null) @@ -218,6 +247,10 @@ export function AutomationEditor({ if (!displayText) return setError(t("errorPrompt")) if (trigger === "schedule" && !cron.trim()) return setError(t("errorCron")) if (folderId == null) return setError(t("errorFolder")) + // A pipeline automation without a pipeline would fire and immediately fail. + if (action === "run_pipeline" && pipelineId == null) { + return setError(tPipeline("automationPipeline")) + } // Folder selected but its path is still resolving; the probe would be global. // The Save button is disabled in this state, so this is a race-safety net — // bail silently and let it re-enable once the path resolves. @@ -263,9 +296,10 @@ export function AutomationEditor({ ...snapshotLabels(snapshot, mode_id, config_values), } - // Enqueue-task automations never run in place: canonicalize the + // Enqueue-task and run-pipeline automations never run in place: canonicalize the // session-only fields so the stored row can't carry a stale branch. const enqueue = action === "enqueue_task" + const runPipeline = action === "run_pipeline" const draft: AutomationDraft = { name: name.trim(), // Enable/disable lives on the detail header + row menu now; preserve an @@ -276,13 +310,19 @@ export function AutomationEditor({ timezone, agent_type: persistedAgentType, root_folder_id: folderId, - isolation: enqueue ? "worktree_per_run" : isolation, + isolation: enqueue || runPipeline ? "worktree_per_run" : isolation, branch: - !enqueue && isolation === "shared_in_root" && branch.trim() + !enqueue && + !runPipeline && + isolation === "shared_in_root" && + branch.trim() ? branch.trim() : null, is_remote_branch: - !enqueue && isolation === "shared_in_root" && branch.trim() + !enqueue && + !runPipeline && + isolation === "shared_in_root" && + branch.trim() ? isRemoteBranch : false, config: fellBackToSubstitute @@ -290,6 +330,7 @@ export function AutomationEditor({ // Preserve the original agent's saved config verbatim; only the // user-editable prompt is refreshed. action, + ...(runPipeline ? { pipeline_id: pipelineId } : {}), prompt_blocks: blocks, display_text: displayText, mode_id: automation.config?.mode_id ?? null, @@ -299,6 +340,7 @@ export function AutomationEditor({ } : { action, + ...(runPipeline ? { pipeline_id: pipelineId } : {}), prompt_blocks: blocks, display_text: displayText, mode_id, @@ -411,8 +453,8 @@ export function AutomationEditor({ - {/* Action — what a fire does: launch a session (legacy) or enqueue a - task on the folder's board. */} + {/* Action — what a fire does: launch a session (legacy), enqueue a + task on the folder's board, or run a pipeline. */}

{t("sectionAction")} @@ -426,6 +468,7 @@ export function AutomationEditor({ [ { value: "launch_session", label: t("actionLaunchSession") }, { value: "enqueue_task", label: t("actionEnqueueTask") }, + { value: "run_pipeline", label: tPipeline("automationAction") }, ] as Array<{ value: AutomationAction; label: string }> ).map((opt) => ( + {selectedStep && ( + + )} +

+ + {selectedStep && ( + + i < graph.steps.findIndex((x) => x.id === selectedStep.id) && + (selectedStep.role === "reviewer" || + selectedStep.role === "tests") + )} + open={inspectorOpen} + onClose={() => setInspectorOpen(false)} + onSave={handleSaveStep} + onDeleteStep={() => handleDeleteStep(selectedStep.id)} + /> + )} + + ) +} + +export const PipelineNode = memo(function PipelineNode({ + data, +}: NodeProps) { + const { pipelineId } = data + + return ( +
+
+ {data.label} +
+
+ +
+
+ ) +}) diff --git a/src/components/canvas/pipeline-step-inspector.test.tsx b/src/components/canvas/pipeline-step-inspector.test.tsx new file mode 100644 index 0000000000..ac625e776b --- /dev/null +++ b/src/components/canvas/pipeline-step-inspector.test.tsx @@ -0,0 +1,211 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" +import enMessages from "@/i18n/messages/en.json" +import type { LoopBack, PipelineStep } from "@/lib/types" +import { PipelineStepInspector } from "./pipeline-step-inspector" + +vi.mock("@/hooks/use-acp-agents", () => ({ + useAcpAgents: () => ({ + agents: [ + { agent_type: "claude_code", name: "Claude Code", enabled: true }, + { agent_type: "codex", name: "Codex", enabled: true }, + { agent_type: "gemini", name: "Gemini", enabled: true }, + ], + fresh: true, + refresh: vi.fn(), + }), +})) + +function makeStep(over: Partial = {}): PipelineStep { + return { + id: "coder_1", + role: "coder", + label: "Coder", + agent_type: "claude_code", + mode_id: null, + config_values: { model: "claude-3-7-sonnet" }, + prompt_template: "$task", + timeout_secs: 1800, + read_memory: false, + read_only: false, + ...over, + } +} + +function renderInspector(props: { + step?: PipelineStep + loop?: LoopBack | null + availableLoopTargets?: PipelineStep[] + open?: boolean + onClose?: () => void + onSave?: (step: PipelineStep, loop?: LoopBack | null) => void + onDeleteStep?: (id: string) => void +}) { + const defaultProps = { + step: makeStep(), + loop: null, + availableLoopTargets: [], + open: true, + onClose: vi.fn(), + onSave: vi.fn(), + ...props, + } + + return { + ...render( + + + + ), + props: defaultProps, + } +} + +describe("PipelineStepInspector", () => { + it("renders with step data populated", () => { + const step = makeStep({ + id: "coder_step", + label: "Custom Coder", + config_values: { model: "gpt-4o" }, + prompt_template: "Write unit tests for $task", + timeout_secs: 1200, + }) + + renderInspector({ step }) + + expect(screen.getByText("Step settings")).toBeDefined() + expect(screen.getByDisplayValue("coder_step")).toBeDefined() + expect(screen.getByDisplayValue("Custom Coder")).toBeDefined() + expect(screen.getByDisplayValue("gpt-4o")).toBeDefined() + expect(screen.getByDisplayValue("Write unit tests for $task")).toBeDefined() + expect(screen.getByDisplayValue("20")).toBeDefined() // 1200 / 60 = 20 min + }) + + it("updates fields and calls onSave with modified step data", async () => { + const onSave = vi.fn() + const onClose = vi.fn() + const step = makeStep() + + renderInspector({ step, onSave, onClose }) + + const labelInput = screen.getByDisplayValue("Coder") + fireEvent.change(labelInput, { target: { value: "Lead Coder" } }) + + const promptInput = screen.getByDisplayValue("$task") + fireEvent.change(promptInput, { + target: { value: "Implement $task and summarize in $summary" }, + }) + + const timeoutInput = screen.getByDisplayValue("30") + fireEvent.change(timeoutInput, { target: { value: "45" } }) + + const saveButton = screen.getByRole("button", { name: "Save" }) + fireEvent.click(saveButton) + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + id: "coder_1", + label: "Lead Coder", + prompt_template: "Implement $task and summarize in $summary", + timeout_secs: 2700, // 45 * 60 + }), + null + ) + expect(onClose).toHaveBeenCalled() + }) + + it("validates empty prompt and prevents saving", () => { + const onSave = vi.fn() + const step = makeStep({ prompt_template: "" }) + + renderInspector({ step, onSave }) + + const promptInput = screen.getByPlaceholderText("$task") + fireEvent.change(promptInput, { target: { value: " " } }) + + const saveButton = screen.getByRole("button", { name: "Save" }) + fireEvent.click(saveButton) + + expect(onSave).not.toHaveBeenCalled() + expect(screen.getByText(/empty prompt/i)).toBeDefined() + }) + + it("validates bad step id and prevents saving", () => { + const onSave = vi.fn() + const step = makeStep() + + renderInspector({ step, onSave }) + + const idInput = screen.getByDisplayValue("coder_1") + fireEvent.change(idInput, { target: { value: "INVALID ID WITH SPACES" } }) + + const saveButton = screen.getByRole("button", { name: "Save" }) + fireEvent.click(saveButton) + + expect(onSave).not.toHaveBeenCalled() + expect(screen.getByText(/is invalid/i)).toBeDefined() + }) + + it("shows read-only unsupported warning for agents without read-only mode", () => { + const step = makeStep({ + role: "reviewer", + agent_type: "open_code", + read_only: true, + }) + + renderInspector({ step }) + + expect( + screen.getByText( + "This agent has no read-only mode; the reviewer may edit files" + ) + ).toBeDefined() + }) + + it("supports configuring loopback for reviewer steps", () => { + const onSave = vi.fn() + const coderStep = makeStep({ id: "coder_step", role: "coder" }) + const reviewerStep = makeStep({ + id: "reviewer_step", + role: "reviewer", + label: "Reviewer", + }) + + renderInspector({ + step: reviewerStep, + availableLoopTargets: [coderStep], + onSave, + }) + + expect(screen.getByText("on changes requested")).toBeDefined() + + // Enable loopback + const loopSwitch = screen.getByRole("switch", { name: "" }) // loop switch + fireEvent.click(loopSwitch) + + const saveButton = screen.getByRole("button", { name: "Save" }) + fireEvent.click(saveButton) + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ id: "reviewer_step" }), + expect.objectContaining({ + from_step: "reviewer_step", + to_step: "coder_step", + max_iterations: 3, + }) + ) + }) + + it("calls onDeleteStep when delete is confirmed", () => { + const onDeleteStep = vi.fn() + const step = makeStep({ id: "to_delete" }) + + renderInspector({ step, onDeleteStep }) + + const deleteBtn = screen.getByRole("button", { name: /delete/i }) + fireEvent.click(deleteBtn) + + expect(onDeleteStep).toHaveBeenCalledWith("to_delete") + }) +}) diff --git a/src/components/canvas/pipeline-step-inspector.tsx b/src/components/canvas/pipeline-step-inspector.tsx new file mode 100644 index 0000000000..9c0777f3c2 --- /dev/null +++ b/src/components/canvas/pipeline-step-inspector.tsx @@ -0,0 +1,612 @@ +"use client" + +import { useEffect, useMemo, useReducer } from "react" +import { useTranslations } from "next-intl" +import { + AlertCircle, + Code2, + FlaskConical, + ListTodo, + RotateCcw, + ShieldCheck, + Sparkles, + Trash2, + type LucideIcon, +} from "lucide-react" +import { useAcpAgents } from "@/hooks/use-acp-agents" +import { getAgentLabel } from "@/lib/custom-agents" +import type { LoopBack, PipelineRole, PipelineStep } from "@/lib/types" +import { BUILTIN_AGENT_TYPES, STEP_ID_REGEX } from "@/lib/pipeline-graph-edit" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" + +export interface PipelineStepInspectorProps { + step: PipelineStep + loop?: LoopBack | null + availableLoopTargets?: PipelineStep[] + open: boolean + onClose: () => void + onSave: (step: PipelineStep, loop?: LoopBack | null) => void | Promise + onDeleteStep?: (stepId: string) => void + className?: string +} + +const ROLE_ICONS: Record = { + planner: ListTodo, + coder: Code2, + reviewer: ShieldCheck, + tests: FlaskConical, + custom: Sparkles, +} + +const READ_ONLY_SUPPORTED_AGENTS = new Set([ + "claude_code", + "codex", + "gemini", +]) + +export function PipelineStepInspector({ + step, + loop, + availableLoopTargets = [], + open, + onClose, + onSave, + onDeleteStep, + className, +}: PipelineStepInspectorProps) { + const t = useTranslations("Pipeline") + const { agents } = useAcpAgents() + + interface StepState { + id: string + label: string + role: PipelineRole + agentType: string + model: string + readOnly: boolean + promptTemplate: string + timeoutMinutes: number + readMemory: boolean + enableLoop: boolean + loopTarget: string + maxIterations: number + validationError: string | null + } + + const getInitialState = useMemo( + () => (): StepState => ({ + id: step.id, + label: step.label, + role: step.role, + agentType: step.agent_type, + model: step.config_values?.model ?? "", + readOnly: step.read_only, + promptTemplate: step.prompt_template, + timeoutMinutes: Math.max(1, Math.round(step.timeout_secs / 60)), + readMemory: step.read_memory, + enableLoop: Boolean(loop), + loopTarget: loop?.to_step ?? availableLoopTargets[0]?.id ?? "", + maxIterations: loop?.max_iterations ?? 3, + validationError: null, + }), + [step, loop, availableLoopTargets] + ) + + const reducer = ( + state: StepState, + action: + | { type: "reset"; payload: StepState } + | { type: "setId"; payload: string } + | { type: "setLabel"; payload: string } + | { type: "setRole"; payload: PipelineRole } + | { type: "setAgentType"; payload: string } + | { type: "setModel"; payload: string } + | { type: "setReadOnly"; payload: boolean } + | { type: "setPromptTemplate"; payload: string } + | { type: "setTimeoutMinutes"; payload: number } + | { type: "setReadMemory"; payload: boolean } + | { type: "setEnableLoop"; payload: boolean } + | { type: "setLoopTarget"; payload: string } + | { type: "setMaxIterations"; payload: number } + | { type: "setValidationError"; payload: string | null } + ): StepState => { + switch (action.type) { + case "reset": + return action.payload + case "setId": + return { ...state, id: action.payload } + case "setLabel": + return { ...state, label: action.payload } + case "setRole": + return { ...state, role: action.payload } + case "setAgentType": + return { ...state, agentType: action.payload } + case "setModel": + return { ...state, model: action.payload } + case "setReadOnly": + return { ...state, readOnly: action.payload } + case "setPromptTemplate": + return { ...state, promptTemplate: action.payload } + case "setTimeoutMinutes": + return { ...state, timeoutMinutes: action.payload } + case "setReadMemory": + return { ...state, readMemory: action.payload } + case "setEnableLoop": + return { ...state, enableLoop: action.payload } + case "setLoopTarget": + return { ...state, loopTarget: action.payload } + case "setMaxIterations": + return { ...state, maxIterations: action.payload } + case "setValidationError": + return { ...state, validationError: action.payload } + default: + return state + } + } + + const [state, dispatch] = useReducer(reducer, undefined, getInitialState) + + const setId = (value: string) => dispatch({ type: "setId", payload: value }) + const setLabel = (value: string) => + dispatch({ type: "setLabel", payload: value }) + const setRole = (value: PipelineRole) => + dispatch({ type: "setRole", payload: value }) + const setAgentType = (value: string) => + dispatch({ type: "setAgentType", payload: value }) + const setModel = (value: string) => + dispatch({ type: "setModel", payload: value }) + const setReadOnly = (value: boolean) => + dispatch({ type: "setReadOnly", payload: value }) + const setPromptTemplate = (value: string) => + dispatch({ type: "setPromptTemplate", payload: value }) + const setTimeoutMinutes = (value: number) => + dispatch({ type: "setTimeoutMinutes", payload: value }) + const setReadMemory = (value: boolean) => + dispatch({ type: "setReadMemory", payload: value }) + const setEnableLoop = (value: boolean) => + dispatch({ type: "setEnableLoop", payload: value }) + const setLoopTarget = (value: string) => + dispatch({ type: "setLoopTarget", payload: value }) + const setMaxIterations = (value: number) => + dispatch({ type: "setMaxIterations", payload: value }) + const setValidationError = (value: string | null) => + dispatch({ type: "setValidationError", payload: value }) + + // Destructure for easier access + const { + id, + label, + role, + agentType, + model, + readOnly, + promptTemplate, + timeoutMinutes, + readMemory, + enableLoop, + loopTarget, + maxIterations, + validationError, + } = state + + // Reset state when step or open changes + useEffect(() => { + dispatch({ type: "reset", payload: getInitialState() }) + }, [getInitialState, open]) + + const agentOptions = useMemo(() => { + const list = agents.map((a) => a.agent_type) + for (const b of BUILTIN_AGENT_TYPES) { + if (!list.includes(b)) list.push(b) + } + if (step.agent_type && !list.includes(step.agent_type)) { + list.push(step.agent_type) + } + return list + }, [agents, step.agent_type]) + + const showReadOnlyWarning = + readOnly && !READ_ONLY_SUPPORTED_AGENTS.has(agentType) + + const canLoop = + (role === "reviewer" || role === "tests") && availableLoopTargets.length > 0 + + const handleSave = async () => { + const trimmedId = id.trim() + if (!trimmedId || !STEP_ID_REGEX.test(trimmedId)) { + setValidationError( + t("validation.badStepId", { id: trimmedId || "empty" }) + ) + return + } + + if (!promptTemplate.trim()) { + setValidationError(t("validation.emptyPrompt", { id: trimmedId })) + return + } + + if ( + timeoutMinutes < 1 || + timeoutMinutes > 1440 || + Number.isNaN(timeoutMinutes) + ) { + setValidationError(t("validation.badTimeout", { id: trimmedId })) + return + } + + const configValues: Record = { ...step.config_values } + if (model.trim()) { + configValues.model = model.trim() + } else { + delete configValues.model + } + + const updatedStep: PipelineStep = { + ...step, + id: trimmedId, + label: label.trim() || trimmedId, + role, + agent_type: agentType, + config_values: configValues, + prompt_template: promptTemplate, + timeout_secs: Math.max(1, Math.round(timeoutMinutes * 60)), + read_memory: readMemory, + read_only: readOnly, + } + + let updatedLoop: LoopBack | null = null + if (canLoop && enableLoop && loopTarget) { + const iterations = Math.min(10, Math.max(1, Math.round(maxIterations))) + updatedLoop = { + from_step: trimmedId, + to_step: loopTarget, + max_iterations: iterations, + } + } + + setValidationError(null) + const result = onSave(updatedStep, updatedLoop) + if (result && typeof (result as Promise).then === "function") { + await result + } + onClose() + } + + const RoleIcon = ROLE_ICONS[role] || Sparkles + + return ( + !isOpen && onClose()}> + + +
+
+ +
+
+ + {t("inspectorTitle")} + + + {step.label || step.id} ({step.role}) + +
+
+
+ + {validationError && ( +
+ + {validationError} +
+ )} + +
+ {/* Step ID & Label */} +
+
+ + setId(e.target.value.toLowerCase().trim())} + placeholder="step_id" + className="font-mono text-xs" + /> +
+
+ + setLabel(e.target.value)} + placeholder="Display label" + className="text-xs" + /> +
+
+ + {/* Role */} +
+ +
+ {( + [ + ["planner", "rolePlanner", ListTodo], + ["coder", "roleCoder", Code2], + ["reviewer", "roleReviewer", ShieldCheck], + ["tests", "roleTests", FlaskConical], + ["custom", "roleCustom", Sparkles], + ] as const + ).map(([rKey, lKey, Icon]) => { + const active = role === rKey + return ( + + ) + })} +
+
+ + {/* Agent & Model */} +
+
+ + +
+ +
+ + setModel(e.target.value)} + placeholder={t("modelUnconfirmed")} + className="text-xs font-mono" + /> +
+
+ + {/* Prompt Template */} +
+
+ + + {t("promptHint")} + +
+