diff --git a/.claude/settings.json b/.claude/settings.json index d86a888d..7ee8e9fe 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,9 @@ "deny": [ "Bash(git revert*)", "Bash(git checkout*)", - "Bash(git rebase*)" + "Bash(git rebase*)", + "Write(docs/superpowers/**)", + "Edit(docs/superpowers/**)" ] } } \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e2977e43..28d4600a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -597,24 +597,36 @@ jobs: git push docker: - needs: [build, release] + needs: [build, build-opr8r, release] runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Download linux binaries + - name: Download linux operator binaries uses: actions/download-artifact@v8 with: pattern: operator-linux-* path: bins merge-multiple: true + # The image ships both halves of the server-client pair, so agent + # sessions inside the container can report step completion via opr8r. + - name: Download linux opr8r binaries + uses: actions/download-artifact@v8 + with: + pattern: opr8r-linux-* + path: bins + merge-multiple: true + # buildx exposes TARGETARCH as amd64/arm64; map the x86_64 artifact name. - name: Stage binaries for build context run: | cp bins/operator-linux-x86_64 ./operator-linux-amd64 cp bins/operator-linux-arm64 ./operator-linux-arm64 - chmod +x operator-linux-amd64 operator-linux-arm64 + cp bins/opr8r-linux-x86_64 ./opr8r-linux-amd64 + cp bins/opr8r-linux-arm64 ./opr8r-linux-arm64 + chmod +x operator-linux-amd64 operator-linux-arm64 \ + opr8r-linux-amd64 opr8r-linux-arm64 - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 diff --git a/.github/workflows/coder-module.yaml b/.github/workflows/coder-module.yaml new file mode 100644 index 00000000..b886af2d --- /dev/null +++ b/.github/workflows/coder-module.yaml @@ -0,0 +1,52 @@ +name: Coder Module + +on: + push: + branches: [main] + paths: + - 'coder-module/**' + - '.github/workflows/coder-module.yaml' + - 'scripts/ci/check-coder-module.sh' + pull_request: + paths: + - 'coder-module/**' + - '.github/workflows/coder-module.yaml' + - 'scripts/ci/check-coder-module.sh' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + test: + name: Validate and Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: coder-module + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Check formatting + run: terraform fmt -check -diff + + - name: Validate + run: | + terraform init -input=false + terraform validate + + # The rendered coder_script is what actually runs in a workspace, so a bash syntax error here is a broken module. + - name: Shell-check the rendered startup script + run: ../scripts/ci/check-coder-module.sh + + - name: Test + run: bun test diff --git a/.gitignore b/.gitignore index 16b74bf1..dbc08f77 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,10 @@ webcomponents/src/generated/ vscode-extension/out/ vscode-extension/.vscode-test/ test-output.txt -.tickets/ \ No newline at end of file +.tickets/ +.proof/ + +# terraform working files left by `bun test` in coder-module/ +coder-module/.terraform/ +coder-module/.terraform.lock.hcl +coder-module/terraform.tfstate* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 92af3b3c..ed9748ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,15 +6,22 @@ ## Tech Stack -- **Language**: Rust -- **TUI Framework**: ratatui (with crossterm backend) -- **Async Runtime**: tokio -- **Notifications**: mac-notification-sys (macOS) -- **Config**: config crate (TOML) -- **File Watching**: notify crate +- **Language**: Rust (core), TypeScript (`ui/`, `webcomponents/`, `vscode-extension/`) +- **TUI**: ratatui (crossterm backend); tokio async runtime +- **Server surfaces**: axum REST API + utoipa OpenAPI (`src/rest/`), MCP server (`src/mcp/`), ACP (`src/acp/`) +- **Web**: Vite/React SPA in `ui/`, shared `webcomponents/` package, thin VS Code extension webview +- **Notifications**: mac-notification-sys (macOS), notify-rust (Linux), webhooks +- **Config**: config crate (TOML); **File Watching**: notify crate ## Code Style -Aim for functional software development with a focus on stateless, single responsibility focus. comments should be terse and used judiciously. +Aim for functional software development with a focus on stateless, single responsibility focus. +Minimize use of comments; they should be terse and used judiciously, ideally one sentence tops. +Data types come from rust; typescript and docs binds are generated from low-level rust types annotated with comments that embed as descriptions into configuration and reference files. +Favor falsey defaults ; lets aim not to enforce `default=true` or some other javascript-truthy default value. + +## Plans & Specs Location + +Write superpowers plans to `superpowers/plans/` and design specs to `superpowers/specs/` (repo root, not hosted). ## Development Standards @@ -26,9 +33,7 @@ Aim for functional software development with a focus on stateless, single respon ### Mandatory Before Committing -All changes MUST pass these checks before committing. Run them with `make check`, -which mirrors the CI `lint-test` job exactly (so a clean local run means a clean -CI run): +All changes MUST pass these checks before committing. Run them with `make check`, which mirrors the CI `lint-test` job exactly: ```bash make check @@ -109,65 +114,68 @@ cargo test # Run specific test cargo run # Run TUI cargo run -- queue # CLI: show queue cargo run -- launch # CLI: launch next ticket +cargo run -- api # REST API + embedded web UI +cargo run -- mcp # MCP server (stdio) +cargo run -- docs # Regenerate auto-generated docs ``` +Full command list: `docs/cli/` (auto-generated). + ## Architecture +Grouped map of `src/` (not exhaustive — `ls src/` for the full list): + ``` src/ -├── main.rs # Entry point, CLI parsing -├── app.rs # Application state and event loop -├── ui/ # TUI rendering -│ ├── mod.rs -│ ├── dashboard.rs # Main dashboard layout -│ ├── queue.rs # Queue panel -│ ├── agents.rs # Agents panel -│ └── dialogs.rs # Confirmation dialogs -├── queue/ # Queue management -│ ├── mod.rs -│ ├── ticket.rs # Ticket parsing -│ ├── watcher.rs # File system watcher -│ └── assigner.rs # Work assignment logic -├── agents/ # Agent lifecycle -│ ├── mod.rs -│ ├── launcher.rs # Claude Desktop integration -│ ├── tracker.rs # Agent state tracking -│ └── session.rs # Session persistence -├── notifications/ # Notification system -│ ├── mod.rs -│ └── macos.rs # macOS notifications -├── config.rs # Configuration management -└── state.rs # Persistent state store +├── main.rs, lib.rs # Entry + CLI parsing; lib/bin split (src/rest compiles +│ # in the lib and must not reference bin-only src/ui) +├── app/ # TUI application state and event loop +├── ui/ # Ratatui rendering: dashboard, panels, dialogs, keybindings +├── queue/ # Ticket parsing, creation, file watcher +├── agents/ # Agent lifecycle: launcher/ (tmux, zellij, cmux, coder, +│ # remote), monitor, activity/idle detection, hooks +├── config.rs, config/ # TOML config: agent profiles, kanban, llm tools, +│ # sessions, targets, git, notifications +├── state.rs # Persistent state store +├── rest/ # REST API + embedded web UI hosting (axum, utoipa) +├── mcp/, acp/ # MCP server; Agent Client Protocol +├── api/, services/ # Kanban/GitHub/PR clients and sync services +├── llm/, permissions/ # LLM tool detection + runtime configs; per-tool +│ # permission translation (claude, codex, gemini) +├── issuetypes/, templates/, collections/ # Issue type schema, registry, +│ # shipped collections +├── taxonomy/, schemas/, docs_gen/ # Source-of-truth data + docs generators +├── workflow_gen/ # Workflow export (Claude .js, AGNT) +├── notifications/ # OS notifications (macOS/Linux) + webhook integrations +└── git/, steps/, relay/, integrations/, startup/, editors.rs, projects.rs, … ``` +Sibling subprojects: `ui/` (SPA), `webcomponents/` (shared JS, built ahead of +`ui/` and `docs/`), `vscode-extension/`, `opr8r/`, `docs/` (hosted Jekyll +site), `collections/community/`. + ## Key Concepts -### Ticket Priority Order -1. INV (Investigation) - Failures, highest priority -2. FIX - Bug fixes -3. FEAT - Features -4. SPIKE - Research (requires pairing) +### Ticket Priority +Queue order is `queue.priority_order` (default INV > FIX > TASK > FEAT > SPIKE), then FIFO. +Issue types are schema-driven (collections + customm templates), not a fixed set. ### Agent Modes -- **Autonomous** (FEAT, FIX): Launch and forget, monitor progress -- **Paired** (SPIKE, INV): Require human interaction, track "awaiting input" +Execution mode is declared per issue type (`mode` in the issuetype schema): +- **Autonomous** (e.g. FEAT, FIX, TASK): launch and monitor progress +- **Paired** (e.g. SPIKE, INV): require human interaction, track "awaiting input" ### Parallelism Rules -- Max agents = min(configured_max, cpu_cores - reserved_cores) -- Autonomous agents can run in parallel across non-intersecting projects +- Effective max agents = max(1, min(`agents.max_parallel`, cpu_cores − `agents.cores_reserved`)) +- Same repo is sequential unless `git.use_worktrees = true`, which allows up to + `agents.max_agents_per_repo` agents in per-ticket worktrees - Paired agents run one at a time per operator attention -- Same project = sequential (to avoid conflicts) ## State Management -Operator state persists in `.operator/`: -``` -.operator/ -├── state.json # Current queue/agent state -├── sessions/ # Agent session logs -│ └── {agent-id}.json -└── history.json # Completed work log -``` +Persistent state lives under `paths.state` (default `.tickets/operator/`); +`state.json` holds queue/agent state — schema documented at `/schemas/state/`. +Per-ticket worktrees default to `~/.operator/worktrees`. ## Ticket Workflow @@ -175,38 +183,33 @@ Operator state persists in `.operator/`: 2. **Sort**: Order by priority, then FIFO timestamp 3. **Assign**: When agent slot available, select next ticket 4. **Confirm**: Prompt operator for launch confirmation -5. **Launch**: Open Claude Desktop with project + ticket prompt +5. **Launch**: Run the agent CLI in the configured session target with the interpolated prompt 6. **Track**: Monitor agent progress, watch for completion 7. **Complete**: Move ticket, notify, update stats -## Claude Desktop Integration +## Agent Launching -Launch command (macOS): -```bash -open -a "Claude" --args --project "/path/to/project" -``` +`src/agents/launcher/` builds the agent CLI command (claude, codex, gemini) with a prompt interpolated from the ticket + issuetype steps, +then runs it in the configured session target: -Initial prompt injected via: -- Clipboard + paste simulation, OR -- Project-specific `.claude/initial-prompt.md`, OR -- AppleScript automation +- Terminal multiplexers: tmux, zellij, cmux +- Editors: VS Code, Cursor, Zed (`src/editors.rs`) +- Remote: SSH hosts and Coder workspaces (`[[targets]]` config) + +Per-ticket git worktrees are prepared by `launcher/worktree_setup.rs`. ## Notifications -macOS notifications via `mac-notification-sys`: -```rust -Notification::new() - .title("Agent Complete") - .subtitle("backend") - .message("FEAT-042: Add pagination") - .send()?; -``` +Dispatched through `src/notifications/service.rs` to the enabled integrations: +OS notifications (mac-notification-sys on macOS, notify-rust on Linux) and +webhooks. ## Project Discovery -On startup, operator scans the configured projects directory for subdirectories containing a `CLAUDE.md` file. These are presented as available projects when creating tickets. +On startup, operator scans the configured projects directory for subdirectories containing an agent marker file (`CLAUDE.md`, `GEMINI.md`, `CODEX.md`). +These are presented as available projects when creating tickets. -## Ticket Workflow +## Working a Ticket ### Before Starting Work diff --git a/Cargo.lock b/Cargo.lock index 0b824728..f1aafa16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,7 +118,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -129,7 +129,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -284,9 +284,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -556,9 +556,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -566,9 +566,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1032,7 +1032,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1514,7 +1514,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1986,7 +1986,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2327,7 +2327,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2533,7 +2533,7 @@ dependencies = [ "sha2 0.11.0", "sysinfo", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "toml", @@ -2875,7 +2875,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2897,7 +2897,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3011,7 +3011,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -3121,7 +3121,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3719,7 +3719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3868,7 +3868,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.61.3", "windows-version", ] @@ -3905,7 +3905,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3982,11 +3982,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -4002,9 +4002,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -4293,7 +4293,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -4362,7 +4362,7 @@ checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ "chrono", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "ts-rs-macros", "uuid", ] @@ -4405,7 +4405,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Dockerfile b/Dockerfile index d01b07dd..217a2461 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,15 +12,19 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates git tmux \ && rm -rf /var/lib/apt/lists/* -# CI stages the prebuilt release binary as operator-linux-${TARGETARCH} -# (operator-linux-x86_64 is renamed to operator-linux-amd64; arm64 matches). +# CI stages the prebuilt release binaries as {operator,opr8r}-linux-${TARGETARCH} +# (the -linux-x86_64 artifacts are renamed to -linux-amd64; arm64 matches). +# Both halves ship: agent sessions launched by the operator server call the +# opr8r client to report step completion for multi-step ticket workflows. COPY operator-linux-${TARGETARCH} /usr/local/bin/operator -RUN chmod +x /usr/local/bin/operator +COPY opr8r-linux-${TARGETARCH} /usr/local/bin/opr8r +RUN chmod +x /usr/local/bin/operator /usr/local/bin/opr8r # Fail the multi-arch build (incl. arm64 under QEMU binfmt from -# setup-qemu-action) before push if the binary can't execute on this base. +# setup-qemu-action) before push if a binary can't execute on this base. # --version short-circuits in clap before any config or tmux load. RUN ["/usr/local/bin/operator", "--version"] +RUN ["/usr/local/bin/opr8r", "--version"] # Run as an unprivileged user with a writable HOME by default. A compromised # agent tool then can't act as root against the mounted workspace. diff --git a/README.md b/README.md index 0eb66a40..3c464b49 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ * **Workflow Export Format** [![Claude Workflow](https://img.shields.io/badge/Claude_Workflow-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/workflows/claude/) [![AGNT Workflow](https://img.shields.io/badge/AGNT_Workflow-6E56CF)](https://operator.untra.io/getting-started/workflows/agnt/) +* **Notification Channel** [![Operating System](https://img.shields.io/badge/Operating_System-333333)](https://operator.untra.io/getting-started/notifications/os/) [![Webhooks](https://img.shields.io/badge/Webhooks-2088FF?logo=webhook&logoColor=white)](https://operator.untra.io/getting-started/notifications/webhooks/) + An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. Install Operator! Terminals extension from Visual Studio Code Marketplace diff --git a/bindings/AgentState.ts b/bindings/AgentState.ts index 4806033f..871bae77 100644 --- a/bindings/AgentState.ts +++ b/bindings/AgentState.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { StepLaunchContext } from "./StepLaunchContext"; export type AgentState = { id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, /** @@ -38,19 +39,19 @@ step_started_at: string | null, */ last_content_change: string | null, /** - * PR URL if created during "pr" step + * PR/MR URL if created during the "pr" step */ pr_url: string | null, /** - * PR number for GitHub API tracking + * Code review request (PR/MR) number */ pr_number: bigint | null, /** - * GitHub repo in format "owner/repo" + * Repository in "owner/repo" format on the configured git provider */ -github_repo: string | null, +repo: string | null, /** - * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + * Last known PR/MR status ("open", "approved", "`changes_requested`", "merged", "closed") */ pr_status: string | null, /** @@ -66,12 +67,13 @@ llm_tool: string | null, */ llm_model: string | null, /** - * Launch mode: "default", "yolo", "docker", "docker-yolo" + * Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` + * (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) */ launch_mode: string | null, /** * Review state for `awaiting_input` agents - * Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" + * Values: "`pending_plan`", "`pending_visual`", "`pending_proof`", "`pending_pr_creation`", "`pending_pr_merge`" */ review_state: string | null, /** @@ -85,4 +87,12 @@ worktree_path: string | null, /** * Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) */ -remote_host: string | null, }; +remote_host: string | null, +/** + * Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. + */ +step_launch_context: StepLaunchContext | null, +/** + * Name of the resolved execution target this agent launched on + */ +target_name: string | null, }; diff --git a/bindings/CoderConfig.ts b/bindings/CoderConfig.ts new file mode 100644 index 00000000..7d530807 --- /dev/null +++ b/bindings/CoderConfig.ts @@ -0,0 +1,47 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Coder workspace target: lifecycle + alias provisioning around the shared + * SSH remote-launch path. There is no `enabled` field — presence in + * `[[targets]]` is the enablement. + */ +export type CoderConfig = { +/** + * Coder template child workspaces are created from (an allowlist — + * never per-ticket input) + */ +template: string, +/** + * Env var NAME holding the Coder deployment URL + */ +url_env: string, +/** + * Env var NAME holding the Coder session token. The variable is stripped + * from every agent's spawn environment on all target kinds. + */ +token_env: string, +/** + * Workspace name prefix for deterministic per-ticket naming + */ +name_prefix: string, +/** + * Project root inside the workspace (None = workspace $HOME) + */ +workdir?: string | null, +/** + * Stop the workspace when the ticket completes (never delete) + */ +stop_on_complete: boolean, +/** + * Bound on workspace create + agent-ready wait + */ +create_timeout_secs: bigint, +/** + * Control-plane-reachable `OPERATOR_API_URL` override for detached + * multi-step (empty/None = reverse tunnel default) + */ +callback_url?: string | null, +/** + * Passthrough `-p` template parameters for `coder create` + */ +parameters?: { [key in string]: string }, }; diff --git a/bindings/Config.ts b/bindings/Config.ts index 079b4682..ed1c0ec9 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -17,6 +17,7 @@ import type { RelayConfig } from "./RelayConfig"; import type { RemoteHost } from "./RemoteHost"; import type { RestApiConfig } from "./RestApiConfig"; import type { SessionsConfig } from "./SessionsConfig"; +import type { TargetDef } from "./TargetDef"; import type { TemplatesConfig } from "./TemplatesConfig"; import type { TmuxConfig } from "./TmuxConfig"; import type { UiConfig } from "./UiConfig"; @@ -53,6 +54,10 @@ model_servers: Array, * from `DelegatorLaunchConfig.host`. */ hosts: Array, +/** + * Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. + */ +targets: Array, /** * Relay MCP injection configuration */ diff --git a/bindings/CreatePrError.ts b/bindings/CreatePrError.ts index 3694931e..16c7a122 100644 --- a/bindings/CreatePrError.ts +++ b/bindings/CreatePrError.ts @@ -3,4 +3,4 @@ /** * Error when creating a PR */ -export type CreatePrError = { "type": "github_cli_not_installed" } | { "type": "github_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "git_remote_not_configured" } | { "type": "target_branch_not_found", branch: string, } | { "type": "branch_not_pushed", branch: string, } | { "type": "pr_already_exists", pr_number: bigint, url: string, } | { "type": "github_api_error", message: string, }; +export type CreatePrError = { "type": "provider_cli_not_installed" } | { "type": "provider_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "git_remote_not_configured" } | { "type": "target_branch_not_found", branch: string, } | { "type": "branch_not_pushed", branch: string, } | { "type": "pr_already_exists", pr_number: bigint, url: string, } | { "type": "provider_api_error", message: string, }; diff --git a/bindings/CreateStepRequest.ts b/bindings/CreateStepRequest.ts index 8e6aaeb6..b57507a1 100644 --- a/bindings/CreateStepRequest.ts +++ b/bindings/CreateStepRequest.ts @@ -5,6 +5,6 @@ */ export type CreateStepRequest = { name: string, display_name: string | null, prompt: string, outputs: Array, allowed_tools: Array, /** - * Type of review required: "none", "plan", "visual", "pr" + * Type of review required: "none", "plan", "visual", "pr", "proof" */ review_type: string, next_step: string | null, permission_mode: string, }; diff --git a/bindings/DelegatorLaunchConfig.ts b/bindings/DelegatorLaunchConfig.ts index d640e63a..d9427d1a 100644 --- a/bindings/DelegatorLaunchConfig.ts +++ b/bindings/DelegatorLaunchConfig.ts @@ -28,7 +28,8 @@ use_worktrees: boolean | null, */ create_branch: boolean | null, /** - * Run in docker container (None = use global `launch.docker.enabled`) + * DEPRECATED: prefer `target`. Run in docker container + * (None = fall back to `launch.docker.enabled`, then local). */ docker: boolean | null, /** @@ -44,7 +45,13 @@ prompt_suffix: string | null, */ operator_relay: boolean | null, /** - * Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent - * CLI on over SSH. `None` = launch locally. + * DEPRECATED: prefer `target`. Name of a declared `RemoteHost` (from + * `Config.hosts`) to launch the agent CLI on over SSH. `None` = local. */ -host?: string | null, }; +host?: string | null, +/** + * Name of an execution target: an explicit `[[targets]]` entry, the + * synthesized `local`/`docker` targets, or a `[[hosts]]` name. + * Supersedes `docker` and `host`. + */ +target?: string | null, }; diff --git a/bindings/DelegatorLaunchConfigDto.ts b/bindings/DelegatorLaunchConfigDto.ts index 586c0ea5..cb69dea9 100644 --- a/bindings/DelegatorLaunchConfigDto.ts +++ b/bindings/DelegatorLaunchConfigDto.ts @@ -44,6 +44,12 @@ prompt_suffix?: string | null, */ operator_relay?: boolean | null, /** - * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local). + * DEPRECATED: prefer `target`. */ -host?: string | null, }; +host?: string | null, +/** + * Name of an execution target (explicit `[[targets]]` entry, synthesized + * `local`/`docker`, or a `[[hosts]]` name). Supersedes `docker`/`host`. + */ +target?: string | null, }; diff --git a/bindings/DetectedTool.ts b/bindings/DetectedTool.ts index 1e12f930..9fe249a8 100644 --- a/bindings/DetectedTool.ts +++ b/bindings/DetectedTool.ts @@ -40,4 +40,8 @@ capabilities: ToolCapabilities, /** * CLI flags for YOLO (auto-accept) mode */ -yolo_flags: Array, }; +yolo_flags: Array, +/** + * Whether the tool passed its health check at detection on startup + */ +health_ok: boolean, }; diff --git a/bindings/GitConfig.ts b/bindings/GitConfig.ts index 21ba3311..25728390 100644 --- a/bindings/GitConfig.ts +++ b/bindings/GitConfig.ts @@ -16,7 +16,7 @@ provider: GitProviderConfig | null, */ github: GitHubConfig, /** - * GitLab-specific configuration (planned) + * GitLab-specific configuration */ gitlab: GitLabConfig, /** diff --git a/bindings/GitLabConfig.ts b/bindings/GitLabConfig.ts index 68508df3..d262d6ed 100644 --- a/bindings/GitLabConfig.ts +++ b/bindings/GitLabConfig.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * GitLab-specific configuration (planned) + * GitLab-specific configuration */ export type GitLabConfig = { /** diff --git a/bindings/GitProvider.ts b/bindings/GitProvider.ts index 6064096f..00a54a93 100644 --- a/bindings/GitProvider.ts +++ b/bindings/GitProvider.ts @@ -3,4 +3,4 @@ /** * Supported Git hosting providers */ -export type GitProvider = "github" | "gitlab" | "bitbucket" | "azuredevops"; +export type GitProvider = "github" | "gitlab" | "bitbucket" | "azuredevops" | "forgejo" | "gitea"; diff --git a/bindings/GitProviderConfig.ts b/bindings/GitProviderConfig.ts index ee0c83b8..4ae4eaee 100644 --- a/bindings/GitProviderConfig.ts +++ b/bindings/GitProviderConfig.ts @@ -3,4 +3,4 @@ /** * Git provider selection */ -export type GitProviderConfig = "github" | "gitlab" | "bitbucket" | "azuredevops"; +export type GitProviderConfig = "github" | "gitlab" | "bitbucket" | "azuredevops" | "forgejo" | "gitea"; diff --git a/bindings/KanbanTicketCard.ts b/bindings/KanbanTicketCard.ts index ccf3e18e..32b95b90 100644 --- a/bindings/KanbanTicketCard.ts +++ b/bindings/KanbanTicketCard.ts @@ -39,4 +39,9 @@ priority: string, /** * Timestamp for sorting (YYYYMMDD-HHMM format) */ -timestamp: string, }; +timestamp: string, +/** + * Ticket markdown filename (joins with the tickets dir + status folder + * for co-located clients; remote clients treat tickets as API-only) + */ +filename: string, }; diff --git a/bindings/LaunchTicketRequest.ts b/bindings/LaunchTicketRequest.ts index 8e6ead2c..d6e13c33 100644 --- a/bindings/LaunchTicketRequest.ts +++ b/bindings/LaunchTicketRequest.ts @@ -21,6 +21,12 @@ model: string | null, * no delegator. Injects the server's base URL / API key env at spawn. */ model_server: string | null, +/** + * Execution-target override by name (explicit `[[targets]]` entry, + * synthesized `local`/`docker`, or a `[[hosts]]` name). Overrides the + * delegator's launch config for this launch only. + */ +target: string | null, /** * Run in YOLO mode (auto-accept all prompts) */ diff --git a/bindings/LaunchTicketResponse.ts b/bindings/LaunchTicketResponse.ts index 8ef6fa7a..5dad010c 100644 --- a/bindings/LaunchTicketResponse.ts +++ b/bindings/LaunchTicketResponse.ts @@ -4,6 +4,12 @@ * Response from launching a ticket */ export type LaunchTicketResponse = { +/** + * True when the server executed the launch itself (non-local targets: + * docker/coder/ssh orchestration is server-side); `command` is then + * empty and the client must NOT run anything. + */ +executed_server_side: boolean, /** * Agent ID assigned to this launch */ diff --git a/bindings/NextStepInfo.ts b/bindings/NextStepInfo.ts index ef87072a..dd9d136a 100644 --- a/bindings/NextStepInfo.ts +++ b/bindings/NextStepInfo.ts @@ -13,7 +13,7 @@ name: string, */ display_name: string, /** - * Review type: "none", "plan", "visual", "pr" + * Review type: "none", "plan", "visual", "pr", "proof" */ review_type: string, /** diff --git a/bindings/ProofReviewConfig.ts b/bindings/ProofReviewConfig.ts new file mode 100644 index 00000000..56dd45d9 --- /dev/null +++ b/bindings/ProofReviewConfig.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Configuration for proof review steps + */ +export type ProofReviewConfig = { +/** + * Assertion command run via `sh -c` in the worktree root; exit code 0 = pass. + * Supports handlebars: `{{ticket_id}}`, `{{step}}`, `{{proof_dir}}` + */ +assertion_command: string, +/** + * Artifact-producing command (e.g. screenshot capture), run after the assertion regardless of its result + */ +artifact_command?: string | null, +/** + * Glob patterns (relative to worktree root) copied into `.proof/{ticket_id}/{step}/` + */ +artifact_patterns: Array, +/** + * Per-command timeout in seconds (default 120) + */ +timeout_secs?: number | null, }; diff --git a/bindings/PullRequestInfo.ts b/bindings/PullRequestInfo.ts index 8d8aeb86..3dadf055 100644 --- a/bindings/PullRequestInfo.ts +++ b/bindings/PullRequestInfo.ts @@ -2,7 +2,7 @@ import type { PrState } from "./PrState"; /** - * PR info returned from GitHub + * PR/MR info returned from the git provider */ export type PullRequestInfo = { /** @@ -10,7 +10,7 @@ export type PullRequestInfo = { */ number: bigint, /** - * PR URL on GitHub + * PR URL on the provider */ url: string, /** diff --git a/bindings/RemoteHost.ts b/bindings/RemoteHost.ts index 1acc12f6..a9910f3d 100644 --- a/bindings/RemoteHost.ts +++ b/bindings/RemoteHost.ts @@ -24,4 +24,8 @@ workdir: string, /** * Optional display name for UI */ -display_name: string | null, }; +display_name: string | null, +/** + * SSH config fragment passed with `-F` (used by provisioned coder aliases) + */ +ssh_config_path?: string | null, }; diff --git a/bindings/ReviewType.ts b/bindings/ReviewType.ts index 63e513b3..38d4df60 100644 --- a/bindings/ReviewType.ts +++ b/bindings/ReviewType.ts @@ -3,4 +3,4 @@ /** * Type of review required for a step */ -export type ReviewType = "none" | "plan" | "visual" | "pr"; +export type ReviewType = "none" | "plan" | "visual" | "pr" | "proof"; diff --git a/bindings/SshTarget.ts b/bindings/SshTarget.ts new file mode 100644 index 00000000..412d6017 --- /dev/null +++ b/bindings/SshTarget.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * SSH target payload. Name and display name live on `TargetDef`; this is the + * connection shape (`RemoteHost` minus identity). + */ +export type SshTarget = { +/** + * Host alias resolved via the user's `~/.ssh/config` (or `ssh_config_path`) + */ +ssh_alias: string, +/** + * Absolute project root on the remote machine + */ +workdir: string, +/** + * SSH config fragment passed with `-F` (used by provisioned coder aliases) + */ +ssh_config_path?: string | null, }; diff --git a/bindings/StepLaunchContext.ts b/bindings/StepLaunchContext.ts new file mode 100644 index 00000000..37004d1b --- /dev/null +++ b/bindings/StepLaunchContext.ts @@ -0,0 +1,45 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Launch context fixed at launch time, persisted with the agent record, and + * read back by `complete_step` to build subsequent step commands. + * + * The persisted context is the baseline for a ticket's whole chain; per-step + * `agent` overrides from the step schema apply on top for that step only. + */ +export type StepLaunchContext = { +/** + * Delegator name used at launch (None = ad-hoc provider/model) + */ +delegator: string | null, +/** + * Resolved LLM tool (e.g. "claude") + */ +tool: string, +/** + * Resolved model alias (e.g. "sonnet") + */ +model: string, +/** + * YOLO (auto-accept) mode + */ +yolo: boolean, +/** + * Session UUID of the step launched with this context (informational; + * each transition mints a fresh UUID for the next step) + */ +session_id: string | null, +/** + * opr8r invocation for the launch environment ("opr8r" inside a + * container where the image ships it on PATH, an absolute path locally). + * Steps exec inside the same environment, so the value holds chain-wide. + */ +opr8r: string, +/** + * Relay MCP injection override from the delegator launch config + */ +operator_relay: boolean | null, +/** + * Extra CLI flags from the delegator launch config + */ +extra_flags: Array, }; diff --git a/bindings/StepResponse.ts b/bindings/StepResponse.ts index 55d90206..8465a31c 100644 --- a/bindings/StepResponse.ts +++ b/bindings/StepResponse.ts @@ -5,6 +5,6 @@ */ export type StepResponse = { name: string, display_name: string | null, prompt: string, outputs: Array, allowed_tools: Array, /** - * Type of review required: "none", "plan", "visual", "pr" + * Type of review required: "none", "plan", "visual", "pr", "proof" */ review_type: string, next_step: string | null, permission_mode: string, }; diff --git a/bindings/StepSchema.ts b/bindings/StepSchema.ts index 1f06fb89..9638da62 100644 --- a/bindings/StepSchema.ts +++ b/bindings/StepSchema.ts @@ -8,6 +8,7 @@ import type { MultiPromptConfig } from "./MultiPromptConfig"; import type { OnReject } from "./OnReject"; import type { PermissionMode } from "./PermissionMode"; import type { PipelineConfig } from "./PipelineConfig"; +import type { ProofReviewConfig } from "./ProofReviewConfig"; import type { ProviderCliArgs } from "./ProviderCliArgs"; import type { RagConfig } from "./RagConfig"; import type { ReviewType } from "./ReviewType"; @@ -42,13 +43,17 @@ outputs: Array, */ prompt: string, /** - * Type of review required for this step (none, plan, visual, pr) + * Type of review required for this step (none, plan, visual, pr, proof) */ review_type: ReviewType, /** * Configuration for visual review (required when `review_type` is "visual") */ visual_config?: VisualReviewConfig | null, +/** + * Configuration for proof review (required when `review_type` is "proof") + */ +proof_config?: ProofReviewConfig | null, /** * What to do if step output is rejected */ diff --git a/bindings/TargetDef.ts b/bindings/TargetDef.ts new file mode 100644 index 00000000..9923a8e0 --- /dev/null +++ b/bindings/TargetDef.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CoderConfig } from "./CoderConfig"; +import type { DockerConfig } from "./DockerConfig"; +import type { SshTarget } from "./SshTarget"; + +/** + * A named execution target agents can be launched on. + */ +export type TargetDef = { +/** + * Unique name, referenced by `DelegatorLaunchConfig.target`. + * `local` and `docker` are reserved for synthesized targets. + */ +name: string, +/** + * Human-readable name for UI surfaces + */ +display_name?: string | null, } & ({ "kind": "local" } | { "kind": "docker" } & DockerConfig | { "kind": "coder" } & CoderConfig | { "kind": "ssh" } & SshTarget); diff --git a/bindings/TargetKind.ts b/bindings/TargetKind.ts new file mode 100644 index 00000000..dc93f7b9 --- /dev/null +++ b/bindings/TargetKind.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CoderConfig } from "./CoderConfig"; +import type { DockerConfig } from "./DockerConfig"; +import type { SshTarget } from "./SshTarget"; + +/** + * Execution-target kind, tagged by `kind` in config. + */ +export type TargetKind = { "kind": "local" } | { "kind": "docker" } & DockerConfig | { "kind": "coder" } & CoderConfig | { "kind": "ssh" } & SshTarget; diff --git a/bindings/UpdateStepRequest.ts b/bindings/UpdateStepRequest.ts index 8590d82c..d742092a 100644 --- a/bindings/UpdateStepRequest.ts +++ b/bindings/UpdateStepRequest.ts @@ -5,6 +5,6 @@ */ export type UpdateStepRequest = { display_name: string | null, prompt: string | null, outputs: Array | null, allowed_tools: Array | null, /** - * Type of review required: "none", "plan", "visual", "pr" + * Type of review required: "none", "plan", "visual", "pr", "proof" */ review_type: string | null, next_step: string | null, permission_mode: string | null, }; diff --git a/bindings/VsCodeLaunchOptions.ts b/bindings/VsCodeLaunchOptions.ts index 5e9553f9..f042a779 100644 --- a/bindings/VsCodeLaunchOptions.ts +++ b/bindings/VsCodeLaunchOptions.ts @@ -20,4 +20,8 @@ yoloMode: boolean, /** * Resume from existing session (uses `session_id` from ticket) */ -resumeSession: boolean, }; +resumeSession: boolean, +/** + * Execution-target override by name (None = delegator/default resolution) + */ +target?: string, }; diff --git a/bump-version.sh b/bump-version.sh index cea051ea..282cb739 100755 --- a/bump-version.sh +++ b/bump-version.sh @@ -34,6 +34,7 @@ TEXT_FILES=( "zed-extension/extension.toml" "vscode-extension/src/webhook-server.ts" "docs/_config.yml" + "coder-module/main.tf" ) # JSON files: update .version via jq diff --git a/coder-module/README.md b/coder-module/README.md index 4c927464..9d6925ed 100644 --- a/coder-module/README.md +++ b/coder-module/README.md @@ -10,7 +10,7 @@ tags: [ai, agents, orchestration, automation] Run [Operator](https://github.com/untra/operator) as a background REST API server inside your Coder workspace. Operator manages ticket queues, launches LLM-powered coding agents, and tracks their progress. -The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. +The module downloads the operator binary and the `opr8r` client from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. ## Usage diff --git a/coder-module/main.tf b/coder-module/main.tf index 3e788a01..7a5e4cb3 100644 --- a/coder-module/main.tf +++ b/coder-module/main.tf @@ -32,10 +32,13 @@ variable "slug" { default = "operator" } +# `default` is revved by bump-version.sh and pinned to the VERSION file by +# tests/version_parity.rs -- an unbumped tag points workspaces at a +# nonexistent GitHub release. variable "install_version" { type = string description = "The version of operator to install (must match a GitHub release tag)." - default = "0.2.0" + default = "0.2.6" } variable "install_prefix" { @@ -99,6 +102,27 @@ variable "offline" { default = false } +# Child-workspace spawning: when set, the generated config gains a coder [[targets]] entry so tickets launched from this workspace create +# sibling agent workspaces from this template. PREREQUISITE: a user session token `coder_token_env must be present +# that token can create, delete, and SSH into every workspace its user owns. +variable "agent_template" { + type = string + description = "Coder template child agent workspaces are created from. Empty disables the coder target." + default = "" +} + +variable "coder_token_env" { + type = string + description = "Name of the env var holding the Coder session token used to spawn agent workspaces." + default = "CODER_SESSION_TOKEN" +} + +variable "callback_url" { + type = string + description = "Control-plane-reachable OPERATOR_API_URL override written into the coder target, so detached multi-step survives SSH tunnel loss. Empty keeps the reverse-tunnel default." + default = "" +} + variable "use_cached" { type = bool description = "Use a cached operator binary if present, otherwise download from GitHub." @@ -119,6 +143,9 @@ resource "coder_script" "operator" { SESSION_WRAPPER = var.session_wrapper, OFFLINE = var.offline, USE_CACHED = var.use_cached, + AGENT_TEMPLATE = var.agent_template, + CODER_TOKEN_ENV = var.coder_token_env, + CALLBACK_URL = var.callback_url, }) run_on_start = true diff --git a/coder-module/run.sh b/coder-module/run.sh index 4c793bfd..c82aac12 100755 --- a/coder-module/run.sh +++ b/coder-module/run.sh @@ -3,22 +3,22 @@ BOLD='\033[0;1m' RESET='\033[0m' -ARCH=$$(uname -m) -case "$$ARCH" in +ARCH=$(uname -m) +case "$ARCH" in x86_64) PLATFORM="linux-x86_64" ;; aarch64) PLATFORM="linux-arm64" ;; *) - echo "Unsupported architecture: $$ARCH" + echo "Unsupported architecture: $ARCH" exit 1 ;; esac OPERATOR_BIN="${INSTALL_PREFIX}/operator" -if [ "${USE_CACHED}" = "true" ] && [ -f "$$OPERATOR_BIN" ]; then +if [ "${USE_CACHED}" = "true" ] && [ -f "$OPERATOR_BIN" ]; then echo "Using cached operator binary" elif [ "${OFFLINE}" = "true" ]; then - if [ -f "$$OPERATOR_BIN" ]; then + if [ -f "$OPERATOR_BIN" ]; then echo "Using offline operator binary" else echo "No operator binary found in offline mode" @@ -27,30 +27,74 @@ elif [ "${OFFLINE}" = "true" ]; then else printf "$${BOLD}Installing operator v${VERSION}...$${RESET}\n" - if [ -n "$$CODER_SCRIPT_BIN_DIR" ] && [ -e "$$CODER_SCRIPT_BIN_DIR/operator" ]; then - rm "$$CODER_SCRIPT_BIN_DIR/operator" + if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/operator" ]; then + rm "$CODER_SCRIPT_BIN_DIR/operator" fi mkdir -p "${INSTALL_PREFIX}" - RELEASE_URL="https://github.com/untra/operator/releases/download/v${VERSION}/operator-$$PLATFORM" + RELEASE_URL="https://github.com/untra/operator/releases/download/v${VERSION}/operator-$PLATFORM" - output=$$(curl -fsSL "$$RELEASE_URL" -o "$$OPERATOR_BIN" 2>&1) - if [ $$? -ne 0 ]; then - echo "Failed to download operator: $$output" + output=$(curl -fsSL "$RELEASE_URL" -o "$OPERATOR_BIN" 2>&1) + if [ $? -ne 0 ]; then + echo "Failed to download operator: $output" exit 1 fi - chmod +x "$$OPERATOR_BIN" + chmod +x "$OPERATOR_BIN" printf "Operator v${VERSION} installed to ${INSTALL_PREFIX}\n" fi -if [ -n "$$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$$CODER_SCRIPT_BIN_DIR/operator" ]; then - ln -s "$$OPERATOR_BIN" "$$CODER_SCRIPT_BIN_DIR/operator" +if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/operator" ]; then + ln -s "$OPERATOR_BIN" "$CODER_SCRIPT_BIN_DIR/operator" +fi + +# opr8r is the client half of the pair: agent sessions launched in this +# workspace call it to report step completion for multi-step ticket workflows. +# The operator server runs fine without it (single-step tickets are +# unaffected), and releases before v0.2.6 ship no opr8r asset, so a failure +# here warns instead of aborting workspace startup. +OPR8R_BIN="${INSTALL_PREFIX}/opr8r" + +if [ "${USE_CACHED}" = "true" ] && [ -f "$OPR8R_BIN" ]; then + echo "Using cached opr8r binary" +elif [ "${OFFLINE}" = "true" ]; then + if [ -f "$OPR8R_BIN" ]; then + echo "Using offline opr8r binary" + else + echo "No opr8r binary found in offline mode; multi-step workflows unavailable" + fi +else + printf "$${BOLD}Installing opr8r v${VERSION}...$${RESET}\n" + + if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -e "$CODER_SCRIPT_BIN_DIR/opr8r" ]; then + rm "$CODER_SCRIPT_BIN_DIR/opr8r" + fi + + mkdir -p "${INSTALL_PREFIX}" + OPR8R_URL="https://github.com/untra/operator/releases/download/v${VERSION}/opr8r-$PLATFORM" + + if output=$(curl -fsSL "$OPR8R_URL" -o "$OPR8R_BIN" 2>&1); then + chmod +x "$OPR8R_BIN" + printf "opr8r v${VERSION} installed to ${INSTALL_PREFIX}\n" + else + rm -f "$OPR8R_BIN" + echo "Warning: failed to download opr8r: $output" + echo "Multi-step ticket workflows will be unavailable in this workspace." + fi +fi + +if [ -n "$CODER_SCRIPT_BIN_DIR" ] && [ -x "$OPR8R_BIN" ] && [ ! -e "$CODER_SCRIPT_BIN_DIR/opr8r" ]; then + ln -s "$OPR8R_BIN" "$CODER_SCRIPT_BIN_DIR/opr8r" fi mkdir -p .tickets/operator .tickets/queue -if [ -n "${CONFIG_TOML}" ]; then - echo "${CONFIG_TOML}" > .tickets/operator/config.toml +# Bind template values to shell variables so conditionals below are real runtime checks +config_toml="${CONFIG_TOML}" +agent_template="${AGENT_TEMPLATE}" +callback_url="${CALLBACK_URL}" + +if [ -n "$config_toml" ]; then + echo "$config_toml" > .tickets/operator/config.toml else cat > .tickets/operator/config.toml <> .tickets/operator/config.toml <> .tickets/operator/config.toml + fi + fi fi echo "Starting operator API server on port ${PORT}..." -"$$OPERATOR_BIN" api --port "${PORT}" > "${LOG_PATH}" 2>&1 & +"$OPERATOR_BIN" api --port "${PORT}" > "${LOG_PATH}" 2>&1 & -for i in $$(seq 1 30); do +for i in $(seq 1 30); do if curl -s "http://localhost:${PORT}/api/v1/health" > /dev/null 2>&1; then echo "Operator is running on port ${PORT}" exit 0 diff --git a/coder-module/test.ts b/coder-module/test.ts new file mode 100644 index 00000000..f36c1929 --- /dev/null +++ b/coder-module/test.ts @@ -0,0 +1,125 @@ +// Local stand-in for the Coder registry's `~test` helper module. +// +// `main.test.ts` is written to the upstream coder/registry conventions so it +// can be published there unchanged, but that repo's shared helper is not +// available here. This provides the same four exports against the local +// module directory, so `bun test` runs standalone and in CI. +// +// Resolved via the `paths` mapping in tsconfig.json. + +import { readFile, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import * as path from "node:path"; +import { expect, it } from "bun:test"; + +/// OpenTofu is a drop-in for the commands used here; prefer whichever exists +/// so contributors aren't forced to install a specific CLI. +const tfBin = (): string => + process.env.TF_CLI ?? (Bun.which("terraform") ? "terraform" : "tofu"); + +interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +const exec = ( + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = {}, +): Promise => + new Promise((resolve, reject) => { + const child = spawn(tfBin(), args, { + cwd, + env: { ...process.env, ...env }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + child.on("error", reject); + child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr })); + }); + +export const runTerraformInit = async (dir: string): Promise => { + const { code, stderr } = await exec(["init", "-input=false", "-no-color"], dir); + if (code !== 0) throw new Error(`terraform init failed:\n${stderr}`); +}; + +export interface TerraformStateResource { + type: string; + name: string; + instances: { attributes: Record }[]; +} + +export interface TerraformState { + resources: TerraformStateResource[]; + outputs: Record; +} + +/// Applies the module with `vars` as TF_VAR_* and returns the resulting state. +/// Throws with terraform's stderr so tests can assert on validation messages. +export const runTerraformApply = async ( + dir: string, + vars: Record, +): Promise => { + const env: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(vars)) env[`TF_VAR_${k}`] = v; + + // Each apply is independent; a stale state file would mask a failed apply. + const statePath = path.join(dir, "terraform.tfstate"); + await rm(statePath, { force: true }); + + const { code, stderr } = await exec( + ["apply", "-auto-approve", "-input=false", "-no-color"], + dir, + env, + ); + if (code !== 0) throw new Error(stderr); + + return JSON.parse(await readFile(statePath, "utf8")) as TerraformState; +}; + +/// Returns the single instance's attributes for a resource type (and optional +/// name), mirroring the upstream helper's signature. +export const findResourceInstance = ( + state: TerraformState, + type: string, + name?: string, +): Record => { + const resource = state.resources.find( + (r) => r.type === type && (name === undefined || r.name === name), + ); + if (!resource) { + throw new Error( + `Resource ${type}${name ? `.${name}` : ""} not found in state`, + ); + } + if (resource.instances.length !== 1) { + throw new Error( + `Expected 1 instance of ${type}, got ${resource.instances.length}`, + ); + } + return resource.instances[0].attributes; +}; + +/// Registers a test per required variable asserting the apply fails when it is +/// omitted, plus one asserting the module applies with all of them supplied. +export const testRequiredVariables = ( + dir: string, + vars: Record, +): void => { + it("applies with all required variables", async () => { + await runTerraformApply(dir, vars); + }); + + for (const varName of Object.keys(vars)) { + it(`fails without required variable: ${varName}`, async () => { + const withoutVar = { ...vars }; + delete withoutVar[varName]; + await expect(runTerraformApply(dir, withoutVar)).rejects.toThrow( + `No value for required variable`, + ); + }); + } +}; diff --git a/coder-module/tsconfig.json b/coder-module/tsconfig.json new file mode 100644 index 00000000..1e0f82c4 --- /dev/null +++ b/coder-module/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "paths": { + "~test": ["./test.ts"] + } + }, + "include": ["*.ts"] +} diff --git a/config/default.toml b/config/default.toml index 146eb0ff..22ba39e7 100644 --- a/config/default.toml +++ b/config/default.toml @@ -167,6 +167,18 @@ connect_timeout_ms = 5000 # tmux, the agent CLI on PATH, credentials in its own environment, and the # project checked out at `workdir`. # +# Named execution targets: where launched agents run. Referenced from +# [delegators.launch_config] target = "". +# [[targets]] +# name = "sandbox" +# kind = "docker" +# image = "untra/operator:latest" +# +# [[targets]] +# name = "cloud" +# kind = "coder" +# template = "operator-agent" + # [[hosts]] # name = "gpu-vm" # ssh_alias = "gpu-vm" diff --git a/docs/_config.yml b/docs/_config.yml index 36098b16..511822fb 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -28,6 +28,7 @@ exclude: - vendor - typescript - collections + - superpowers # Plugins plugins: diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index d1fc08f8..16950927 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -9,8 +9,13 @@ docs: url: /getting-started/platform-support/ - title: Installation url: /getting-started/installation/ - - title: Tickets - url: /getting-started/tickets/ + - title: Operator Concepts + url: /getting-started/concepts/ + children: + - title: Tickets + url: /getting-started/tickets/ + - title: Kanban + url: /getting-started/concepts/kanban/ - title: Supported Session Management url: /getting-started/sessions/ children: @@ -32,6 +37,8 @@ docs: - title: Zed url: /getting-started/sessions/zed/ icon: zed + - title: Remote Hosts (SSH) + url: /getting-started/sessions/remote-hosts/ - title: Supported Kanban Providers url: /getting-started/kanban/ children: @@ -41,6 +48,12 @@ docs: - title: Linear url: /getting-started/kanban/linear/ icon: linear + - title: GitHub Projects + url: /getting-started/kanban/github/ + icon: github + - title: OpenSpec + url: /getting-started/kanban/openspec/ + icon: openspec - title: Supported Coding Agents url: /getting-started/agents/ children: @@ -61,7 +74,7 @@ docs: icon: anthropic - title: OpenAI url: /getting-started/model-servers/openai/ - icon: codex + icon: openai - title: Google url: /getting-started/model-servers/google/ icon: google @@ -80,6 +93,8 @@ docs: - title: GitLab url: /getting-started/git/gitlab/ icon: gitlab + - title: Provider Support + url: /getting-started/git/provider-support/ - title: Supported Notification Integrations url: /getting-started/notifications/ children: @@ -103,6 +118,7 @@ docs: children: - title: AGNT.gg url: /getting-started/integrations/agnt/ + icon: agnt - title: Reference children: - title: CLI @@ -123,6 +139,18 @@ docs: - title: Artifact Detection url: /artifact-detection/ codicon: file-binary + - title: Setup Wizard + url: /startup/ + codicon: rocket + - title: Feature Maturity + url: /maturity/ + codicon: verified + - title: Delegators + url: /delegators/ + codicon: organization + - title: Relay + url: /relay/ + codicon: radio-tower - title: Taxonomy codicon: type-hierarchy children: @@ -143,5 +171,3 @@ docs: url: /schemas/metadata/ - title: REST API url: /schemas/api/ - - title: TypeScript Types - url: /typescript/ diff --git a/docs/architecture/index.md b/docs/architecture/index.md index ba0a5dfb..1edc682a 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -6,10 +6,8 @@ nav_order: 8 published: false --- -# Architecture - Technical documentation on Operator's internal architecture and design decisions. ## Topics -- [Vibe-Kanban Comparison](vibe-kanban-comparison) - How Operator compares to vibe-kanban's approach +- [Operator & opr8r](operator-opr8r) - The server/client relationship between the operator process and the opr8r wrapper, and how they communicate diff --git a/docs/architecture/operator-opr8r.md b/docs/architecture/operator-opr8r.md new file mode 100644 index 00000000..1a47f3dc --- /dev/null +++ b/docs/architecture/operator-opr8r.md @@ -0,0 +1,129 @@ +--- +layout: page +title: Operator & opr8r +parent: Architecture +nav_order: 2 +published: false +--- + +Operator ships as **two executables built from one repository**: `operator`, a long-running server/TUI process, and `opr8r`, a small client binary that runs *inside* the terminal sessions Operator launches. This page describes the relationship between them and the two independent channels they use to talk to each other. + +## Roles + +| | `operator` | `opr8r` | +|---|---|---| +| **Shape** | Long-running process (TUI, REST API, queue, agent tracking) | Short-lived CLI, one invocation per step | +| **Runs where** | The operator's own terminal/host | Inside each agent's session (tmux/cmux/Zellij pane, VS Code terminal) | +| **Lifetime** | For the duration of the workspace | For the duration of a single ticket step | +| **Role in the relationship** | Server: owns ticket/queue state, exposes a REST API, hosts the relay hub | Client: wraps an LLM tool invocation, reports back over HTTP, optionally speaks MCP | +| **Binary size** | Full application (~tens of MB) | Optimized for size (~3–5 MB): stripped, LTO, single codegen unit, `panic = abort` | + +`opr8r` is deliberately minimal so it can be signed and distributed as an independent artifact alongside `operator` releases and the VS Code extension, without needing the full application dependency tree. + +## Two independent channels + +Operator and opr8r never share memory or a socket file handle directly — everything crosses a process boundary. There are two distinct channels, used for two distinct purposes: + +``` +operator process +├── spawns ─────────────► LLM tool session (tmux pane, direct spawn, etc.) +│ the ticket's initial prompt, project cwd, session id +│ +├── REST API (HTTP, localhost) ◄──────── opr8r (step-wrapper mode) +│ POST /api/v1/tickets/{id}/steps/{step}/complete +│ +└── RelayHub (Unix socket) ◄──────────── opr8r relay (MCP subcommand, child of the LLM tool) + relay_ask / relay_reply / relay_broadcast / relay_peers / relay_rename +``` + +1. **Launch (operator → session, one-way, process spawn).** Operator decides what to run and starts it — see [Launching agents](#launching-agents). +2. **Step completion (opr8r → operator, HTTP).** After the wrapped LLM command exits, `opr8r` reports the result back to Operator's REST API — see [The REST channel](#the-rest-channel-opr8r-as-step-wrapper). +3. **Peer messaging (agent ↔ agent, via operator).** A *different* opr8r subcommand, `opr8r relay`, runs as an MCP server so the LLM tool itself can message other agents through Operator's relay hub. This is a separate feature covered in full on the [Relay](/docs/relay/) page — this doc only places it in the launch topology. + +## Launching agents + +Operator decides which LLM tool, model, and prompt to use (see the [LLM Tools](/docs/llm-tools/) and [Delegators](/docs/delegators/) references) and starts that process inside a session wrapper. Today the LLM tool is spawned directly — `opr8r` does not sit between Operator and the agent for a single, non-stepped launch. + +Where `opr8r` becomes the parent process is **multi-step ticket workflows**, where a ticket's issuetype defines a sequence of steps (e.g. `plan` → `build` → `test`) and each step needs its completion reported before the next can run: + +```bash +opr8r --ticket-id=FEAT-042 --step=build -- claude --prompt "implement the feature" +``` + +`opr8r` spawns the LLM command as a child process, tees its stdout/stderr through to the terminal so the human sees identical output to a direct launch, and waits for it to exit. + +## The REST channel: opr8r as step-wrapper + +When the wrapped LLM command exits, `opr8r` calls back into Operator's REST API: + +``` +POST /api/v1/tickets/{id}/steps/{step}/complete +``` + +Request body (`StepCompleteRequest`): + +```json +{ + "exit_code": 0, + "session_id": "uuid", + "duration_secs": 342, + "output": { "...": "parsed OPERATOR_STATUS block, if present" } +} +``` + +Operator's handler (`complete_step` in `src/rest/routes/launch.rs`) records the result against the ticket, resolves the issuetype's next step, and decides whether to auto-proceed: it does, only when the step's `review_type` is `none`. The response tells `opr8r` what to do next: + +```json +{ + "status": "completed", + "next_step": { "name": "test", "review_type": "none" }, + "auto_proceed": true, + "next_command": "opr8r --ticket-id=FEAT-042 --step=test -- claude ..." +} +``` + +- If `auto_proceed` is true, `opr8r` `exec()`s the `next_command` — on Unix this replaces the current process image in place (same terminal, same pane, no new session), on Windows it spawns and waits since there's no `exec()` equivalent. +- If a review is required, `opr8r` prints an "awaiting review" banner and exits, leaving the terminal open for the operator to advance the ticket manually or via the TUI. +- `--no-auto-proceed` disables the exec regardless of what the server returns. + +**Current status:** the endpoint, request/response contract, and `opr8r` chain-exec logic are implemented; the server-side construction of a fully general `next_command` for arbitrary next steps is still a placeholder in `complete_step` (see the `// For now, return a placeholder` comment in `src/rest/routes/launch.rs`) — treat multi-step auto-chaining as alpha until that lands. + +### Discovering the API + +`opr8r` never hardcodes a port. It resolves the Operator API base URL in this order (`opr8r/src/api.rs::resolve_base_url` / `discover`): + +| Priority | Source | Used for | +|---|---|---| +| 1 | `--api-url` flag | Explicit override | +| 2 | `OPERATOR_API_URL` env var | Remote launches, where callbacks must route back through an SSH reverse tunnel | +| 3 | `.tickets/operator/api-session.json` | Local discovery — see below | +| 4 | `http://localhost:7008` | Default fallback | + +Operator writes `api-session.json` (`{"port", "pid", "started_at", "version"}`) into `.tickets/operator/` when its REST server starts (`src/rest/server.rs::write_session_file`), and removes it on shutdown. This is the primary discovery mechanism: opr8r reads the file to find the live port without any configuration. + +### Failure handling + +`opr8r` retries `complete_step` with exponential backoff (3 attempts). If the API stays unreachable, it exits with code `3` and prints recovery steps (start `operator api`, check `api-session.json`, or advance the ticket manually from the TUI) rather than silently dropping the step result. + +| Exit code | Meaning | +|---|---| +| `0` | Success | +| `1` | Wrapped LLM command failed | +| `3` | Operator API unreachable | +| `4` | Configuration error | +| `130` | Interrupted (SIGINT) | + +## The relay channel: opr8r as MCP peer + +Separately from step-wrapping, `opr8r relay` runs as an MCP stdio server — a *child* of the LLM tool rather than its parent — connecting to Operator's in-process relay hub over a Unix socket so agents on different tickets can message each other (`relay_ask`, `relay_reply`, `relay_broadcast`, `relay_peers`, `relay_rename`). Operator locates and injects this automatically for delegators with `operator_relay = true`. Full protocol, socket discovery, and wiring details live on the [Relay](/docs/relay/) page — this doc's scope is just where it sits in the launch/communication topology relative to the step-wrapper role above. + +## Why one binary, two roles + +`opr8r` step-wrapping and `opr8r relay` are both subcommands of the same binary (`relay` is a `Cmd::Relay` variant; step-wrapper mode is the default when no subcommand is given). This means only one small artifact needs to be built, signed, and bundled with Operator releases and the VS Code extension — there is no separate `operator-relay` binary to maintain (a legacy standalone `operator-relay` is still detected for backward compatibility, but is not produced by current builds). + +## See also + +- [Relay](/docs/relay/) — the MCP peer-to-peer protocol and hub, in full +- [CLI Reference](/docs/cli/) — `opr8r`'s full flag reference +- [Delegators](/docs/delegators/) — how Operator picks the LLM tool/model a session launches with +- [LLM Tools](/docs/llm-tools/) — how Operator detects and invokes CLI coding agents diff --git a/docs/architecture/vibe-kanban-comparison.md b/docs/architecture/vibe-kanban-comparison.md deleted file mode 100644 index b2b877f5..00000000 --- a/docs/architecture/vibe-kanban-comparison.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -layout: page -title: Vibe-Kanban Comparison -parent: Architecture -nav_order: 1 -published: false ---- - -# Operator vs Vibe-Kanban Architecture Comparison - -This document compares [vibe-kanban](https://github.com/BloopAI/vibe-kanban) and Operator's architectural approaches to LLM agent orchestration. - -## Overview - -| Aspect | Vibe-Kanban | Operator | -|--------|-------------|----------| -| **Focus** | Multi-executor task management with container isolation | TUI-driven ticket workflow with tmux sessions | -| **State Storage** | Database-backed (SQLite/PostgreSQL) | File-based JSON | -| **Execution Host** | Container service (Docker) | tmux sessions (macOS/Linux) | -| **Primary UI** | Web dashboard | Terminal TUI (ratatui) | - ---- - -## Configuration Systems - -### Vibe-Kanban: Three-Layer - -``` -1. Built-in defaults (default_profiles.json) -2. User overrides (~/.vibe-kanban/profiles.json) -3. Runtime overrides (env vars, CLI args) -``` - -Profiles use hierarchical identifiers: `CLAUDE_CODE.PLAN`, `CODEX.HIGH`. - -### Operator: Four-Layer - -``` -1. Built-in defaults (embedded in binary) -2. Project config (.tickets/operator/config.toml) -3. User config (~/.config/operator/config.toml) -4. Environment variables (OPERATOR_ prefix) -``` - -Providers are flat structures with optional variant fields. - ---- - -## Profile/Variant Model - -### Vibe-Kanban: Executor Variants - -Each executor (CLAUDE_CODE, CODEX, CURSOR_AGENT) has named variants: - -```json -{ - "executors": { - "CLAUDE_CODE": { - "DEFAULT": { "dangerously_skip_permissions": true }, - "PLAN": { "plan": true }, - "OPUS": { "model": "opus" }, - "APPROVALS": { "approvals": true } - } - } -} -``` - -Variant resolution: `ExecutorProfileId(executor, variant)` → HashMap lookup → fallback to DEFAULT. - -### Operator: Extended LlmProvider - -Flat structure with optional variant fields per provider: - -```rust -pub struct LlmProvider { - pub tool: String, // "claude", "codex", "gemini" - pub model: String, // "opus", "gpt-4.1" - pub display_name: Option, - - // Variant fields (all optional) - pub flags: Vec, - pub env: HashMap, - pub approvals: bool, - pub plan_only: bool, - pub reasoning_effort: Option, // Codex - pub sandbox: Option, // Codex -} -``` - ---- - -## Task Execution Lifecycle - -### Vibe-Kanban Hierarchy - -``` -Task -└── Workspace (isolated Git worktree + branch) - └── Session (conversation context) - └── ExecutionProcess (individual execution step) -``` - -**Key features:** -- Workspaces get isolated Git worktrees -- Sessions maintain conversation continuity via internal agent IDs -- ExecutionProcess tracks setup, agent run, cleanup, dev servers - -### Operator Hierarchy - -``` -Ticket -└── Agent (tmux session) - └── Step (plan, build, code, test, deploy) - └── Session UUID (Claude session ID per step) -``` - -**Key features:** -- Steps defined declaratively in JSON templates -- Session UUIDs stored in ticket YAML frontmatter -- Review gates per step (Plan, Visual, PR) - ---- - -## Isolation Model - -### Vibe-Kanban: Containerized Worktrees - -- Each task gets isolated Git worktree -- Branch pattern: `{prefix}/{short_uuid}-{sanitized_title}` -- Before/after commit states tracked per repository -- Container spawns executor via `StandardCodingAgentExecutor::spawn()` - -### Operator: Per-Ticket Worktrees - -- WorktreeManager creates worktrees at `~/.operator/worktrees/{project}/{ticket_id}` -- Branch pattern: `{ticket_type}/{ticket_id}-{sanitized_summary}` -- ProjectRepo.setup_script runs before agent launch -- ProjectRepo.cleanup_script runs after PR merge -- Global locking prevents race conditions during creation - ---- - -## State Management - -### Vibe-Kanban - -- **Database-backed**: SQLite or PostgreSQL -- Workspace, Session, ExecutionProcess as database models -- Exit signal monitoring via ExecutorExitSignal -- Auto-commit modifications on completion - -### Operator - -- **File-based JSON**: `.tickets/operator/state.json` -- AgentState tracks tmux session, content hash, last activity -- SessionMonitor polls tmux every 30s -- Silence detection for "awaiting input" status - ---- - -## Human-in-the-Loop - -### Vibe-Kanban - -Approval controlled via variant selection: - -| Variant | Behavior | -|---------|----------| -| DEFAULT | Auto-approve all | -| APPROVALS | Require approval for modifications | -| PLAN | Present plan before execution | - -### Operator - -Mode + review gates: - -| Mode | Ticket Types | Behavior | -|------|--------------|----------| -| Autonomous | FEAT, FIX, TASK | Auto-proceed between steps | -| Paired | SPIKE, INV | Pause for human interaction | - -Review gates per step: -- **Plan**: Operator approval before proceeding -- **Visual**: Browser-based visual check -- **PR**: GitHub PR review gate - ---- - -## Multi-LLM Support - -### Vibe-Kanban - -First-class executors with per-executor configuration: - -| Executor | Variants | -|----------|----------| -| CLAUDE_CODE | DEFAULT, PLAN, OPUS, APPROVALS | -| CODEX | DEFAULT, HIGH, APPROVALS, MAX | -| CURSOR_AGENT | DEFAULT, SONNET_4_5, GPT_5, GROK | - -### Operator - -Detection-based with extended LlmProvider: - -```toml -[[llm_tools.providers]] -tool = "claude" -model = "opus" -display_name = "Claude Opus" -flags = ["--dangerously-skip-permissions"] - -[[llm_tools.providers]] -tool = "codex" -model = "gpt-4.1" -display_name = "Codex High" -sandbox = "danger-full-access" -reasoning_effort = "high" -``` - ---- - -## Key Differences Summary - -| Feature | Vibe-Kanban | Operator | -|---------|-------------|----------| -| Variant structure | Nested HashMap | Flat LlmProvider | -| Database | SQLite/PostgreSQL | JSON files | -| Execution | Container service | tmux sessions | -| Worktree base | Per-workspace | Per-ticket | -| Session continuity | Database model | Frontmatter UUIDs | -| Notifications | N/A | macOS native | -| UI | Web dashboard | Terminal TUI | -| Config format | JSON | TOML | - ---- - -## Patterns Adopted from Vibe-Kanban - -1. **Per-task Git worktrees** for parallel development isolation -2. **Setup/cleanup scripts** per repository -3. **Variant fields** on providers (flags, env, approvals, plan_only) -4. **Branch naming convention** with type prefix -5. **Multi-LLM support** (Claude, Codex, Gemini) - ---- - -## References - -- [vibe-kanban Configuration and Profiles](https://deepwiki.com/BloopAI/vibe-kanban/3.2-configuration-and-profiles) -- [vibe-kanban Task Attempts and Execution Lifecycle](https://deepwiki.com/BloopAI/vibe-kanban/2.3-task-attempts-and-execution-lifecycle) diff --git a/docs/assets/icons/agnt.svg b/docs/assets/icons/agnt.svg new file mode 100644 index 00000000..78a78d14 --- /dev/null +++ b/docs/assets/icons/agnt.svg @@ -0,0 +1 @@ +AGNT \ No newline at end of file diff --git a/docs/assets/icons/openai.svg b/docs/assets/icons/openai.svg new file mode 100644 index 00000000..ebbdab0e --- /dev/null +++ b/docs/assets/icons/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/docs/assets/icons/openspec.svg b/docs/assets/icons/openspec.svg new file mode 100644 index 00000000..a1d39712 --- /dev/null +++ b/docs/assets/icons/openspec.svg @@ -0,0 +1 @@ +OpenSpec diff --git a/docs/cli/index.md b/docs/cli/index.md index 40f28404..38fcc936 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -6,8 +6,6 @@ layout: doc -# CLI Reference - Operator provides both a TUI dashboard and CLI commands for queue management. ## Global Options @@ -40,7 +38,7 @@ Launch agent for next available ticket | `` | Specific ticket to launch (optional) | | `-y, --yes` | Skip confirmation prompt | | `--delegator` | Use a named delegator from config (mutually exclusive with --llm-tool/--model/--model-server) | -| `--llm-tool` | LLM tool override: claude, codex, gemini | +| `--llm-tool` | LLM tool override (e.g., claude, codex, gemini, or configured tool) | | `--model` | Model override (e.g., opus, gpt-4o, qwen2.5-coder) | | `--model-server` | Named model server reference (e.g., ollama-local) — overrides the delegator's default. Pairs with --llm-tool/--model for ad-hoc ollama-backed launches. v1 accepts the flag and validates the name; env-var injection on spawn ships in v2 | @@ -140,7 +138,7 @@ Initialize operator workspace (non-interactive by default) | `-f, --force` | Overwrite existing files | | `-w, --working-dir` | Working directory (parent of .tickets/) | | `-k, --kanban-provider` | Kanban provider to configure: jira, linear | -| `-l, --llm-tool` | Preferred LLM tool: claude, codex, gemini | +| `-l, --llm-tool` | Preferred LLM tool (e.g., claude, codex, gemini, or any configured tool) | | `--skip-llm-detection` | Skip LLM tool detection | ### `workflow` diff --git a/docs/configuration/index.md b/docs/configuration/index.md index d7fb849e..6707c0b2 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -6,8 +6,6 @@ layout: doc -# Configuration - Operator configuration is stored in `.tickets/operator/config.toml`. ## Configuration Sections @@ -156,6 +154,7 @@ projects = [] delegators = [] model_servers = [] hosts = [] +targets = [] [agents] max_parallel = 5 diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 44273778..85bf571b 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -70,12 +70,85 @@ prompt_suffix = "\n\nBe concise." | `flags` | `[]` | Extra CLI flags appended to the launch command | | `use_worktrees` | inherit | Override global `git.use_worktrees` for this delegator | | `create_branch` | inherit | Whether to create a git branch per ticket | -| `docker` | inherit | Run agent in a Docker container | +| `target` | none | Name of an execution target, such as a remote host, `docker` container or `coder` workspace | | `prompt_prefix` | none | Text prepended before the generated ticket prompt | | `prompt_suffix` | none | Text appended after the generated ticket prompt | `inherit` means the global config value is used. +## Execution targets + +*Where does the agent process run?* One `[[targets]]` registry answers it for +every launch path (TUI, web, VS Code, REST). A delegator references a target +by name, exactly like `model_server`: + +```toml +[[targets]] +name = "sandbox" +kind = "docker" +image = "untra/operator:latest" + +[[targets]] +name = "cloud" +kind = "coder" +template = "operator-agent" # child workspaces come from this template + +[[targets]] +name = "gpu-vm" +kind = "ssh" +ssh_alias = "gpu-vm" # resolved via ~/.ssh/config +workdir = "/home/me/proj" + +[[delegators]] +name = "heavy" +llm_tool = "claude" +model = "opus" +[delegators.launch_config] +target = "cloud" +``` + +**Resolution precedence** (first match wins): + +1. `target` name — explicit `[[targets]]` entry, the synthesized + `local`/`docker` targets, or a `[[hosts]]` name. Unknown names are a hard + error, never a silent fallback to local. +2. `host` name (deprecated) — the `[[hosts]]` entry of that name +3. `docker = true` (deprecated) — the synthesized docker target +4. `docker = false` — local +5. `launch.docker.enabled = true` — the synthesized docker target. + **Behavior change:** this was previously only a TUI dialog gate; it is now + a real fallback, so REST/CLI/auto launches with it set run in docker. +6. otherwise — local + +Legacy inputs are synthesized rather than special-cased: `[launch.docker]` +becomes a target named `docker`, and every `[[hosts]]` entry becomes an ssh +target of the same name. Setting both `docker` and `host` now resolves +deterministically to the host (with a deprecation warning) instead of +erroring. + +### Coder targets + +A coder target's execution shape is an SSH target with a dynamically +provisioned alias: Operator creates (or restarts) a per-ticket workspace from +`template`, writes an SSH config fragment (`ProxyCommand coder ssh --stdio `), prepares the git checkout, and launches over the shared SSH remote path. Workspaces are stopped on completion and **never deleted** — reclamation belongs to the Coder admin's +autostop policy. + +Credentials are held **by name**: `url_env` / `token_env` name environment +variables, and the token variable is stripped from every agent's spawn +environment on all target kinds. **Blast radius:** a Coder session token can +create, delete, and SSH into every workspace its user owns — scope accordingly. + +Known limitation: prompt files are written on the operator side, so a coder +target currently requires the workspace to reach them (e.g. Operator itself +running inside a Coder workspace via the +[coder module](/getting-started/platforms/coder/)); `callback_url` keeps +multi-step chains reporting when the SSH tunnel drops. + +Remote constraints for ssh and coder targets: worktrees and relay MCP +injection are forced off, and the zellij session wrapper is unsupported. See +[Remote Hosts (SSH)](/getting-started/sessions/remote-hosts/) for the +underlying mechanics. + ### Relay MCP injection Set `operator_relay = true` to enable the relay MCP server for Claude Code @@ -151,7 +224,7 @@ id = "a1b2c3d4-…" # AGNT agent UUID, or an OpenAI asst_… id Remote agents are **export-only**: Operator has no runtime client for those platforms, so a delegator carrying a `remote_agent` cannot be launched locally — resolution returns a `RemoteOnlyDelegator` error on every launch path. When the platform is `agnt`, the reference is -surfaced in the [`--format agnt` workflow export](/docs/) as a native AGNT `agnt-agent` node; other platforms +surfaced in the [`--format agnt` workflow export](/getting-started/workflows/agnt/) as a native AGNT `agnt-agent` node; other platforms ride opaquely in the profile. > **Caveat:** a non-AGNT remote delegator (e.g. `platform = "openai"`) used as a step agent in an @@ -179,10 +252,10 @@ The running Operator API exposes full CRUD for delegators, plus agent-profile in | `PUT` | `/api/v1/delegators/{name}` | Update a delegator | | `DELETE` | `/api/v1/delegators/{name}` | Delete a delegator | -See the [OpenAPI reference](/docs/schemas/openapi.json) for request/response shapes. +See the [OpenAPI reference](/schemas/openapi.json) for request/response shapes. ## See also -- [Configuration reference](/docs/configuration/) — full `operator.toml` schema -- [LLM Tools](/docs/llm-tools/) — which tools Operator can detect and launch -- [Schema reference](/docs/schemas/config/) — type definitions for `Delegator` and `DelegatorLaunchConfig` +- [Configuration reference](/configuration/) — full `operator.toml` schema +- [LLM Tools](/llm-tools/) — which tools Operator can detect and launch +- [Schema reference](/schemas/config/) — type definitions for `Delegator` and `DelegatorLaunchConfig` diff --git a/docs/downloads/index.md b/docs/downloads/index.md index 15767af5..857deb61 100644 --- a/docs/downloads/index.md +++ b/docs/downloads/index.md @@ -4,8 +4,6 @@ description: "Download Operator! binaries for macOS, Linux, and Windows." layout: doc --- -# Operator artifact Downloads - Download Operator! for your platform. Current version: **v{{ site.version }}** ## VS Code Extension (Recommended) diff --git a/docs/getting-started/agents/claude.md b/docs/getting-started/agents/claude.md index e7153178..62b59c15 100644 --- a/docs/getting-started/agents/claude.md +++ b/docs/getting-started/agents/claude.md @@ -4,8 +4,6 @@ description: "Configure Claude Code as your AI coding agent." layout: doc --- -# Claude Code - [Claude Code](https://code.claude.com) is Anthropic's AI coding assistant agent, available as Claude Code for command-line development workflows. ## Installation @@ -60,5 +58,5 @@ To enable relay for a delegator, set `operator_relay = true` in its `launch_config`. The global default is `false` (opt-in), so single-agent workflows stay lean unless relay is explicitly requested. -See [Relay](/docs/relay/) for the full architecture. +See [Relay](/relay/) for the full architecture. diff --git a/docs/getting-started/agents/codex.md b/docs/getting-started/agents/codex.md index ae6af80d..24c90caa 100644 --- a/docs/getting-started/agents/codex.md +++ b/docs/getting-started/agents/codex.md @@ -4,8 +4,6 @@ description: "Configure OpenAI Codex as your AI coding agent." layout: doc --- -# Codex - [Codex](https://developers.openai.com/codex/) is the [OpenAI](https://openai.com/) code-specialized CLI agent, available through the OpenAI API. ## Status @@ -53,7 +51,7 @@ Or add it to your shell profile for persistence. Operator injects relay env vars (`RELAY_HUB_SOCKET`, `RELAY_AGENT_NAME`) into Codex sessions at launch so agents can discover each other by ticket ID. Full MCP tool support for Codex relay is planned for a future release. -See [Relay](/docs/relay/) for details. +See [Relay](/relay/) for details. ## API Usage diff --git a/docs/getting-started/agents/gemini-cli.md b/docs/getting-started/agents/gemini-cli.md index 9a6091ba..3cdda9de 100644 --- a/docs/getting-started/agents/gemini-cli.md +++ b/docs/getting-started/agents/gemini-cli.md @@ -1,11 +1,9 @@ --- -title: "Gemini" +title: "Gemini CLI" description: "Configure Google Gemini as your AI coding agent." layout: doc --- -# Gemini CLI - [Gemini](https://geminicli.com/) is [Google](https://google.com)'s multimodal agent CLI with strong coding capabilities. ## Status diff --git a/docs/getting-started/agents/index.md b/docs/getting-started/agents/index.md index 26bec423..9ba0c345 100644 --- a/docs/getting-started/agents/index.md +++ b/docs/getting-started/agents/index.md @@ -4,8 +4,6 @@ description: "AI coding agents compatible with Operator." layout: doc --- -# Supported Coding Agents - Operator orchestrates AI coding agents to work on tickets from your kanban board. The following agents are currently supported: ## Available Agents diff --git a/docs/getting-started/concepts/index.md b/docs/getting-started/concepts/index.md new file mode 100644 index 00000000..282692fb --- /dev/null +++ b/docs/getting-started/concepts/index.md @@ -0,0 +1,12 @@ +--- +title: "Operator Concepts" +description: "The core ideas behind Operator: tickets, kanban, and agents." +layout: doc +--- + +Operator! runs on two ideas: work is written down as **tickets**, and tickets move across a **kanban** board as agents pick them up and finish them. + +- **[Tickets](/getting-started/tickets/)** — the unit of work. A markdown file describing one task for an agent, carrying an issue type that decides how the work is done. +- **[Kanban](/getting-started/concepts/kanban/)** — the board. Where tickets wait, get worked, and land when done. New to kanban? Start here. + +Everything else — [agents](/getting-started/agents/), [providers](/getting-started/kanban/), [workflows](/workflows/) — builds on these two. diff --git a/docs/getting-started/concepts/kanban.md b/docs/getting-started/concepts/kanban.md new file mode 100644 index 00000000..8ee34762 --- /dev/null +++ b/docs/getting-started/concepts/kanban.md @@ -0,0 +1,26 @@ +--- +title: "Kanban" +description: "What a kanban board is, and how Operator uses one to run agents." +layout: doc +--- + +Kanban is a way of managing work by making it visible. A **board** holds columns; each **column** is a state of work; each **card** is one piece of work. Cards move left to right: + +``` +| To Do | In Progress | Done | +|----------------|-----------------|-----------------| +| waiting work | active work | finished work | +``` + +Two rules do most of the work: + +1. **Pull, don't push.** Nobody is handed work — whoever has capacity pulls the next card. +2. **Limit work in progress.** Few cards in flight at once means work finishes instead of piling up half-done. + +That's it. The board *is* the status report. + +## How Operator uses kanban + +In Operator!, the cards are [tickets](/getting-started/tickets/) and the workers are AI agents. Operator holds three internal states — **todo**, **doing**, **done** — and enforces both rules: agents pull the next ticket when a slot frees up, and parallelism limits cap work in progress. + +You can run entirely from local tickets, or sync the board with an external [kanban provider](/getting-started/kanban/) like Jira, Linear, or GitHub Projects — Operator maps its three states onto your board's columns and moves cards as agents work. diff --git a/docs/getting-started/git/github.md b/docs/getting-started/git/github.md index 69d6efa2..5c39e632 100644 --- a/docs/getting-started/git/github.md +++ b/docs/getting-started/git/github.md @@ -5,8 +5,6 @@ layout: doc published: true --- -# GitHub - Connect Operator to GitHub for repository management and pull requests. ## Prerequisites diff --git a/docs/getting-started/git/gitlab.md b/docs/getting-started/git/gitlab.md index f3ad902a..698e6244 100644 --- a/docs/getting-started/git/gitlab.md +++ b/docs/getting-started/git/gitlab.md @@ -5,8 +5,6 @@ layout: doc published: true --- -# GitLab - Connect Operator to GitLab for repository management and merge requests. ## Prerequisites diff --git a/docs/getting-started/git/index.md b/docs/getting-started/git/index.md index 96220ef8..dbc9518a 100644 --- a/docs/getting-started/git/index.md +++ b/docs/getting-started/git/index.md @@ -4,8 +4,6 @@ description: "Git hosting integrations for Operator." layout: doc --- -# Supported Git Repositories - Operator integrates with Git hosting platforms to manage branches and pull/merge requests. ## Prerequisites diff --git a/docs/getting-started/git/provider-support.md b/docs/getting-started/git/provider-support.md index 57679add..3ce80176 100644 --- a/docs/getting-started/git/provider-support.md +++ b/docs/getting-started/git/provider-support.md @@ -4,8 +4,6 @@ description: "Architecture guide for adding new Git provider integrations." layout: doc --- -# Provider Support - This guide explains how Operator integrates with Git hosting providers and how to add support for new providers. ## Architecture Overview diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index c2aebb67..fe989cfd 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -4,8 +4,6 @@ description: "Install and configure Operator! for managing AI-assisted developme layout: doc --- -# Getting Started - Welcome to Operator! This guide will help you get up and running with AI-assisted kanban-shaped software development. ## Quick Start diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index fcde458a..e8984d90 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -4,8 +4,6 @@ description: "Download and install Operator on your system." layout: doc --- -# Installation - This guide covers installing Operator on macOS, Linux, and Windows. ## VS Code Extension (Recommended) diff --git a/docs/getting-started/integrations/agnt.md b/docs/getting-started/integrations/agnt.md index c3525b7a..56ca9b13 100644 --- a/docs/getting-started/integrations/agnt.md +++ b/docs/getting-started/integrations/agnt.md @@ -4,8 +4,6 @@ description: "Export Operator workflows to AGNT.gg and drive Operator from AGNT layout: doc --- -# AGNT.gg - [AGNT.gg](https://agnt.gg) is a local-first agent operating system: a desktop app + local runtime with visual graph workflows, agents, a plugin marketplace, and native MCP support. Operator connects to AGNT in both directions. diff --git a/docs/getting-started/integrations/index.md b/docs/getting-started/integrations/index.md index 39bb4c53..1d1b47e7 100644 --- a/docs/getting-started/integrations/index.md +++ b/docs/getting-started/integrations/index.md @@ -4,8 +4,6 @@ description: "Connect Operator! to agent OS and automation platforms like AGNT.g layout: doc --- -# Automation Platform Integrations - Operator is not just a standalone TUI — it exposes its ticket orchestration over a **REST API** and a **stdio MCP server**, so external *automation platforms* and *agent operating systems* can drive it, and Operator can hand work out to them. diff --git a/docs/getting-started/kanban/github.md b/docs/getting-started/kanban/github.md index bcf2ebbe..d17926c3 100644 --- a/docs/getting-started/kanban/github.md +++ b/docs/getting-started/kanban/github.md @@ -4,8 +4,6 @@ description: "Configure GitHub Projects v2 integration with Operator." layout: doc --- -# GitHub Projects - Connect Operator to [**GitHub Projects v2**](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for issue tracking and project management. > **⚠ Token Disambiguation — read this first** diff --git a/docs/getting-started/kanban/index.md b/docs/getting-started/kanban/index.md index 97712215..8609e64f 100644 --- a/docs/getting-started/kanban/index.md +++ b/docs/getting-started/kanban/index.md @@ -4,18 +4,18 @@ description: "Kanban and issue tracking integrations for Operator." layout: doc --- -# Supported Kanban Providers - Operator integrates with popular issue tracking systems to manage work items for AI agents. ## Available Integrations +Statuses follow the [feature maturity](/maturity/) scale. + | Provider | Status | Notes | |----------|--------|-------| -| [Jira Cloud](/getting-started/kanban/jira/) | Supported | Full API integration | -| [Linear](/getting-started/kanban/linear/) | Supported | Full API integration | -| [GitHub Projects](/getting-started/kanban/github/) | Supported | Projects v2 GraphQL integration | -| [OpenSpec](/getting-started/kanban/openspec/) | Experimental | Local spec-driven changes; pull-only | +| [Jira Cloud](/getting-started/kanban/jira/) | Beta | Full API integration | +| [Linear](/getting-started/kanban/linear/) | Beta | Full API integration | +| [GitHub Projects](/getting-started/kanban/github/) | Beta | Projects v2 GraphQL integration | +| [OpenSpec](/getting-started/kanban/openspec/) | Alpha | Local spec-driven changes; pull-only | ## How It Works @@ -86,10 +86,10 @@ as dropdowns. See the per-provider guides for details. ## Choosing a Provider -Both Jira Cloud and Linear are fully supported kanban providers: - - **Jira Cloud**: Best for teams already using Atlassian products, with rich workflow customization - **Linear**: Best for teams wanting a modern, fast issue tracker with streamlined workflows +- **GitHub Projects**: Best when your work already lives in GitHub issues and Projects v2 boards +- **OpenSpec**: Best for local, spec-driven change tracking without an external tracker (pull-only) ## Local Tickets diff --git a/docs/getting-started/kanban/jira-api.md b/docs/getting-started/kanban/jira-api.md index 8ae764b3..4af77586 100644 --- a/docs/getting-started/kanban/jira-api.md +++ b/docs/getting-started/kanban/jira-api.md @@ -6,8 +6,6 @@ layout: doc -# Jira API Reference - Auto-generated documentation of Jira Cloud REST API response types used by Operator. ## Overview diff --git a/docs/getting-started/kanban/jira.md b/docs/getting-started/kanban/jira.md index 78781c6e..d5d3f335 100644 --- a/docs/getting-started/kanban/jira.md +++ b/docs/getting-started/kanban/jira.md @@ -4,8 +4,6 @@ description: "Configure Jira Cloud integration with Operator." layout: doc --- -# Jira Cloud - Connect Operator to [**Jira Cloud**](https://www.atlassian.com/software/jira) for issue tracking and project management. ## Prerequisites @@ -129,3 +127,8 @@ curl -u email:token https://your-org.atlassian.net/rest/api/3/myself ### Missing issues Check your JQL query and permissions in Jira. + +## API Reference + +The full set of Jira REST calls Operator makes is documented in the generated +[Jira API Reference](/getting-started/kanban/jira-api/). diff --git a/docs/getting-started/kanban/linear.md b/docs/getting-started/kanban/linear.md index 4d4cab7e..d24bad62 100644 --- a/docs/getting-started/kanban/linear.md +++ b/docs/getting-started/kanban/linear.md @@ -4,8 +4,6 @@ description: "Configure Linear integration with Operator." layout: doc --- -# Linear - Connect Operator to [**Linear**](https://linear.app/features) for modern issue tracking and project management. ## Prerequisites diff --git a/docs/getting-started/kanban/openspec.md b/docs/getting-started/kanban/openspec.md index b6ea3814..7c996868 100644 --- a/docs/getting-started/kanban/openspec.md +++ b/docs/getting-started/kanban/openspec.md @@ -4,8 +4,6 @@ description: "Import OpenSpec spec-driven change tasks as Operator tickets." layout: doc --- -# OpenSpec - Experimental Operator can import work from [**OpenSpec**](https://github.com/Fission-AI/OpenSpec), the spec-driven development (SDD) framework for AI coding assistants. OpenSpec keeps proposed changes as plain-markdown bundles in your repository; Operator turns their task checklists into queued tickets. diff --git a/docs/getting-started/model-servers/anthropic.md b/docs/getting-started/model-servers/anthropic.md index 628ddf3c..f72383e3 100644 --- a/docs/getting-started/model-servers/anthropic.md +++ b/docs/getting-started/model-servers/anthropic.md @@ -4,8 +4,6 @@ description: "Connect Anthropic as a first-party model provider and list its mod layout: doc --- -# Anthropic - [**Anthropic**](https://www.anthropic.com/) is a first-party model provider — it produces the Claude family of models and serves them from its own API. It is the zero-config default for the `claude` llm tool, and a first-class diff --git a/docs/getting-started/model-servers/google.md b/docs/getting-started/model-servers/google.md index cb6f3fcd..7dad00f8 100644 --- a/docs/getting-started/model-servers/google.md +++ b/docs/getting-started/model-servers/google.md @@ -4,8 +4,6 @@ description: "Connect Google (Gemini) as a first-party model provider and list i layout: doc --- -# Google - [**Google**](https://ai.google.dev/) is a first-party model provider — it produces the Gemini family and serves them from its own API. It is the zero-config default for the `gemini` llm tool, and a first-class diff --git a/docs/getting-started/model-servers/index.md b/docs/getting-started/model-servers/index.md index b35b82e5..460cb687 100644 --- a/docs/getting-started/model-servers/index.md +++ b/docs/getting-started/model-servers/index.md @@ -1,17 +1,13 @@ --- -layout: default -title: Supported Model Providers -parent: Getting Started -nav_order: 5 -has_children: true +title: "Supported Model Providers" +description: "Model servers and inference providers for Operator delegators." +layout: doc --- -# Model Providers - A **model server** is a named host that serves models via an inference API. It's orthogonal to the LLM tool that runs your coding agent: - **LLM tools** (claude, codex, gemini) are the agentic CLIs that drive the coding session — they use tools, edit files, resume sessions. -- **Model servers** are where the model weights live — Anthropic's API, OpenAI's API, Google's API, or a many-model provider like [OpenRouter](openrouter/), a local [Ollama](ollama/) server, lmstudio, or vllm. +- **Model servers** are where the model weights live — Anthropic's API, OpenAI's API, Google's API, or a many-model provider like [OpenRouter](/getting-started/model-servers/openrouter/), a local [Ollama](/getting-started/model-servers/ollama/) server, lmstudio, or vllm. A delegator pairs an LLM tool with a model (and, optionally, a model server). @@ -21,14 +17,16 @@ Every kind is a **model provider**. They split into two classes — the grouping every surface (README badges, the status tree, the REST `/kinds` catalog) derives from `ModelServerKind::provider_class()`: -- **First-party** — a single vendor's own API: [Anthropic](anthropic/) - (`anthropic-api`), [OpenAI](openai/) (`openai-api`), [Google](google/) +- **First-party** — a single vendor's own API: [Anthropic](/getting-started/model-servers/anthropic/) + (`anthropic-api`), [OpenAI](/getting-started/model-servers/openai/) (`openai-api`), + [Google](/getting-started/model-servers/google/) (`google-api`). These double as the zero-config defaults for the claude/codex/gemini tools, so you rarely declare them — but they're first-class: operator lists each one's live models from its `/models` endpoint when the corresponding key env is set. - **Gateways** — a host or aggregator that fronts *many* models behind one - endpoint: [OpenRouter](openrouter/) (`openrouter`), a local [Ollama](ollama/) + endpoint: [OpenRouter](/getting-started/model-servers/openrouter/) (`openrouter`), + a local [Ollama](/getting-started/model-servers/ollama/) server (`ollama`), or any `openai-compat` / `lmstudio` server. Declare one to reach its whole catalog. @@ -70,7 +68,7 @@ Delegators that omit `model_server` resolve to these builtins automatically. Exi | `openai-api` | OpenAI / a compatible proxy | | `google-api` | Google Gemini API | | `ollama` | Local ollama server (`ollama serve`, default `http://localhost:11434`) | -| `openrouter` | [OpenRouter](openrouter/) hosted gateway to 300+ models (`https://openrouter.ai/api/v1`) | +| `openrouter` | [OpenRouter](/getting-started/model-servers/openrouter/) hosted gateway to 300+ models (`https://openrouter.ai/api/v1`) | | `openai-compat` | Any OpenAI-API-compatible server (vllm, lmstudio, together.ai, groq, …) | | `lmstudio` | LM Studio's local server | diff --git a/docs/getting-started/model-servers/ollama.md b/docs/getting-started/model-servers/ollama.md index 047e2310..f1ce06a2 100644 --- a/docs/getting-started/model-servers/ollama.md +++ b/docs/getting-started/model-servers/ollama.md @@ -4,8 +4,6 @@ description: "Run local open models with Ollama as an Operator model provider." layout: doc --- -# Ollama - [**Ollama**](https://ollama.com/) runs open models (Llama, Qwen, Mistral, …) locally and serves them over an OpenAI-compatible API. Declare it as a [model server](./) to drive agents against models on your own machine — no cloud diff --git a/docs/getting-started/model-servers/openai.md b/docs/getting-started/model-servers/openai.md index ad1e6edd..537c9a95 100644 --- a/docs/getting-started/model-servers/openai.md +++ b/docs/getting-started/model-servers/openai.md @@ -4,8 +4,6 @@ description: "Connect OpenAI as a first-party model provider and list its models layout: doc --- -# OpenAI - [**OpenAI**](https://openai.com/) is a first-party model provider — it produces the GPT family and serves them from its own API. It is the zero-config default for the `codex` llm tool, and a first-class [model provider](./): once connected, diff --git a/docs/getting-started/model-servers/openrouter.md b/docs/getting-started/model-servers/openrouter.md index 57a8aa90..4c0c3514 100644 --- a/docs/getting-started/model-servers/openrouter.md +++ b/docs/getting-started/model-servers/openrouter.md @@ -4,8 +4,6 @@ description: "Reach hundreds of models through OpenRouter, one OpenAI-compatible layout: doc --- -# OpenRouter - [**OpenRouter**](https://openrouter.ai/) is a hosted gateway that fronts hundreds of models (Anthropic, OpenAI, Google, Meta, Mistral, and more) behind a single OpenAI-compatible endpoint and one API key. Declare it once as a diff --git a/docs/getting-started/notifications/index.md b/docs/getting-started/notifications/index.md index de2da3c5..f29b088b 100644 --- a/docs/getting-started/notifications/index.md +++ b/docs/getting-started/notifications/index.md @@ -4,8 +4,6 @@ description: "Notification providers for Operator events." layout: doc --- -# Supported Notification Integrations - Operator can notify you when important events occur, such as agent completion, failures, or tickets awaiting review. ## Available Integrations diff --git a/docs/getting-started/notifications/os.md b/docs/getting-started/notifications/os.md index 772068be..96f49817 100644 --- a/docs/getting-started/notifications/os.md +++ b/docs/getting-started/notifications/os.md @@ -4,8 +4,6 @@ description: "Configure native OS notifications for Operator events." layout: doc --- -# Operating System Notifications - Display native system notifications when Operator events occur. ## Platform Support diff --git a/docs/getting-started/notifications/webhooks.md b/docs/getting-started/notifications/webhooks.md index bcd0a6bf..b0a07868 100644 --- a/docs/getting-started/notifications/webhooks.md +++ b/docs/getting-started/notifications/webhooks.md @@ -4,8 +4,6 @@ description: "Configure webhook notifications for Operator events." layout: doc --- -# Webhook Notifications - Send HTTP POST requests to external services when Operator events occur. ## Configuration diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md index 7f1b8502..f01b5a6a 100644 --- a/docs/getting-started/platform-support.md +++ b/docs/getting-started/platform-support.md @@ -4,8 +4,6 @@ description: "What works on each operating system and which features are unavail layout: doc --- -# Platform Support & Limitations - This page is the authoritative reference for what Operator supports on each operating system. Each gap is tagged as one of: **not applicable** (the underlying tool doesn't exist on that OS), **blocked** (a dependency prevents support and a workaround is needed), or **planned** (the intent is to support it; no timeline committed). ## Quick Reference Matrix diff --git a/docs/getting-started/platforms/coder.md b/docs/getting-started/platforms/coder.md index e367d7fc..6b1d2923 100644 --- a/docs/getting-started/platforms/coder.md +++ b/docs/getting-started/platforms/coder.md @@ -4,8 +4,6 @@ description: "Run Operator as a background service in Coder workspaces via Terra layout: doc --- -# Coder - Supported Run [Operator](https://operator.untra.io) as a background REST API server inside your [Coder](https://coder.com) workspace. The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. @@ -99,7 +97,7 @@ No Operator configuration is needed to access these — they are ambient in the ## How It Works 1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) -2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary) +2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary), then the `opr8r` client binary that agent sessions call to report step completion for multi-step workflows. 3. Generates a TOML configuration file (or uses the provided `config_toml`) 4. Starts `operator api` as a background process 5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds diff --git a/docs/getting-started/platforms/docker.md b/docs/getting-started/platforms/docker.md index 46bcd95a..f179307e 100644 --- a/docs/getting-started/platforms/docker.md +++ b/docs/getting-started/platforms/docker.md @@ -4,15 +4,12 @@ description: "Run Operator from an official multi-arch Docker image, mounting yo layout: doc --- -# Docker - Supported -Run [Operator](https://operator.untra.io) from an official multi-arch container image. The image bundles the Operator binary (with the embedded web dashboard and REST API) on a slim Debian base, plus the `git` and `tmux` substrate Operator needs to launch agents. Mount your projects root into the container and Operator treats it as the workspace. +Run [Operator](https://operator.untra.io) from an official multi-arch container image. The image bundles the Operator binary (with the embedded web dashboard and REST API) and the `opr8r` client on a slim Debian base, plus the `git` and `tmux` substrate Operator needs to launch agents. Mount your projects root into the container and Operator treats it as the workspace. **Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) — `linux/amd64` and `linux/arm64`. -> **Not to be confused with** the `[docker]` config section, which makes Operator launch each *agent* inside a container. This page is about distributing *Operator itself* as a container image. The two are orthogonal. ## Usage @@ -56,6 +53,7 @@ docker run --rm -v $(pwd):/op:rw -it untra/operator:{{ site.version }} | Included | Purpose | |----------|---------| | `operator` binary | The CLI/TUI/REST API, with the web dashboard embedded | +| `opr8r` binary | Client agent sessions call to report step completion for multi-step workflows | | `git` | Branch and commit operations for ticket work | | `tmux` | Default session wrapper Operator uses to spawn agent sessions | | `ca-certificates` | TLS for LLM, kanban, and git provider APIs | diff --git a/docs/getting-started/platforms/index.md b/docs/getting-started/platforms/index.md index d9a2a13f..45819821 100644 --- a/docs/getting-started/platforms/index.md +++ b/docs/getting-started/platforms/index.md @@ -4,8 +4,6 @@ description: "Workspace platform integrations for running Operator in remote dev layout: doc --- -# Supported Workspace Platforms - Operator can run as a background service in remote workspace platforms, providing API access and dashboard visibility without requiring a local terminal. ## Available Options diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index 396e0c0a..63d42c6e 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -4,8 +4,6 @@ description: "System requirements and dependencies for running Operator." layout: doc --- -# Prerequisites - Before installing Operator, ensure your system meets the following requirements. ## System Requirements @@ -47,13 +45,13 @@ Operator requires a session manager for launching and managing coding agents: | Platform | Recommended | Alternative | |----------|-------------|-------------| -| macOS | [VS Code Extension](/getting-started/vscode-extension/) | tmux | -| Linux | [VS Code Extension](/getting-started/vscode-extension/) | tmux | -| Windows | [VS Code Extension](/getting-started/vscode-extension/) (required) | N/A | +| macOS | [VS Code Extension](/getting-started/sessions/vscode/) | tmux | +| Linux | [VS Code Extension](/getting-started/sessions/vscode/) | tmux | +| Windows | [VS Code Extension](/getting-started/sessions/vscode/) (required) | N/A | #### VS Code Extension -The VS Code Extension provides the best experience across all platforms and is **required on Windows**. See [VS Code Extension Setup](/getting-started/vscode-extension/) for installation instructions. +The VS Code Extension provides the best experience across all platforms and is **required on Windows**. See [VS Code Extension Setup](/getting-started/sessions/vscode/) for installation instructions. #### tmux (macOS/Linux only) @@ -92,7 +90,7 @@ At least one AI coding agent should be installed: - [Claude Code](/getting-started/agents/claude/) (recommended) - [Codex](/getting-started/agents/codex/) -- [Gemini](/getting-started/agents/gemini/) +- [Gemini](/getting-started/agents/gemini-cli/) ### Kanban Integration diff --git a/docs/getting-started/sessions/cmux.md b/docs/getting-started/sessions/cmux.md index 7e0cb485..98615f25 100644 --- a/docs/getting-started/sessions/cmux.md +++ b/docs/getting-started/sessions/cmux.md @@ -4,8 +4,6 @@ description: "macOS terminal multiplexer integration for managing AI agent sessi layout: doc --- -# cmux Sessions - Supported Operator! supports [**cmux**](https://cmux.app){:target="_blank"}, a macOS terminal multiplexer, as a session management backend. When running inside cmux, Operator can launch and manage AI coding agents directly in cmux workspaces. diff --git a/docs/getting-started/sessions/cursor.md b/docs/getting-started/sessions/cursor.md index dd501afc..5d40e673 100644 --- a/docs/getting-started/sessions/cursor.md +++ b/docs/getting-started/sessions/cursor.md @@ -4,8 +4,6 @@ description: "Cursor IDE integration via the operator-terminals VS Code extensio layout: doc --- -# Cursor - Supported Install from OpenVSX diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md index d6237910..abcfd201 100644 --- a/docs/getting-started/sessions/index.md +++ b/docs/getting-started/sessions/index.md @@ -4,8 +4,6 @@ description: "Session wrapper tools for managing AI agent terminals." layout: doc --- -# Supported Session Management - Operator supports multiple session management backends for running AI coding agents in persistent, manageable terminal sessions. ## Available Options diff --git a/docs/getting-started/sessions/remote-hosts.md b/docs/getting-started/sessions/remote-hosts/index.md similarity index 64% rename from docs/getting-started/sessions/remote-hosts.md rename to docs/getting-started/sessions/remote-hosts/index.md index d90c65c3..52fdf81f 100644 --- a/docs/getting-started/sessions/remote-hosts.md +++ b/docs/getting-started/sessions/remote-hosts/index.md @@ -4,14 +4,10 @@ description: "Launch agent CLI processes on a remote machine over SSH while the layout: doc --- -# Remote Hosts (SSH) - -Operator can launch an agent's CLI process on a **remote machine** while the -dashboard, queue, and tracking stay local. Declare a `[[hosts]]` entry and -reference it from a delegator's `launch_config`: +Operator can launch an agent's CLI process on a **remote machine** while the dashboard, queue, and tracking stay local. Declare a `[[targets]]` entry and reference it from a delegator's `launch_config`: ```toml -[[hosts]] +[[targets]] name = "gpu-vm" ssh_alias = "gpu-vm" # resolved via your ~/.ssh/config workdir = "/srv/agents/my-project" @@ -29,20 +25,13 @@ A host is deliberately distinct from a [model server](/configuration/): a `[[mod ## How it works -The local tmux (or cmux) pane Operator creates runs a generated wrapper script -that: +The local tmux (or cmux) pane Operator creates runs a generated wrapper script that: -1. Ships the prompt file and run script to - `{workdir}/.tickets/operator/` on the host over `ssh` -2. Execs `ssh -t` into a **remote tmux session** (named like the local one, - `op-…`) that runs the agent -3. Opens an SSH **reverse tunnel** for the REST port, so `opr8r` step-completion - callbacks from the remote side reach your local Operator at - `http://localhost:{port}` — the API stays loopback-only on both machines +1. Ships the prompt file and run script to `{workdir}/.tickets/operator/` on the host over `ssh` +2. Execs `ssh -t` into a **remote tmux session** +3. Opens an SSH **reverse tunnel** for the REST port, so `opr8r` step-completion callbacks from the remote side reach your local Operator at `http://localhost:{port}` -Because the tracked pane is local, screen scraping, attach, idle detection, and -send-keys all behave exactly as for local agents. The agent row shows an -`@{host}` annotation in the dashboard. +Because the tracked pane is local, screen scraping, attach, idle detection, and send-keys all behave exactly as for local agents. The agent row shows an `@{host}` annotation in the dashboard. ## Remote host requirements @@ -54,21 +43,15 @@ send-keys all behave exactly as for local agents. The agent row shows an authenticated there (e.g. remote `~/.claude` credentials). - **The project checked out** at `workdir`. - **API keys in the remote environment**: model-server keys are passed by - reference (`export ANTHROPIC_API_KEY=${YOUR_VAR}`) and expand in the *remote* - shell. Export them in a file sourced by non-interactive shells, or rely on - the CLI's own auth. + reference (`export ANTHROPIC_API_KEY=${YOUR_VAR}`) and expand in the *remote* shell. Export them in a file sourced by non-interactive shells, or rely on the CLI's own auth. -Operator preflights all of this (reachability, tmux, tool, workdir) before -creating any session and fails the launch with a specific message if a check -fails. +Operator preflights all of this (reachability, tmux, tool, workdir) before creating any session and fails the launch with a specific message if a check fails. ## Disconnects and reconnecting If the SSH link drops (laptop sleep, network change), the local pane dies and the agent shows as dead — but the **remote tmux session and agent survive**. -Relaunch the ticket from the TUI: the wrapper regenerates and -`tmux new-session -A` reattaches the surviving remote session with scrollback -intact. +Relaunch the ticket from the TUI: the wrapper regenerates and `tmux new-session -A` reattaches the surviving remote session with scrollback intact. ## limitations @@ -78,10 +61,6 @@ intact. liveness relies on pane presence and screen content, the same posture cmux agents have. - **No relay MCP injection** (the relay hub is a local Unix socket). -- **No docker mode** and **no zellij wrapper** with a remote host — both are - rejected at resolution time. -- **One remote agent per host at a time** is the safe posture: concurrent - agents to the same host would collide on the reverse-tunnel port, and the - second launch fails loudly (`ExitOnForwardFailure`). -- Ticket files live on the local machine; remote agents signal progress through - `opr8r` callbacks rather than moving ticket files. +- **No docker mode** and **no zellij wrapper** with a remote host — both are rejected at resolution time. +- **One remote agent per host at a time** is the safe posture: concurrent agents to the same host would collide on the reverse-tunnel port, and the second launch fails loudly. +- Ticket files live on the local machine; remote agents signal progress through `opr8r` callbacks rather than moving ticket files. diff --git a/docs/getting-started/sessions/vscode.md b/docs/getting-started/sessions/vscode.md index 1393e65f..20465fd3 100644 --- a/docs/getting-started/sessions/vscode.md +++ b/docs/getting-started/sessions/vscode.md @@ -4,8 +4,6 @@ description: "VS Code terminal integration for Operator multi-agent orchestratio layout: doc --- -# VS Code Extension - Recommended Install from VS Code Marketplace diff --git a/docs/getting-started/sessions/zed.md b/docs/getting-started/sessions/zed.md index d41556b5..7209f65e 100644 --- a/docs/getting-started/sessions/zed.md +++ b/docs/getting-started/sessions/zed.md @@ -4,8 +4,6 @@ description: "Zed editor integration for Operator via MCP context server, ACP ag layout: doc --- -# Zed - Alpha
diff --git a/docs/getting-started/sessions/zellij.md b/docs/getting-started/sessions/zellij.md index 846ec621..47aed420 100644 --- a/docs/getting-started/sessions/zellij.md +++ b/docs/getting-started/sessions/zellij.md @@ -4,8 +4,6 @@ description: "Zellij terminal workspace manager integration for managing AI agen layout: doc --- -# Zellij Sessions - Supported Operator! supports [**Zellij**](https://zellij.dev){:target="_blank"}, a terminal workspace manager, as a session management backend. When running inside Zellij, Operator can launch and manage AI coding agents in dedicated Zellij tabs. diff --git a/docs/getting-started/tickets/index.md b/docs/getting-started/tickets/index.md index 43d9424d..bb1940da 100644 --- a/docs/getting-started/tickets/index.md +++ b/docs/getting-started/tickets/index.md @@ -4,8 +4,6 @@ description: "Create and manage tickets with markdown format, naming conventions layout: doc --- -# Tickets - Tickets are the unit of work in Operator!. Each one describes a task for an agent to complete, and carries an **issue type** that decides *how* the work is done — see [Workflows](/workflows/) for the process behind the ticket. ## Ticket Format diff --git a/docs/getting-started/workflows/agnt.md b/docs/getting-started/workflows/agnt.md index f27b4a7b..bc9e54b9 100644 --- a/docs/getting-started/workflows/agnt.md +++ b/docs/getting-started/workflows/agnt.md @@ -4,8 +4,6 @@ description: "Export an Operator ticket + issue type into an AGNT.gg workflow gr layout: doc --- -# AGNT Workflow - Renders a `ticket + issue type` into an [AGNT.gg](https://agnt.gg) **workflow graph** — a `{ name, description, nodes, edges }` JSON document AGNT can import and run. diff --git a/docs/getting-started/workflows/claude.md b/docs/getting-started/workflows/claude.md index d1742262..0105f9e1 100644 --- a/docs/getting-started/workflows/claude.md +++ b/docs/getting-started/workflows/claude.md @@ -4,8 +4,6 @@ description: "Export an Operator ticket + issue type into a Claude Code dynamic layout: doc --- -# Claude Workflow - The default export target. Renders a `ticket + issue type` into a **Claude Code dynamic workflow** — a `.js` module the [`@untra/naiveworkflow-compiler`](https://operator.untra.io/getting-started/workflows/) diff --git a/docs/getting-started/workflows/index.md b/docs/getting-started/workflows/index.md index 187115f0..deafbb03 100644 --- a/docs/getting-started/workflows/index.md +++ b/docs/getting-started/workflows/index.md @@ -4,8 +4,6 @@ description: "Export an Operator workflow into a format another LLM tool or mode layout: doc --- -# Workflow Export Formats - Operator is a kanban-shaped orchestrator: each **ticket** carries the work, and its **issue type** carries an **Operator workflow** — an ordered graph of steps (tasks, classifiers, delegators, fan-outs, pipelines, human review gates). That @@ -25,8 +23,8 @@ same input always produces the same output. | Format | Artifact | Status | Docs | |---|---|---|---| -| Claude Workflow | `.js` (Claude Code dynamic workflow) | GA | [Claude Workflow](./claude/) | -| AGNT Workflow | `.json` (AGNT.gg graph) | Alpha | [AGNT Workflow](./agnt/) | +| Claude Workflow | `.js` (Claude Code dynamic workflow) | GA | [Claude Workflow](/getting-started/workflows/claude/) | +| AGNT Workflow | `.json` (AGNT.gg graph) | Alpha | [AGNT Workflow](/getting-started/workflows/agnt/) | The authoritative, machine-readable list is the [`GET /api/v1/workflow-formats`](https://operator.untra.io/schemas/openapi.json) diff --git a/docs/llm-tools/index.md b/docs/llm-tools/index.md index d2fe35b1..49461599 100644 --- a/docs/llm-tools/index.md +++ b/docs/llm-tools/index.md @@ -10,13 +10,60 @@ layout: doc ### Claude Code -The primary LLM tool supported by Operator. Claude Code is a CLI tool that provides: +The primary LLM tool supported by Operator. -- Code generation and editing -- Bug analysis and fixes -- Test writing -- Documentation -- Refactoring +### OpenAI Codex + +### Google Gemini + +## Custom Tool Configs + +Beyond the builtin tools (claude, gemini, codex), any LLM CLI can be added at runtime by dropping a JSON config into the user tool-config directory - no rebuild required: + +- Linux: `~/.config/operator/tools/.json` +- macOS: `~/Library/Application Support/operator/tools/.json` + +Configs are loaded fresh on every startup. A user config whose `tool_name` +matches a builtin **fully replaces** that builtin (no field-by-field merge). +Malformed files are skipped with a logged warning. Runtime-loaded tools work +everywhere the builtins do, including remote (SSH) launches, where the tool's +presence on the remote host is verified by a `command -v` preflight. + +> **Security note:** `command_template` is arbitrary shell executed at launch. +> Operator! only loads tool configs from the +> user-global config directory - never from repository-local paths - so a +> cloned repo cannot inject a tool config. + +The full config format is documented in the schema reference on this site +(source of truth: `src/llm/tools/tool_config.schema.json`). + +## Detection Modes + +By default a tool is detected only when `which ` succeeds. The +optional `detection` object overrides this: + +```json +{ + "detection": { "mode": "always", "health_command": "your-tool ping" } +} +``` + +- `mode: "which"` (default) - gate detection on the binary being in PATH +- `mode: "always"` - skip the PATH lookup and use `tool_name` verbatim as the + invocation path; for tools not installed locally, e.g. only present on a + remote SSH host +- `health_command` - health check run at every startup + +Health is **earned, never assumed**, and re-verified on every startup: + +| Mode | No `health_command` | With `health_command` | +|------|---------------------|-----------------------| +| `which` | Healthy - the PATH lookup proves the binary is present | Healthy if still on PATH **and** the command passes | +| `always` | **Unhealthy** - nothing is locally verifiable | Healthy if the command passes | + +An unhealthy tool stays listed among the detected tools, but launching a local agent with it fails until it is healthy again. + +Remote (SSH) launches are unaffected - they are gated by their own `command -v` preflight on the remote host. An `always`-mode tool should therefore define a `health_command` that proves reachability. ## Integration Points diff --git a/docs/llms.txt b/docs/llms.txt index 88b83bcd..295ce3df 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -18,6 +18,7 @@ Operator runs from the root of your work directory, discovers projects by LLM ma - [Workflows](https://operator.untra.io/workflows/): Shareable collections of Operator workflows. - [Workflow Export Formats](https://operator.untra.io/getting-started/workflows/): Export an Operator workflow into a format another LLM tool or model can run. - [Delegators](https://operator.untra.io/delegators/): Named LLM tool + model pairings for autonomous ticket launching. +- [Remote Hosts (SSH)](https://operator.untra.io/getting-started/sessions/remote-hosts/): Launch agent CLI processes on a remote machine over SSH while the Operator dashboard stays local. ## Integrations - [LLM Tools](https://operator.untra.io/llm-tools/): Configure Claude Code and other LLM tools for AI-powered agent integration with Operator!. diff --git a/docs/maturity/index.md b/docs/maturity/index.md index c55e2d6b..b15112d1 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -6,8 +6,6 @@ layout: doc -# Feature Maturity - Operator integrates with many providers and tools across several **verticals**. Each integration carries an official **support status** so you know what to expect before you depend on it. This page is generated from the same source of truth that drives the README badges and the `/api/v1/integrations` API, so it always reflects the current state. ## Support levels @@ -46,6 +44,8 @@ Operator integrates with many providers and tools across several **verticals**. | GitLab | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [GitLab](https://operator.untra.io/getting-started/git/gitlab/) | | Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | | Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | +| Forgejo | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | +| Gitea | ![Proto](https://img.shields.io/badge/Proto-6B7280) | — | ## Session @@ -90,3 +90,10 @@ Operator integrates with many providers and tools across several **verticals**. |---|---|---| | Claude Workflow | ![GA](https://img.shields.io/badge/GA-1BB91F) | [Claude Workflow](https://operator.untra.io/getting-started/workflows/claude/) | | AGNT Workflow | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT Workflow](https://operator.untra.io/getting-started/workflows/agnt/) | + +## Notification Channel + +| Integration | Status | Docs | +|---|---|---| +| Operating System | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Operating System](https://operator.untra.io/getting-started/notifications/os/) | +| Webhooks | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Webhooks](https://operator.untra.io/getting-started/notifications/webhooks/) | diff --git a/docs/relay/index.md b/docs/relay/index.md index 0f64293f..385a5b7e 100644 --- a/docs/relay/index.md +++ b/docs/relay/index.md @@ -24,7 +24,7 @@ opr8r --ticket-id FEAT-042 --step build -- claude --prompt "implement the featur Operator API → next step / review / done ``` -See the [opr8r CLI reference](/docs/cli/) for full flag documentation. +See the [opr8r CLI reference](/cli/) for full flag documentation. ### relay — MCP client for the relay hub @@ -105,6 +105,6 @@ The protocol is byte-compatible with TypeScript claude-relay. Existing TS channe ## See also -- [Claude agent setup](/docs/getting-started/agents/claude/) -- [Codex agent setup](/docs/getting-started/agents/codex/) -- [Delegators](/docs/delegators/) — named tool + model pairings that launch agents +- [Claude agent setup](/getting-started/agents/claude/) +- [Codex agent setup](/getting-started/agents/codex/) +- [Delegators](/delegators/) — named tool + model pairings that launch agents diff --git a/docs/schemas/api.md b/docs/schemas/api.md index 712f14fd..e133b3f9 100644 --- a/docs/schemas/api.md +++ b/docs/schemas/api.md @@ -4,8 +4,6 @@ description: "Interactive Swagger UI documentation for the Operator! REST API fo layout: doc --- -# Operator REST API - Interactive API documentation powered by Swagger UI. The Operator REST API provides endpoints for managing issue types and collections programmatically. diff --git a/docs/schemas/config.json b/docs/schemas/config.json index ceacb8b1..e3528cbc 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -160,6 +160,14 @@ }, "default": [] }, + "targets": { + "description": "Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`.", + "type": "array", + "items": { + "$ref": "#/$defs/TargetDef" + }, + "default": [] + }, "relay": { "description": "Relay MCP injection configuration", "$ref": "#/$defs/RelayConfig", @@ -968,6 +976,11 @@ "type": "string" }, "default": [] + }, + "health_ok": { + "description": "Whether the tool passed its health check at detection on startup", + "type": "boolean", + "default": false } }, "required": [ @@ -1139,7 +1152,7 @@ } }, "gitlab": { - "description": "GitLab-specific configuration (planned)", + "description": "GitLab-specific configuration", "$ref": "#/$defs/GitLabConfig", "default": { "enabled": false, @@ -1181,6 +1194,16 @@ "description": "Azure DevOps (dev.azure.com)", "type": "string", "const": "azuredevops" + }, + { + "description": "Forgejo (e.g. codeberg.org or self-hosted)", + "type": "string", + "const": "forgejo" + }, + { + "description": "Gitea (gitea.com or self-hosted)", + "type": "string", + "const": "gitea" } ] }, @@ -1201,7 +1224,7 @@ } }, "GitLabConfig": { - "description": "GitLab-specific configuration (planned)", + "description": "GitLab-specific configuration", "type": "object", "properties": { "enabled": { @@ -1253,7 +1276,7 @@ "default": {} }, "openspec": { - "description": "OpenSpec roots keyed by a free-form instance name (e.g., a repo alias).\nExperimental, pull-only: each active change under `/changes/`\nacts as a kanban \"project\" whose issues are the tasks.md task groups.", + "description": "`OpenSpec` roots keyed by a free-form instance name (e.g., a repo alias).\nExperimental, pull-only: each active change under `/changes/`\nacts as a kanban \"project\" whose issues are the tasks.md task groups.", "type": "object", "additionalProperties": { "$ref": "#/$defs/OpenspecConfig" @@ -1409,7 +1432,7 @@ } }, "OpenspecConfig": { - "description": "OpenSpec provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials — the provider reads local markdown under `root_path`.", + "description": "`OpenSpec` provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials — the provider reads local markdown under `root_path`.", "type": "object", "properties": { "enabled": { @@ -1418,7 +1441,7 @@ "default": false }, "root_path": { - "description": "Directory containing the OpenSpec `changes/` tree (typically `/openspec`)", + "description": "Directory containing the `OpenSpec` `changes/` tree (typically `/openspec`)", "type": "string", "default": "" }, @@ -1578,7 +1601,7 @@ "default": null }, "docker": { - "description": "Run in docker container (None = use global `launch.docker.enabled`)", + "description": "DEPRECATED: prefer `target`. Run in docker container\n(None = fall back to `launch.docker.enabled`, then local).", "type": [ "boolean", "null" @@ -1610,7 +1633,14 @@ "default": null }, "host": { - "description": "Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent\nCLI on over SSH. `None` = launch locally.", + "description": "DEPRECATED: prefer `target`. Name of a declared `RemoteHost` (from\n`Config.hosts`) to launch the agent CLI on over SSH. `None` = local.", + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "Name of an execution target: an explicit `[[targets]]` entry, the\nsynthesized `local`/`docker` targets, or a `[[hosts]]` name.\nSupersedes `docker` and `host`.", "type": [ "string", "null" @@ -1709,6 +1739,13 @@ "null" ], "default": null + }, + "ssh_config_path": { + "description": "SSH config fragment passed with `-F` (used by provisioned coder aliases)", + "type": [ + "string", + "null" + ] } }, "required": [ @@ -1717,6 +1754,169 @@ "workdir" ] }, + "TargetDef": { + "description": "A named execution target agents can be launched on.", + "type": "object", + "properties": { + "name": { + "description": "Unique name, referenced by `DelegatorLaunchConfig.target`.\n`local` and `docker` are reserved for synthesized targets.", + "type": "string" + }, + "display_name": { + "description": "Human-readable name for UI surfaces", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ], + "oneOf": [ + { + "description": "Run the agent process directly on this machine", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "local" + } + }, + "required": [ + "kind" + ] + }, + { + "description": "Wrap the agent command in a `docker run` container.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "docker" + } + }, + "$ref": "#/$defs/DockerConfig", + "required": [ + "kind" + ] + }, + { + "description": "Run inside a Coder workspace over a provisioned SSH alias", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "coder" + } + }, + "$ref": "#/$defs/CoderConfig", + "required": [ + "kind" + ] + }, + { + "description": "Run on a remote machine over SSH", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "ssh" + } + }, + "$ref": "#/$defs/SshTarget", + "required": [ + "kind" + ] + } + ] + }, + "CoderConfig": { + "description": "Coder workspace target: lifecycle + alias provisioning around the shared\nSSH remote-launch path. There is no `enabled` field — presence in\n`[[targets]]` is the enablement.", + "type": "object", + "properties": { + "template": { + "description": "Coder template child workspaces are created from (an allowlist —\nnever per-ticket input)", + "type": "string" + }, + "url_env": { + "description": "Env var NAME holding the Coder deployment URL", + "type": "string", + "default": "CODER_URL" + }, + "token_env": { + "description": "Env var NAME holding the Coder session token. The variable is stripped\nfrom every agent's spawn environment on all target kinds.", + "type": "string", + "default": "CODER_SESSION_TOKEN" + }, + "name_prefix": { + "description": "Workspace name prefix for deterministic per-ticket naming", + "type": "string", + "default": "op" + }, + "workdir": { + "description": "Project root inside the workspace (None = workspace $HOME)", + "type": [ + "string", + "null" + ] + }, + "stop_on_complete": { + "description": "Stop the workspace when the ticket completes (never delete)", + "type": "boolean", + "default": true + }, + "create_timeout_secs": { + "description": "Bound on workspace create + agent-ready wait", + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 300 + }, + "callback_url": { + "description": "Control-plane-reachable `OPERATOR_API_URL` override for detached\nmulti-step (empty/None = reverse tunnel default)", + "type": [ + "string", + "null" + ] + }, + "parameters": { + "description": "Passthrough `-p` template parameters for `coder create`", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "template" + ] + }, + "SshTarget": { + "description": "SSH target payload. Name and display name live on `TargetDef`; this is the\nconnection shape (`RemoteHost` minus identity).", + "type": "object", + "properties": { + "ssh_alias": { + "description": "Host alias resolved via the user's `~/.ssh/config` (or `ssh_config_path`)", + "type": "string" + }, + "workdir": { + "description": "Absolute project root on the remote machine", + "type": "string" + }, + "ssh_config_path": { + "description": "SSH config fragment passed with `-F` (used by provisioned coder aliases)", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "ssh_alias", + "workdir" + ] + }, "RelayConfig": { "description": "Relay MCP injection configuration", "type": "object", diff --git a/docs/schemas/config.md b/docs/schemas/config.md index e74b48b4..9c9d7bd8 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -6,8 +6,6 @@ layout: doc -# Configuration Schema - JSON Schema for the Operator configuration file (`config.toml`). ## Schema Information @@ -49,6 +47,7 @@ JSON Schema for the Operator configuration file (`config.toml`). | `delegators` | `array` | No | Agent delegator configurations for autonomous ticket launching | | `model_servers` | `array` | No | User-declared model servers (ollama, lmstudio, any OpenAI-compat host). Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. | | `hosts` | `array` | No | Remote machines agents can be launched on over SSH, referenced by name from `DelegatorLaunchConfig.host`. | +| `targets` | `array` | No | Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. | | `relay` | → `RelayConfig` | No | Relay MCP injection configuration | | `mcp` | → `McpConfig` | No | Model Context Protocol (MCP) server configuration | | `acp` | → `AcpConfig` | No | Agent Client Protocol (ACP) agent configuration | @@ -320,6 +319,7 @@ A detected CLI tool (e.g., claude binary) | `command_template` | `string` | No | Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders | | `capabilities` | → `ToolCapabilities` | No | Tool capabilities | | `yolo_flags` | `array` | No | CLI flags for YOLO (auto-accept) mode | +| `health_ok` | `boolean` | No | Whether the tool passed its health check at detection on startup | ### ToolCapabilities @@ -375,7 +375,7 @@ Git provider configuration for PR/MR operations | --- | --- | --- | --- | | `provider` | object | No | Active provider (auto-detected from remote URL if not specified) | | `github` | → `GitHubConfig` | No | GitHub-specific configuration | -| `gitlab` | → `GitLabConfig` | No | GitLab-specific configuration (planned) | +| `gitlab` | → `GitLabConfig` | No | GitLab-specific configuration | | `branch_format` | `string` | No | Branch naming format (e.g., "{type}/{ticket_id}-{slug}") | | `use_worktrees` | `boolean` | No | Whether to use git worktrees for per-ticket isolation (default: false) When false, tickets work directly in the project directory with branches | @@ -389,6 +389,8 @@ Git provider selection - `gitlab` - GitLab (gitlab.com or self-hosted) - `bitbucket` - Bitbucket (bitbucket.org) - `azuredevops` - Azure DevOps (dev.azure.com) +- `forgejo` - Forgejo (e.g. codeberg.org or self-hosted) +- `gitea` - Gitea (gitea.com or self-hosted) ### GitHubConfig @@ -401,7 +403,7 @@ GitHub-specific configuration ### GitLabConfig -GitLab-specific configuration (planned) +GitLab-specific configuration | Property | Type | Required | Description | | --- | --- | --- | --- | @@ -423,7 +425,7 @@ Providers are keyed by domain/workspace: | `jira` | `object` | No | Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") | | `linear` | `object` | No | Linear instances keyed by workspace slug | | `github` | `object` | No | GitHub Projects v2 instances keyed by owner login (user or org) NOTE: This is the *kanban* GitHub integration (Projects v2), distinct from `GitHubConfig` which is the *git provider* used for PRs and branches. The two use different env vars and different scopes — see `docs/getting-started/kanban/github.md` for the full disambiguation. | -| `openspec` | `object` | No | OpenSpec roots keyed by a free-form instance name (e.g., a repo alias). Experimental, pull-only: each active change under `/changes/` acts as a kanban "project" whose issues are the tasks.md task groups. | +| `openspec` | `object` | No | `OpenSpec` roots keyed by a free-form instance name (e.g., a repo alias). Experimental, pull-only: each active change under `/changes/` acts as a kanban "project" whose issues are the tasks.md task groups. | ### JiraConfig @@ -503,7 +505,7 @@ require different OAuth scopes (`project` vs `repo`). See ### OpenspecConfig -OpenSpec provider configuration (experimental, pull-only) +`OpenSpec` provider configuration (experimental, pull-only) The instance name is the `HashMap` key in `KanbanConfig.openspec`. There are no credentials — the provider reads local markdown under `root_path`. @@ -511,7 +513,7 @@ are no credentials — the provider reads local markdown under `root_path`. | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether this provider is enabled | -| `root_path` | `string` | No | Directory containing the OpenSpec `changes/` tree (typically `/openspec`) | +| `root_path` | `string` | No | Directory containing the `OpenSpec` `changes/` tree (typically `/openspec`) | | `project` | `string` \| `null` | No | Operator project stamped on imported tickets (defaults to the change id) | ### VersionCheckConfig @@ -559,11 +561,12 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override. | `flags` | `array` | No | Additional CLI flags | | `use_worktrees` | `boolean` \| `null` | No | Override global `git.use_worktrees` per-delegator (None = use global setting) | | `create_branch` | `boolean` \| `null` | No | Whether to create a git branch for the ticket (None = default behavior) | -| `docker` | `boolean` \| `null` | No | Run in docker container (None = use global `launch.docker.enabled`) | +| `docker` | `boolean` \| `null` | No | DEPRECATED: prefer `target`. Run in docker container (None = fall back to `launch.docker.enabled`, then local). | | `prompt_prefix` | `string` \| `null` | No | Prompt text to prepend before the generated step prompt | | `prompt_suffix` | `string` \| `null` | No | Prompt text to append after the generated step prompt | | `operator_relay` | `boolean` \| `null` | No | Override global relay auto-inject MCP setting per-delegator (None = use global setting) | -| `host` | `string` \| `null` | No | Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent CLI on over SSH. `None` = launch locally. | +| `host` | `string` \| `null` | No | DEPRECATED: prefer `target`. Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent CLI on over SSH. `None` = local. | +| `target` | `string` \| `null` | No | Name of an execution target: an explicit `[[targets]]` entry, the synthesized `local`/`docker` targets, or a `[[hosts]]` name. Supersedes `docker` and `host`. | ### RemoteAgentRef @@ -616,6 +619,48 @@ Distinct from [`ModelServer`] (where model *inference* lives) and from | `ssh_alias` | `string` | Yes | SSH destination, resolved via the user's `~/.ssh/config` | | `workdir` | `string` | Yes | Absolute path to the project root on the remote host | | `display_name` | `string` \| `null` | No | Optional display name for UI | +| `ssh_config_path` | `string` \| `null` | No | SSH config fragment passed with `-F` (used by provisioned coder aliases) | + +### TargetDef + +A named execution target agents can be launched on. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | Unique name, referenced by `DelegatorLaunchConfig.target`. `local` and `docker` are reserved for synthesized targets. | +| `display_name` | `string` \| `null` | No | Human-readable name for UI surfaces | + +**Allowed Values:** + + +### CoderConfig + +Coder workspace target: lifecycle + alias provisioning around the shared +SSH remote-launch path. There is no `enabled` field — presence in +`[[targets]]` is the enablement. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `template` | `string` | Yes | Coder template child workspaces are created from (an allowlist — never per-ticket input) | +| `url_env` | `string` | No | Env var NAME holding the Coder deployment URL | +| `token_env` | `string` | No | Env var NAME holding the Coder session token. The variable is stripped from every agent's spawn environment on all target kinds. | +| `name_prefix` | `string` | No | Workspace name prefix for deterministic per-ticket naming | +| `workdir` | `string` \| `null` | No | Project root inside the workspace (None = workspace $HOME) | +| `stop_on_complete` | `boolean` | No | Stop the workspace when the ticket completes (never delete) | +| `create_timeout_secs` | `integer` | No | Bound on workspace create + agent-ready wait | +| `callback_url` | `string` \| `null` | No | Control-plane-reachable `OPERATOR_API_URL` override for detached multi-step (empty/None = reverse tunnel default) | +| `parameters` | `object` | No | Passthrough `-p` template parameters for `coder create` | + +### SshTarget + +SSH target payload. Name and display name live on `TargetDef`; this is the +connection shape (`RemoteHost` minus identity). + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `ssh_alias` | `string` | Yes | Host alias resolved via the user's `~/.ssh/config` (or `ssh_config_path`) | +| `workdir` | `string` | Yes | Absolute project root on the remote machine | +| `ssh_config_path` | `string` \| `null` | No | SSH config fragment passed with `-F` (used by provisioned coder aliases) | ### RelayConfig diff --git a/docs/schemas/index.md b/docs/schemas/index.md index 29c82e04..3e887c40 100644 --- a/docs/schemas/index.md +++ b/docs/schemas/index.md @@ -6,8 +6,6 @@ layout: doc -# Schema Reference - This section documents all JSON schemas and type definitions used by Operator. ## Documentation @@ -38,8 +36,8 @@ Machine-readable JSON Schema files for validation and code generation: TypeScript type definitions are available for frontend integration: -- [TypeScript API Documentation](/typescript/) - Generated via TypeDoc - Source: `shared/types.ts` (generated via ts-rs) +- API docs can be generated locally with `npm run docs:typescript` ## Regenerating Schemas diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md index 9a3247e3..1e636358 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -6,8 +6,6 @@ layout: doc -# Issue Type Schema - Schema definition for an issuetype template ## Schema Information @@ -150,8 +148,9 @@ Schema definition for a lifecycle step | `type` | → `StepTypeTag` | No | Step type discriminator (defaults to "task" for backward compatibility) | | `outputs` | `array` | Yes | Types of outputs this step produces | | `prompt` | `string` | Yes | Initial prompt template for the Claude agent | -| `review_type` | → `ReviewType` | No | Type of review required for this step (none, plan, visual, pr) | +| `review_type` | → `ReviewType` | No | Type of review required for this step (none, plan, visual, pr, proof) | | `visual_config` | object | No | Configuration for visual review (required when `review_type` is "visual") | +| `proof_config` | object | No | Configuration for proof review (required when `review_type` is "proof") | | `on_reject` | object | No | What to do if step output is rejected | | `next_step` | `string` \| `null` | No | Name of the next step (None for final step) | | `allowed_tools` | `array` | No | Claude Code tools allowed in this step | @@ -193,6 +192,17 @@ Configuration for visual review steps | `startup_command` | `string` \| `null` | No | Optional startup command (e.g., dev server) to run before opening browser | | `startup_timeout_secs` | `integer` \| `null` | No | Timeout in seconds for server startup (default: 30) | +### Definition: ProofReviewConfig + +Configuration for proof review steps + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `assertion_command` | `string` | Yes | Assertion command run via `sh -c` in the worktree root; exit code 0 = pass. Supports handlebars: `{{ticket_id}}`, `{{step}}`, `{{proof_dir}}` | +| `artifact_command` | `string` \| `null` | No | Artifact-producing command (e.g. screenshot capture), run after the assertion regardless of its result | +| `artifact_patterns` | `array` | No | Glob patterns (relative to worktree root) copied into `.proof/{ticket_id}/{step}/` | +| `timeout_secs` | `integer` \| `null` | No | Per-command timeout in seconds (default 120) | + ### Definition: OnReject Action to take when a step is rejected diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index c7aa5df7..0f4e3579 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -6,8 +6,6 @@ layout: doc -# Ticket Metadata Schema - Schema for operator-tracked ticket metadata in YAML frontmatter. This schema documents the structure of ticket files used by the operator TUI. ## Schema Information diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index a6147660..17b0417e 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -3354,7 +3354,7 @@ }, "review_type": { "type": "string", - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, @@ -3452,7 +3452,7 @@ "boolean", "null" ], - "description": "Run in docker container (None = use global `launch.docker.enabled`)" + "description": "DEPRECATED: prefer `target`. Run in docker container\n(None = fall back to `launch.docker.enabled`, then local)." }, "flags": { "type": "array", @@ -3466,7 +3466,7 @@ "string", "null" ], - "description": "Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent\nCLI on over SSH. `None` = launch locally." + "description": "DEPRECATED: prefer `target`. Name of a declared `RemoteHost` (from\n`Config.hosts`) to launch the agent CLI on over SSH. `None` = local." }, "operator_relay": { "type": [ @@ -3496,6 +3496,13 @@ ], "description": "Prompt text to append after the generated step prompt" }, + "target": { + "type": [ + "string", + "null" + ], + "description": "Name of an execution target: an explicit `[[targets]]` entry, the\nsynthesized `local`/`docker` targets, or a `[[hosts]]` name.\nSupersedes `docker` and `host`." + }, "use_worktrees": { "type": [ "boolean", @@ -3539,7 +3546,7 @@ "string", "null" ], - "description": "Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local)" + "description": "Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local).\nDEPRECATED: prefer `target`." }, "operator_relay": { "type": [ @@ -3569,6 +3576,13 @@ ], "description": "Prompt text to append after the generated step prompt" }, + "target": { + "type": [ + "string", + "null" + ], + "description": "Name of an execution target (explicit `[[targets]]` entry, synthesized\n`local`/`docker`, or a `[[hosts]]` name). Supersedes `docker`/`host`." + }, "use_worktrees": { "type": [ "boolean", @@ -3691,6 +3705,10 @@ "type": "string", "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" }, + "health_ok": { + "type": "boolean", + "description": "Whether the tool passed its health check at detection on startup" + }, "min_version": { "type": [ "string", @@ -4424,6 +4442,10 @@ "timestamp" ], "properties": { + "filename": { + "type": "string", + "description": "Ticket markdown filename (joins with the tickets dir + status folder\nfor co-located clients; remote clients treat tickets as API-only)" + }, "id": { "type": "string", "description": "Ticket ID (e.g., \"FEAT-7598\")" @@ -4511,6 +4533,13 @@ ], "description": "Feedback for relaunch (what went wrong on previous attempt)" }, + "target": { + "type": [ + "string", + "null" + ], + "description": "Execution-target override by name (explicit `[[targets]]` entry,\nsynthesized `local`/`docker`, or a `[[hosts]]` name). Overrides the\ndelegator's launch config for this launch only." + }, "wrapper": { "type": [ "string", @@ -4553,6 +4582,10 @@ "type": "string", "description": "Command to execute in terminal" }, + "executed_server_side": { + "type": "boolean", + "description": "True when the server executed the launch itself (non-local targets:\ndocker/coder/ssh orchestration is server-side); `command` is then\nempty and the client must NOT run anything." + }, "session_context_ref": { "type": [ "string", @@ -5119,7 +5152,7 @@ }, "review_type": { "type": "string", - "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, @@ -5991,7 +6024,7 @@ }, "review_type": { "type": "string", - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, @@ -6323,7 +6356,7 @@ "string", "null" ], - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 13705282..8c24a4ec 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -163,7 +163,7 @@ "default": null }, "pr_url": { - "description": "PR URL if created during \"pr\" step", + "description": "PR/MR URL if created during the \"pr\" step", "type": [ "string", "null" @@ -171,7 +171,7 @@ "default": null }, "pr_number": { - "description": "PR number for GitHub API tracking", + "description": "Code review request (PR/MR) number", "type": [ "integer", "null" @@ -180,8 +180,8 @@ "minimum": 0, "default": null }, - "github_repo": { - "description": "GitHub repo in format \"owner/repo\"", + "repo": { + "description": "Repository in \"owner/repo\" format on the configured git provider", "type": [ "string", "null" @@ -189,7 +189,7 @@ "default": null }, "pr_status": { - "description": "Last known PR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")", + "description": "Last known PR/MR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")", "type": [ "string", "null" @@ -221,7 +221,7 @@ "default": null }, "launch_mode": { - "description": "Launch mode: \"default\", \"yolo\", \"docker\", \"docker-yolo\"", + "description": "Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]`\n(derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match)", "type": [ "string", "null" @@ -261,6 +261,26 @@ "null" ], "default": null + }, + "step_launch_context": { + "description": "Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model.", + "anyOf": [ + { + "$ref": "#/$defs/StepLaunchContext" + }, + { + "type": "null" + } + ], + "default": null + }, + "target_name": { + "description": "Name of the resolved execution target this agent launched on", + "type": [ + "string", + "null" + ], + "default": null } }, "required": [ @@ -274,6 +294,66 @@ "paired" ] }, + "StepLaunchContext": { + "description": "Launch context fixed at launch time, persisted with the agent record, and\nread back by `complete_step` to build subsequent step commands.\n\nThe persisted context is the baseline for a ticket's whole chain; per-step\n`agent` overrides from the step schema apply on top for that step only.", + "type": "object", + "properties": { + "delegator": { + "description": "Delegator name used at launch (None = ad-hoc provider/model)", + "type": [ + "string", + "null" + ], + "default": null + }, + "tool": { + "description": "Resolved LLM tool (e.g. \"claude\")", + "type": "string" + }, + "model": { + "description": "Resolved model alias (e.g. \"sonnet\")", + "type": "string" + }, + "yolo": { + "description": "YOLO (auto-accept) mode", + "type": "boolean" + }, + "session_id": { + "description": "Session UUID of the step launched with this context (informational;\neach transition mints a fresh UUID for the next step)", + "type": [ + "string", + "null" + ], + "default": null + }, + "opr8r": { + "description": "opr8r invocation for the launch environment (\"opr8r\" inside a\ncontainer where the image ships it on PATH, an absolute path locally).\nSteps exec inside the same environment, so the value holds chain-wide.", + "type": "string" + }, + "operator_relay": { + "description": "Relay MCP injection override from the delegator launch config", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "extra_flags": { + "description": "Extra CLI flags from the delegator launch config", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "tool", + "model", + "yolo", + "opr8r" + ] + }, "CompletedTicket": { "type": "object", "properties": { diff --git a/docs/schemas/state.md b/docs/schemas/state.md index ca74a9dd..8c03a7bf 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -6,8 +6,6 @@ layout: doc -# Application State Schema - JSON Schema for the Operator runtime state file (`state.json`). This file tracks the current state of agents, completed tickets, and system status. @@ -58,18 +56,39 @@ This file tracks the current state of agents, completed tickets, and system stat | `current_step` | `string` \| `null` | No | Current step in the ticket workflow (e.g., "plan", "implement", "test") | | `step_started_at` | `string` \| `null` | No | When the current step started (for timeout detection) | | `last_content_change` | `string` \| `null` | No | Last time content changed in the session (for hung detection) | -| `pr_url` | `string` \| `null` | No | PR URL if created during "pr" step | -| `pr_number` | `integer` \| `null` | No | PR number for GitHub API tracking | -| `github_repo` | `string` \| `null` | No | GitHub repo in format "owner/repo" | -| `pr_status` | `string` \| `null` | No | Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") | +| `pr_url` | `string` \| `null` | No | PR/MR URL if created during the "pr" step | +| `pr_number` | `integer` \| `null` | No | Code review request (PR/MR) number | +| `repo` | `string` \| `null` | No | Repository in "owner/repo" format on the configured git provider | +| `pr_status` | `string` \| `null` | No | Last known PR/MR status ("open", "approved", "`changes_requested`", "merged", "closed") | | `completed_steps` | `array` | No | Completed steps for this ticket | | `llm_tool` | `string` \| `null` | No | LLM tool used (e.g., "claude", "gemini", "codex") | | `llm_model` | `string` \| `null` | No | LLM model alias (e.g., "opus", "sonnet", "gpt-4o") | -| `launch_mode` | `string` \| `null` | No | Launch mode: "default", "yolo", "docker", "docker-yolo" | +| `launch_mode` | `string` \| `null` | No | Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) | | `review_state` | `string` \| `null` | No | Review state for `awaiting_input` agents Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" | | `dev_server_pid` | `integer` \| `null` | No | Server process ID for visual review cleanup (if applicable) | | `worktree_path` | `string` \| `null` | No | Path to the git worktree for this ticket (per-ticket isolation) | | `remote_host` | `string` \| `null` | No | Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) | +| `step_launch_context` | object | No | Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. | +| `target_name` | `string` \| `null` | No | Name of the resolved execution target this agent launched on | + +### StepLaunchContext + +Launch context fixed at launch time, persisted with the agent record, and +read back by `complete_step` to build subsequent step commands. + +The persisted context is the baseline for a ticket's whole chain; per-step +`agent` overrides from the step schema apply on top for that step only. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `delegator` | `string` \| `null` | No | Delegator name used at launch (None = ad-hoc provider/model) | +| `tool` | `string` | Yes | Resolved LLM tool (e.g. "claude") | +| `model` | `string` | Yes | Resolved model alias (e.g. "sonnet") | +| `yolo` | `boolean` | Yes | YOLO (auto-accept) mode | +| `session_id` | `string` \| `null` | No | Session UUID of the step launched with this context (informational; each transition mints a fresh UUID for the next step) | +| `opr8r` | `string` | Yes | opr8r invocation for the launch environment ("opr8r" inside a container where the image ships it on PATH, an absolute path locally). Steps exec inside the same environment, so the value holds chain-wide. | +| `operator_relay` | `boolean` \| `null` | No | Relay MCP injection override from the delegator launch config | +| `extra_flags` | `array` | No | Extra CLI flags from the delegator launch config | ### CompletedTicket diff --git a/docs/shortcuts/index.md b/docs/shortcuts/index.md index e99d3d49..559d81c9 100644 --- a/docs/shortcuts/index.md +++ b/docs/shortcuts/index.md @@ -6,8 +6,6 @@ layout: doc -# Keyboard Shortcuts - Operator uses vim-style keybindings for navigation and actions. This reference documents all available keyboard shortcuts. ## Quick Reference @@ -53,7 +51,7 @@ Operator uses vim-style keybindings for navigation and actions. This reference d | `E/e` | Edit ticket ($EDITOR) | Launch Dialog | | `N/n` | Cancel | Launch Dialog | | `M/m` | Cycle provider/model | Launch Dialog | -| `D/d` | Toggle Docker mode | Launch Dialog | +| `D/d` | Cycle execution target | Launch Dialog | | `Y/y` | Toggle Auto-accept (YOLO) | Launch Dialog | ## Dashboard @@ -155,6 +153,6 @@ These shortcuts are available in the ticket launch confirmation dialog. | `E/e` | Edit ticket ($EDITOR) | | `N/n` | Cancel | | `M/m` | Cycle provider/model | -| `D/d` | Toggle Docker mode | +| `D/d` | Cycle execution target | | `Y/y` | Toggle Auto-accept (YOLO) | diff --git a/docs/startup/index.md b/docs/startup/index.md index 64cf4d0f..8ce64082 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -6,8 +6,6 @@ layout: doc -# Setup Wizard - When Operator starts and no `.tickets/` directory exists, the setup wizard guides you through first-time initialization. This reference documents each step of the wizard. ## Steps Overview diff --git a/docs/superpowers/plans/2026-05-16-acp-agent.md b/docs/superpowers/plans/2026-05-16-acp-agent.md deleted file mode 100644 index 76fddd8a..00000000 --- a/docs/superpowers/plans/2026-05-16-acp-agent.md +++ /dev/null @@ -1,977 +0,0 @@ -# Operator as an ACP Agent — Editor-Hosted Sessions over Stdio - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -> -> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. - -**Goal:** Make operator runnable as an Agent Client Protocol (ACP) agent so editors that speak ACP — Zed, JetBrains (via the ACP Agent Registry), Emacs (`agent-shell`), Kiro, OpenCode, marimo, and Eclipse — can launch operator as a subprocess and host kanban-aware sessions inside the IDE. The editor becomes the chat surface; operator owns the ticket lifecycle and routes work to the configured delegator. - -**Architecture:** ACP is JSON-RPC 2.0 over stdio, bidirectional (both sides may initiate requests), with a session-based model. Editor spawns operator via `operator acp`, sends `initialize`, then `session/new` with a working directory, then `session/prompt` with user text. Operator responds with streaming `session/update` notifications and a final prompt response. Operator's v1 strategy is **bridge mode**: each ACP session corresponds to one operator ticket, and `session/prompt` launches a delegator subprocess (Claude Code, Codex CLI, Gemini CLI — whatever is configured) whose output is translated into ACP `session/update` chunks. Operator does **not** try to be the LLM-driving agent itself; it's the orchestrator that owns "which ticket, which delegator, which project." Lifecycle, config, and status integration mirror the existing `RestApiServer` pattern (`src/rest/server.rs`). - -**Tech Stack:** Rust 1.88+, tokio (async stdio + subprocess), `agent-client-protocol` crate (official Rust SDK from `github.com/agentclientprotocol/agent-client-protocol`), serde_json, clap (CLI), ratatui (status integration), the existing delegator infrastructure under `src/agents/`. - ---- - -## Critical Structural Approach - -Four decisions lock the rest of the plan: - -1. **Depend on the official `agent-client-protocol` Rust crate.** Operator already hand-rolls MCP JSON-RPC types because MCP is simple and the surface is small. ACP is bidirectional and has dozens of method shapes — hand-rolling is a maintenance trap. The Zed-published crate provides the `Agent` trait and message types; operator implements the trait. Verify the latest crate version on crates.io before pinning. - -2. **One ACP session = one operator ticket.** When the editor calls `session/new`, operator either (a) parses the working directory and prompt to attach to an existing in-progress ticket, or (b) creates a new ticket from a system prompt. The `sessionId` returned to the editor is the ticket UUID. This makes the ACP session traceable in the kanban board and lets the same session be resumed via `session/load` after a restart. - -3. **`session/prompt` does not run an LLM in-process — it delegates.** Operator spawns its configured delegator (Claude Code, Codex CLI, Gemini CLI) via the existing `src/agents/launcher.rs` infrastructure. The delegator's stdout/stderr is translated, line by line, into ACP `session/update` notifications. This keeps operator's orchestration role honest: it does not compete with the agent runtimes; it composes them. - -4. **Mirror the `RestApiServer` lifecycle for the stdio listener.** Even though `operator acp` is typically spawned by an editor (so its lifetime is bound to one editor connection), operator's TUI may also launch ACP listeners for inspection/testing. The `AcpAgentServer` handle (Status enum, `Arc>`, oneshot shutdown, session-file in `.operator/acp-session.json`) follows the same shape as `src/rest/server.rs:75-218`. The status panel (`ConnectionsSection`) gets an "ACP" row alongside "Operator API" and "MCP". - -The first task verifies the crate name and version. Everything else hangs off task 1. - ---- - -## File Structure - -**Create:** -- `src/acp/mod.rs` — module root, public re-exports -- `src/acp/agent.rs` — `OperatorAcpAgent` struct implementing the crate's `Agent` trait -- `src/acp/session.rs` — `AcpSession` (sessionId ↔ ticket ↔ delegator subprocess) -- `src/acp/translator.rs` — converts delegator stdout lines into ACP `session/update` notifications -- `src/acp/server.rs` — `AcpAgentServer` lifecycle handle (mirrors `RestApiServer`) -- `src/acp/client_configs.rs` — config snippets for Zed, JetBrains, Emacs, Kiro -- `tests/acp_integration.rs` — spawn `operator acp`, send initialize + session/new, assert response shape - -**Modify:** -- `Cargo.toml` — add `agent-client-protocol` dependency -- `src/main.rs:24-35` (modules), `:131-266` (Commands), `:281-362` (match arm), bottom (`cmd_acp`) -- `src/config.rs` — add `AcpConfig` struct + field on `Config` -- `src/ui/status_panel.rs` — add ACP fields to `StatusSnapshot`, new `StatusAction` variants -- `src/ui/sections/connections_section.rs` — add an ACP row - ---- - -## Tasks - -### Task 1: Add the `agent-client-protocol` crate dependency - -**Files:** -- Modify: `Cargo.toml` - -- [ ] **Step 1: Find the latest crate version** - -Run: `cargo search agent-client-protocol --limit 5` -Expected output (similar): `agent-client-protocol = "0.21.0" # Rust SDK for ACP` - -Record the latest version. If the search fails or returns no results, fall back to checking the GitHub releases page at `https://github.com/agentclientprotocol/agent-client-protocol/releases` and reading the `Cargo.toml` in the `rust/` subdirectory. - -- [ ] **Step 2: Add to `Cargo.toml`** - -Edit `Cargo.toml`. Under `[dependencies]`, add: - -```toml -agent-client-protocol = "0.21" # pin to the version from Step 1 -``` - -- [ ] **Step 3: Verify it compiles** - -Run: `cargo build` -Expected: clean build (just downloads + compiles the new crate; no operator code uses it yet). - -- [ ] **Step 4: Skim the crate's `Agent` trait** - -Run: `cargo doc --open --package agent-client-protocol` -Read the `Agent` trait and its associated message types. The remaining tasks reference method names from this trait. If the trait has changed shape relative to this plan (rename, new required method), pause and update the plan before proceeding. - -- [ ] **Step 5: Stop for commit review** - ---- - -### Task 2: Scaffold `src/acp/` and wire it into the crate - -**Files:** -- Create: `src/acp/mod.rs` -- Modify: `src/main.rs:24-35` (module list) - -- [ ] **Step 1: Create the module file** - -Create `src/acp/mod.rs`: - -```rust -//! Agent Client Protocol (ACP) integration for Operator. -//! -//! Operator runs as an ACP agent that editors (Zed, JetBrains, Emacs, -//! Kiro, etc.) launch as a stdio subprocess. Each ACP session maps to -//! one operator ticket. Prompts are delegated to the configured runtime -//! (Claude Code, Codex CLI, Gemini CLI), and the delegator's stream is -//! translated into ACP `session/update` notifications. -//! -//! See: https://agentclientprotocol.com/ - -pub mod agent; -pub mod client_configs; -pub mod server; -pub mod session; -pub mod translator; -``` - -- [ ] **Step 2: Register the module** - -In `src/main.rs` around line 27 (alongside `mod mcp;`), add: - -```rust -mod acp; -``` - -- [ ] **Step 3: Verify** - -Run: `cargo build` -Expected: FAIL with "unresolved module" for each of `agent`, `client_configs`, `server`, `session`, `translator` — files don't exist yet. Comment out the unresolved lines, leaving only `pub mod agent;` (the first one we'll fill in). Or proceed straight to Task 3. - -- [ ] **Step 4: Stop for commit review** - ---- - -### Task 3: Implement `OperatorAcpAgent` skeleton — initialize + capabilities - -**Files:** -- Create: `src/acp/agent.rs` - -The exact trait method signatures depend on the crate version pinned in Task 1. Adjust if the crate's `Agent` trait differs from what's shown here. Consult `cargo doc --open --package agent-client-protocol`. - -- [ ] **Step 1: Write a failing test for the initialize response** - -Create `src/acp/agent.rs`: - -```rust -//! Operator's implementation of the ACP `Agent` trait. - -use std::sync::Arc; -use tokio::sync::Mutex; - -use crate::acp::session::SessionRegistry; -use crate::config::Config; - -/// The operator-side ACP agent. -/// -/// Holds operator state needed to handle ACP requests: config, the -/// per-session ticket registry, and a handle to the delegator launcher. -pub struct OperatorAcpAgent { - pub config: Config, - pub sessions: Arc>, -} - -impl OperatorAcpAgent { - pub fn new(config: Config) -> Self { - Self { - config, - sessions: Arc::new(Mutex::new(SessionRegistry::default())), - } - } -} - -// ---- ACP Agent trait implementation ---- -// -// The exact trait shape and method signatures come from the -// `agent-client-protocol` crate. Look at the trait definition (cargo doc) -// and implement each required method. The skeleton below shows the four -// methods we need for v1: -// -// - initialize: return capabilities -// - new_session: create a session + ticket, return sessionId -// - prompt: delegate to the configured runtime, stream updates -// - cancel: signal the in-flight delegator to stop -// -// Use the crate's request/response types verbatim — do not re-define them. - -#[async_trait::async_trait] -impl agent_client_protocol::Agent for OperatorAcpAgent { - async fn initialize( - &self, - _params: agent_client_protocol::InitializeParams, - ) -> Result { - Ok(agent_client_protocol::InitializeResponse { - protocol_version: agent_client_protocol::PROTOCOL_VERSION, - agent_capabilities: agent_client_protocol::AgentCapabilities { - load_session: false, // v1: no resume - prompt_capabilities: Default::default(), - }, - auth_methods: vec![], - }) - } - - async fn new_session( - &self, - params: agent_client_protocol::NewSessionParams, - ) -> Result { - let session_id = self.sessions.lock().await.create_session(&self.config, ¶ms).await - .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; - Ok(agent_client_protocol::NewSessionResponse { session_id }) - } - - async fn prompt( - &self, - _params: agent_client_protocol::PromptParams, - ) -> Result { - // Implemented in Task 5 — for now return a placeholder so the trait compiles. - Err(agent_client_protocol::Error::method_not_supported( - "session/prompt not yet implemented", - )) - } - - async fn cancel( - &self, - _params: agent_client_protocol::CancelParams, - ) -> Result<(), agent_client_protocol::Error> { - Err(agent_client_protocol::Error::method_not_supported( - "session/cancel not yet implemented", - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_initialize_advertises_v1_capabilities() { - let agent = OperatorAcpAgent::new(Config::default()); - let resp = agent.initialize(agent_client_protocol::InitializeParams::default()).await.unwrap(); - // v1 does not implement session/load — explicitly assert that - assert!(!resp.agent_capabilities.load_session); - } -} -``` - -- [ ] **Step 2: Run the test (expect compilation issues)** - -Run: `cargo test acp::agent` -Expected: Likely FAIL due to type/method-name mismatches with whatever version of the crate is pinned. Read the compile errors, look at `cargo doc`, and adjust the types/field names to match the crate's actual API. **Do not invent type names**; mirror what the crate exports. - -- [ ] **Step 3: Verify the test passes** - -Run: `cargo test acp::agent` -Expected: PASS. - -- [ ] **Step 4: Stop for commit review** - ---- - -### Task 4: Implement `SessionRegistry` — sessionId ↔ ticket mapping - -**Files:** -- Create: `src/acp/session.rs` - -- [ ] **Step 1: Write a failing test** - -```rust -//! ACP session registry. -//! -//! Maps ACP session IDs (UUIDs) to operator tickets and the spawned -//! delegator subprocess for the in-flight prompt. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::process::Child; -use tokio::sync::Mutex; - -use crate::config::Config; - -#[derive(Default)] -pub struct SessionRegistry { - sessions: HashMap, -} - -pub struct AcpSession { - pub session_id: String, - pub ticket_id: String, - pub working_directory: PathBuf, - pub delegator: Option>>, -} - -impl SessionRegistry { - /// Create a new session, allocating a ticket. - /// - /// The ticket is created in `.tickets/in-progress/` immediately - /// (because the editor is actively using it) using the existing - /// `services::ticket_manager`. - pub async fn create_session( - &mut self, - config: &Config, - params: &agent_client_protocol::NewSessionParams, - ) -> Result { - let session_id = uuid::Uuid::new_v4().to_string(); - let ticket_id = crate::services::ticket_manager::create_in_progress( - config, - ¶ms.cwd, - &format!("ACP session from {}", params.cwd.display()), - ).await?; - let session = AcpSession { - session_id: session_id.clone(), - ticket_id, - working_directory: params.cwd.clone(), - delegator: None, - }; - self.sessions.insert(session_id.clone(), session); - Ok(session_id) - } - - pub fn get(&self, session_id: &str) -> Option<&AcpSession> { - self.sessions.get(session_id) - } - - pub fn get_mut(&mut self, session_id: &str) -> Option<&mut AcpSession> { - self.sessions.get_mut(session_id) - } - - pub fn remove(&mut self, session_id: &str) -> Option { - self.sessions.remove(session_id) - } - - pub fn len(&self) -> usize { - self.sessions.len() - } - - pub fn is_empty(&self) -> bool { - self.sessions.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_session_registry_create_and_lookup() { - let mut reg = SessionRegistry::default(); - let temp = tempfile::TempDir::new().unwrap(); - let config = { - let mut c = Config::default(); - c.paths.tickets_dir = temp.path().to_string_lossy().to_string(); - c - }; - let params = agent_client_protocol::NewSessionParams { - cwd: temp.path().to_path_buf(), - mcp_servers: vec![], - }; - let session_id = reg.create_session(&config, ¶ms).await.unwrap(); - assert!(reg.get(&session_id).is_some()); - assert_eq!(reg.len(), 1); - } -} -``` - -- [ ] **Step 2: Add the supporting `ticket_manager::create_in_progress` function** - -Grep for the existing ticket creation surface (`rg "fn create_ticket|fn write_ticket" src/`). If `create_in_progress` doesn't exist, add it alongside the existing creation function. It should write `.tickets/in-progress/{id}.md` with minimal frontmatter and return the ticket ID. - -- [ ] **Step 3: Run the test** - -Run: `cargo test acp::session` -Expected: PASS. - -- [ ] **Step 4: Stop for commit review** - ---- - -### Task 5: Implement `session/prompt` — bridge to delegator + stream updates - -**Files:** -- Modify: `src/acp/agent.rs` (replace the placeholder `prompt` impl) -- Create: `src/acp/translator.rs` - -This is the core of operator-as-ACP. When the editor calls `session/prompt`, operator (a) looks up the session, (b) spawns the configured delegator with the prompt as input, (c) reads delegator stdout line-by-line, (d) emits ACP `session/update` notifications for each chunk, (e) returns the final response when the delegator exits. - -- [ ] **Step 1: Implement the translator** - -Create `src/acp/translator.rs`: - -```rust -//! Translate delegator subprocess output into ACP session/update notifications. -//! -//! Different delegators (Claude Code, Codex CLI, Gemini CLI) have different -//! stdout formats. This module hosts per-delegator translators. v1 implements -//! the simplest case: treat each non-empty line as an `assistant_message_chunk`. - -use agent_client_protocol::SessionUpdate; - -pub fn line_to_update(line: &str) -> Option { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - // Future: parse JSON-formatted output from `claude --output-format stream-json` - // and emit structured tool-call / tool-result updates. For v1, plain text. - Some(SessionUpdate::AssistantMessageChunk { - content: agent_client_protocol::ContentBlock::Text { - text: format!("{}\n", trimmed), - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_blank_line_ignored() { - assert!(line_to_update("").is_none()); - assert!(line_to_update(" ").is_none()); - } - - #[test] - fn test_text_line_becomes_chunk() { - let update = line_to_update("hello").unwrap(); - match update { - SessionUpdate::AssistantMessageChunk { content } => match content { - agent_client_protocol::ContentBlock::Text { text } => { - assert!(text.contains("hello")); - } - _ => panic!("expected text content"), - }, - _ => panic!("expected assistant message chunk"), - } - } -} -``` - -(Field names like `SessionUpdate::AssistantMessageChunk` and `ContentBlock::Text` come from the crate — adjust if the crate uses different names.) - -- [ ] **Step 2: Replace the placeholder `prompt` in `agent.rs`** - -Replace the placeholder `prompt` impl from Task 3 with: - -```rust - async fn prompt( - &self, - params: agent_client_protocol::PromptParams, - notifier: agent_client_protocol::Notifier, - ) -> Result { - use tokio::io::{AsyncBufReadExt, BufReader}; - - let session_id = params.session_id.clone(); - let (cwd, ticket_id) = { - let sessions = self.sessions.lock().await; - let s = sessions.get(&session_id).ok_or_else(|| { - agent_client_protocol::Error::invalid_params(format!("Unknown session: {session_id}")) - })?; - (s.working_directory.clone(), s.ticket_id.clone()) - }; - - // Build the delegator command from operator's configured default - let delegator = crate::agents::launcher::resolve_default_delegator(&self.config) - .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; - let prompt_text = params.prompt.iter() - .filter_map(|block| match block { - agent_client_protocol::ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - - let mut child = tokio::process::Command::new(&delegator.command) - .args(&delegator.args) - .current_dir(&cwd) - .env("OPERATOR_TICKET_ID", &ticket_id) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| agent_client_protocol::Error::internal(format!("spawn delegator: {e}")))?; - - // Pipe the prompt to the delegator's stdin - if let Some(mut stdin) = child.stdin.take() { - use tokio::io::AsyncWriteExt; - let _ = stdin.write_all(prompt_text.as_bytes()).await; - let _ = stdin.write_all(b"\n").await; - } - - // Stream stdout → session/update notifications - if let Some(stdout) = child.stdout.take() { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if let Some(update) = crate::acp::translator::line_to_update(&line) { - notifier.session_update(&session_id, update).await.ok(); - } - } - } - - let status = child.wait().await - .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; - - Ok(agent_client_protocol::PromptResponse { - stop_reason: if status.success() { - agent_client_protocol::StopReason::EndTurn - } else { - agent_client_protocol::StopReason::Refusal - }, - }) - } -``` - -(`agents::launcher::resolve_default_delegator` may need to be added if not present — it returns a `Delegator` struct with `command` and `args` based on operator's `[delegators]` config.) - -- [ ] **Step 3: Add a smoke test using `cat` as the delegator** - -Append to `src/acp/agent.rs::tests`: - -```rust - #[tokio::test] - async fn test_prompt_uses_cat_as_delegator_smoke() { - // Verifies the pipe-through path end-to-end using /bin/cat as the - // fake delegator. Skipped on non-unix. - #[cfg(unix)] - { - // ... configure agent with a Delegator { command: "cat", args: [] } - // ... call agent.prompt with text "hello" - // ... assert at least one AssistantMessageChunk with "hello" - } - } -``` - -(Filled in by the engineer using whatever mocking surface the crate's `Notifier` provides — likely a `MockNotifier` collected into a `Vec`.) - -- [ ] **Step 4: Run** - -Run: `cargo test acp::` -Expected: PASS. - -- [ ] **Step 5: Stop for commit review** - ---- - -### Task 6: `operator acp` CLI subcommand - -**Files:** -- Create: `src/acp/server.rs` -- Modify: `src/main.rs:131-266` (Commands), `:281-362` (match), bottom (`cmd_acp`) - -- [ ] **Step 1: Implement the stdio entrypoint in `src/acp/server.rs`** - -```rust -//! ACP server lifecycle — runs the stdio listener. -//! -//! Mirrors the shape of `src/rest/server.rs:RestApiServer` so the TUI can -//! query status / start / stop. - -use std::sync::{Arc, Mutex}; -use tokio::sync::oneshot; -use tokio::task::JoinHandle; - -use crate::config::Config; - -#[derive(Debug, Clone, PartialEq)] -pub enum AcpStatus { - Stopped, - Starting, - Running { active_sessions: usize }, - Stopping, - Error(String), -} - -pub struct AcpAgentServer { - config: Config, - status: Arc>, - shutdown_tx: Arc>>>, - task_handle: Arc>>>, -} - -impl AcpAgentServer { - pub fn new(config: Config) -> Self { - Self { - config, - status: Arc::new(Mutex::new(AcpStatus::Stopped)), - shutdown_tx: Arc::new(Mutex::new(None)), - task_handle: Arc::new(Mutex::new(None)), - } - } - - pub fn status(&self) -> AcpStatus { - self.status.lock().unwrap().clone() - } - - pub fn is_running(&self) -> bool { - matches!(self.status(), AcpStatus::Running { .. }) - } -} - -/// Run the ACP stdio listener using the given reader/writer. -/// -/// Production callers pass `tokio::io::stdin()` / `tokio::io::stdout()`. -pub async fn run(config: Config, reader: R, writer: W) -> anyhow::Result<()> -where - R: tokio::io::AsyncRead + Unpin + Send + 'static, - W: tokio::io::AsyncWrite + Unpin + Send + 'static, -{ - let agent = crate::acp::agent::OperatorAcpAgent::new(config); - // Use the crate's stdio adapter to wire the Agent impl onto a stdio - // transport. Exact function name from `cargo doc --open --package agent-client-protocol`. - agent_client_protocol::stdio::serve(agent, reader, writer).await?; - Ok(()) -} -``` - -- [ ] **Step 2: Add the CLI subcommand** - -In `src/main.rs`, add an `Acp` variant after `Mcp`: - -```rust - /// Run as an ACP agent over stdio (for use by Zed, JetBrains, Emacs, Kiro, etc.). - Acp, -``` - -Add the match arm: - -```rust - Some(Commands::Acp) => { - cmd_acp(&config).await?; - } -``` - -Add `cmd_acp` at the bottom of main.rs: - -```rust -async fn cmd_acp(config: &Config) -> Result<()> { - tracing::info!("Starting ACP stdio agent"); - crate::acp::server::run(config.clone(), tokio::io::stdin(), tokio::io::stdout()).await?; - tracing::info!("ACP agent stopped (stdin closed)"); - Ok(()) -} -``` - -- [ ] **Step 3: Verify the binary runs** - -Run: `cargo build --release` -Run: -``` -printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' | ./target/release/operator acp -``` -Expected: One line of JSON containing the operator agent's `initialize` response. (Exact shape determined by the crate.) - -- [ ] **Step 4: Stop for commit review** - ---- - -### Task 7: Add `[acp]` config section - -**Files:** -- Modify: `src/config.rs` - -- [ ] **Step 1: Add `AcpConfig`** - -```rust -#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct AcpConfig { - /// Whether to advertise the ACP stdio entrypoint in the status panel. - #[serde(default = "default_true")] - pub stdio_advertised: bool, - /// Default delegator to use when an ACP session/prompt arrives. - /// If None, falls back to `delegators[0]`. - #[serde(default)] - pub default_delegator: Option, - /// Maximum number of concurrent ACP sessions. Defaults to 4. - #[serde(default = "default_max_sessions")] - pub max_concurrent_sessions: usize, -} - -impl Default for AcpConfig { - fn default() -> Self { - Self { - stdio_advertised: true, - default_delegator: None, - max_concurrent_sessions: 4, - } - } -} - -fn default_max_sessions() -> usize { 4 } -``` - -- [ ] **Step 2: Add the field to `Config`** - -```rust - #[serde(default)] - pub acp: AcpConfig, -``` - -Update `Config::default()`. - -- [ ] **Step 3: Regenerate config docs** - -Run: `cargo run -- docs --only config` -Verify the generated docs describe `[acp]`. - -- [ ] **Step 4: Stop for commit review** - ---- - -### Task 8: ACP editor config snippet generator - -**Files:** -- Create: `src/acp/client_configs.rs` - -Editors integrate ACP agents by registering them in editor-specific config files. Generate the right snippet for each. - -- [ ] **Step 1: Implement** - -```rust -//! Generates copy-paste ACP agent registrations for various editors. - -use serde_json::{json, Value}; -use std::path::PathBuf; - -fn exe() -> PathBuf { - std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) -} - -/// Zed: agent_servers entry in settings.json -pub fn zed_snippet() -> Value { - json!({ - "agent_servers": { - "operator": { - "command": exe().to_string_lossy(), - "args": ["acp"], - "env": {} - } - } - }) -} - -/// JetBrains: registered via the ACP Agent Registry; the operator entry -/// is a JSON object that JetBrains imports. -pub fn jetbrains_snippet() -> Value { - json!({ - "name": "operator", - "displayName": "Operator (Kanban Orchestrator)", - "command": exe().to_string_lossy(), - "args": ["acp"], - "icon": "https://operator.untra.io/icon.png" - }) -} - -/// Emacs (agent-shell): elisp form to add to init -pub fn emacs_snippet() -> String { - format!( - "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))", - exe().display() - ) -} - -/// Kiro CLI: ~/.kiro/agents.toml entry -pub fn kiro_snippet() -> String { - format!( - "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n", - exe().display() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_zed_snippet_shape() { - let snippet = zed_snippet(); - assert_eq!(snippet["agent_servers"]["operator"]["args"][0], "acp"); - } - - #[test] - fn test_emacs_snippet_is_valid_elisp() { - let snippet = emacs_snippet(); - assert!(snippet.starts_with("(add-to-list")); - assert!(snippet.contains("acp")); - } -} -``` - -- [ ] **Step 2: Run tests** - -Run: `cargo test acp::client_configs` -Expected: PASS. - -- [ ] **Step 3: Stop for commit review** - ---- - -### Task 9: Integrate ACP into `StatusSnapshot` and `ConnectionsSection` - -**Files:** -- Modify: `src/ui/status_panel.rs` -- Modify: `src/ui/sections/connections_section.rs` - -- [ ] **Step 1: Add ACP fields to `StatusSnapshot`** - -```rust - /// Whether the `[acp]` stdio entrypoint is advertised. - pub acp_stdio_advertised: bool, - /// Active ACP sessions (only relevant if operator launched an ACP listener itself). - pub acp_active_sessions: usize, -``` - -- [ ] **Step 2: Add new `StatusAction` variants** - -```rust - /// Copy an ACP editor config snippet to the clipboard. - /// `editor` is one of: "zed", "jetbrains", "emacs", "kiro". - CopyAcpEditorConfig { editor: String }, - /// Open ACP setup docs in the browser. - OpenAcpDocs, -``` - -- [ ] **Step 3: Add an ACP row to `ConnectionsSection::children`** - -After the MCP row added by the MCP plan (Task 9), append: - -```rust - rows.push(TreeRow { - section_id: SectionId::Connections, - depth: 1, - label: "ACP".into(), - description: if snapshot.acp_stdio_advertised { - if snapshot.acp_active_sessions > 0 { - format!("stdio · {} sessions", snapshot.acp_active_sessions) - } else { - "stdio ready".into() - } - } else { - "Disabled".into() - }, - icon: if snapshot.acp_stdio_advertised { StatusIcon::Plug } else { StatusIcon::Cross }, - is_header: false, - actions: ActionSet { - primary: StatusAction::CopyAcpEditorConfig { editor: "zed".to_string() }, - back: StatusAction::None, - special: StatusAction::CopyAcpEditorConfig { editor: "jetbrains".to_string() }, - special_meta: Some(ActionMeta { title: "JBrn", tooltip: "Copy JetBrains ACP registry snippet" }), - refresh: StatusAction::OpenAcpDocs, - refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open ACP setup docs" }), - }, - health: SectionHealth::Gray, - }); -``` - -- [ ] **Step 4: Update `StatusSnapshot` construction site** - -Same pattern as the MCP plan's Task 9 Step 4: populate the new fields. `acp_active_sessions` defaults to `0` unless operator is also hosting an ACP listener itself (uncommon — the editor typically hosts). - -- [ ] **Step 5: Update test snapshots** - -Search test files for `StatusSnapshot {` (now including the MCP fields from the MCP plan) and add `acp_stdio_advertised: true, acp_active_sessions: 0`. - -- [ ] **Step 6: Add a test for the ACP row** - -```rust - #[test] - fn test_connections_acp_row_present() { - let section = ConnectionsSection; - let snap = base_snapshot(); - let children = section.children(&snap); - let row = children.iter().find(|r| r.label == "ACP"); - assert!(row.is_some(), "ACP row should always be present"); - } -``` - -- [ ] **Step 7: Wire the action handlers** - -Find the `StatusAction` dispatch site (grep `StatusAction::StartApi =>`). Add: -- `CopyAcpEditorConfig { editor }` — generate via `crate::acp::client_configs`, write to clipboard -- `OpenAcpDocs` — open `https://operator.untra.io/acp/` via the existing `OpenUrl` helper - -- [ ] **Step 8: Verify** - -Run: `cargo fmt && cargo clippy -- -D warnings && cargo test` -Expected: green. - -- [ ] **Step 9: Stop for commit review** - ---- - -### Task 10: End-to-end integration test - -**Files:** -- Create: `tests/acp_integration.rs` - -- [ ] **Step 1: Write the test** - -```rust -//! Spawn `operator acp` and roundtrip an initialize request. - -use std::process::Stdio; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::Command; - -#[tokio::test] -async fn test_operator_acp_initialize_roundtrip() { - let exe = env!("CARGO_BIN_EXE_operator"); - let mut child = Command::new(exe) - .arg("acp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn operator acp"); - - let mut stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - let mut reader = BufReader::new(stdout).lines(); - - // Initialize message — exact shape depends on the ACP crate version - let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} -"#; - stdin.write_all(init).await.unwrap(); - stdin.flush().await.unwrap(); - - let line = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()) - .await.unwrap().unwrap().unwrap(); - let resp: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(resp["id"], 1); - assert!(resp["result"].is_object()); - - drop(stdin); - let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; -} -``` - -- [ ] **Step 2: Run** - -Run: `cargo test --test acp_integration -- --nocapture` -Expected: PASS. - -- [ ] **Step 3: Final verification** - -Run: -``` -cargo fmt -cargo clippy -- -D warnings -cargo test -``` -Expected: green. - -- [ ] **Step 4: Stop for user to commit** - ---- - -## Self-Review - -**Spec coverage:** -- ACP crate dependency (Task 1) — covered -- `Agent` trait impl with initialize / new_session / prompt / cancel (Tasks 3, 5) — covered (`session/cancel` is left as a stub in Task 3; v1.1 should implement it by signaling the delegator subprocess) -- Session ↔ ticket mapping (Task 4) — covered -- Delegator bridge + stream translation (Task 5) — covered -- CLI subcommand (Task 6) — covered -- Config section (Task 7) — covered -- Editor config snippets (Task 8) — covered -- Status integration (Task 9) — covered -- End-to-end test (Task 10) — covered - -**Open assumptions to verify before starting:** -1. Exact crate name, version, and trait shape of `agent-client-protocol`. Task 1 validates. -2. The `Notifier` injection on `Agent::prompt` — the trait method signature in Task 3 shows `_params` only, but Task 5 uses `notifier: Notifier`. Confirm the real signature; the crate likely passes the notifier via a `&self` field or an additional parameter. -3. The existence of `agents::launcher::resolve_default_delegator` — may need to be added. -4. `services::ticket_manager::create_in_progress` — may need to be added. - -**v1 limitations explicitly accepted:** -- No `session/load` — ACP sessions don't survive operator restart. (Easy to add later: the registry writes to `.operator/acp-sessions.json`.) -- No `session/cancel` — a v1.1 task. -- No structured tool-call translation. The delegator's raw text becomes `AssistantMessageChunk`. When using Claude Code with `--output-format stream-json`, parse and emit structured `ToolCall` updates instead. (Task 5's translator is the single chokepoint to extend.) -- No `fs/*` request forwarding. The delegator subprocess does its own filesystem access. This means file edits don't surface as approvable actions in the editor — an explicit v1 tradeoff. If a target editor needs approval routing, switch from "delegator owns FS" to "operator owns FS, asks editor for permission" in v2. -- Single-tenant: operator runs in the project directory the editor opens it in. Multi-project sessions need a v2 design decision (do separate editor windows share an operator process, or each get their own?). diff --git a/docs/superpowers/plans/2026-05-16-acp-zed-extension.md b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md deleted file mode 100644 index d8b570a4..00000000 --- a/docs/superpowers/plans/2026-05-16-acp-zed-extension.md +++ /dev/null @@ -1,320 +0,0 @@ -# Plan: ACP Integration for the Operator Zed Extension - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -> -> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. - -## Context - -A sibling plan, `docs/superpowers/plans/2026-05-16-acp-agent.md`, wires the **operator binary** itself as an ACP agent (`operator acp` over stdio). This follow-up plan picks up where that one ends: integrate ACP into the **`zed-extension/`** package so Zed users can launch Operator from the agent panel — not just via slash commands. - -Today the Zed extension (`zed-extension/src/lib.rs`, `extension.toml`) is a WASM-sandboxed slash-command bridge: 11 `/op-*` commands shell out to `curl` against the local REST API (`http://localhost:7008`). It does not register an ACP agent. The Zed agent panel surface is unused. - -This plan adds ACP-agent registration to the existing extension so: -- Zed's agent panel shows **Operator** as a selectable agent alongside Claude / Codex / Gemini CLI -- Selecting Operator and opening a new thread spawns `operator acp` in the project root, wired to Zed via JSON-RPC stdio -- The existing `/op-*` slash commands stay — they cover different needs (status queries, queue inspection) and complement the agent thread - -Workflow this enables: a developer in a Zed window for project X opens the agent panel, picks Operator, and chats. Operator (per the upstream ACP plan) creates a ticket from that chat, picks the next queued ticket if one matches, and delegates to Claude Code / Codex / Gemini under the hood — streaming the delegator's output back as `session/update` notifications visible inside Zed. - -## Hard Dependency - -This plan **assumes the operator-side ACP plan is complete and merged.** Specifically: -- `operator acp` subcommand exists and serves a working `Agent` impl over stdio -- `initialize`, `session/new`, and `session/prompt` roundtrip cleanly -- `tests/acp_integration.rs` is green - -If `operator acp` doesn't exist yet, **execute the upstream plan first.** This plan adds Zed-side packaging on top. - -## How Zed Discovers ACP Agents (Key Facts) - -From `https://zed.dev/docs/extensions/agent-servers` and the user-config docs: - -1. **Manifest registration:** A Zed extension declares ACP agents via `[agent_servers.]` blocks in `extension.toml`. Each block has `name`, optional `icon`, optional `env`, plus per-platform `targets.-` entries with `archive` (download URL), `cmd`, `args`, and recommended `sha256`. -2. **User override:** Users can override extension-provided agents (or add custom ones) under `agent_servers` in `settings.json`. The custom form is `{"type": "custom", "command": "...", "args": [...], "env": {...}}`. The registry form is `{"type": "registry", ...}` for curated entries. -3. **Lifecycle:** Zed spawns the configured command as a subprocess with `cwd` = the project root and pipes JSON-RPC over its stdio. No WASM API call is required from the extension code (`src/lib.rs`). -4. **Forwarded context:** Zed passes the project root as `cwd` in `session/new`, plus MCP server configurations, and forwards model/mode selection if the agent advertises support. - -Consequence: the **majority of this plan is `extension.toml` + docs + a release pipeline** — `src/lib.rs` does not need ACP code, because ACP runs in the operator binary, not in the WASM sandbox. - -## Critical Files - -**Modify:** -- `zed-extension/extension.toml` — add `[agent_servers.operator]` block with platform targets -- `zed-extension/README.md` — document the agent panel flow alongside slash commands -- `zed-extension/TODO.md` — mark ACP agent panel as ✅ implemented; audit "not possible" entries against what ACP unblocks -- `bump-version.sh` — bump the extension's version when shipping -- `.github/workflows/*.yml` (or equivalent) — publish per-platform operator archives whose URLs are referenced from `extension.toml` - -**Create:** -- `zed-extension/docs/acp-setup.md` — short walkthrough: install the extension, configure `agent_servers` in `settings.json` for dev, or use the bundled archive in release mode -- `zed-extension/tests/acp_smoke.sh` (or a CI step) — end-to-end smoke that builds operator, starts it under `operator acp`, sends `initialize`, asserts the JSON response -- `src/integrations/inventory.rs` (operator crate) — single source-of-truth list of operator capabilities exposed across surfaces -- `tests/surface_parity.rs` (operator crate) — enforces that every capability has both a slash-command and an ACP-tool entry point - -**Do NOT modify:** -- `zed-extension/src/lib.rs` — slash commands stay as-is. ACP runs out-of-process in the operator binary, not in the extension WASM. - -## Tasks - -### Task 1: Confirm operator-side ACP is functional - -- [ ] **Step 1:** Run `cargo run -- acp < /tmp/init.json` from operator root with a hand-rolled JSON-RPC `initialize` request. Assert it produces a valid `InitializeResponse` containing `agentCapabilities` with `loadSession: false` (per upstream plan v1). -- [ ] **Step 2:** Run `cargo test --test acp_integration` and confirm green. -- [ ] **Step 3:** If either fails, **stop** — the upstream plan is the blocker; finish that first. - ---- - -### Task 2: Add `[agent_servers.operator]` to `extension.toml` - -**Files:** -- Modify: `zed-extension/extension.toml` - -- [ ] **Step 1:** Add the agent_servers block after `[slash_commands]`: - -```toml -[agent_servers.operator] -name = "Operator" -icon = "https://operator.untra.io/icon.png" - -[agent_servers.operator.targets.darwin-aarch64] -archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-aarch64.tar.gz" -cmd = "./operator" -args = ["acp"] -sha256 = "{SHA256}" - -[agent_servers.operator.targets.darwin-x86_64] -archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-x86_64.tar.gz" -cmd = "./operator" -args = ["acp"] -sha256 = "{SHA256}" - -[agent_servers.operator.targets.linux-x86_64] -archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-x86_64.tar.gz" -cmd = "./operator" -args = ["acp"] -sha256 = "{SHA256}" - -[agent_servers.operator.targets.linux-aarch64] -archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-aarch64.tar.gz" -cmd = "./operator" -args = ["acp"] -sha256 = "{SHA256}" -``` - -- [ ] **Step 2:** Pin `{VERSION}` to the operator version that first contains `operator acp`. Bake `{SHA256}` per target at release time via `bump-version.sh` (or accept manual updates in Task 3). -- [ ] **Step 3:** Confirm `extension.toml` parses by building the extension: - ```bash - cd zed-extension && cargo build --release --target wasm32-wasip1 - ``` -- [ ] **Step 4:** Stop for commit review. - ---- - -### Task 3: Update the release pipeline to ship operator archives - -The `archive` URLs in Task 2 must resolve to real artifacts. Inventory `.github/workflows/` and `bump-version.sh` first to see what exists; add the missing pieces. - -**Files:** -- Modify: `.github/workflows/*.yml` (release workflow) -- Modify: `bump-version.sh` - -- [ ] **Step 1:** Add a CI step that, on a tagged release, produces `operator-{os}-{arch}.tar.gz` for the four target tuples in Task 2. Each archive contains the `operator` binary at the archive root (so `cmd = "./operator"` resolves). -- [ ] **Step 2:** Add a CI step that computes each archive's `sha256` and rewrites `zed-extension/extension.toml` with the real `{SHA256}` and `{VERSION}` values before publishing the extension. -- [ ] **Step 3:** Verify by tagging a pre-release and downloading one archive locally: - ```bash - tar -tzf operator-darwin-aarch64.tar.gz | head - ``` - Expected: `operator` appears at the top level. -- [ ] **Step 4:** Stop for commit review. - ---- - -### Task 4: Document the dev-mode override - -Most operator developers will not consume the archive — they'll point Zed at their local debug build. Document this clearly so the extension is usable before the release pipeline is finished. - -**Files:** -- Create: `zed-extension/docs/acp-setup.md` - -- [ ] **Step 1:** Write the setup doc, including: - - ````markdown - # Operator ACP Setup - - ## Dev mode (local binary) - - Add to `~/.config/zed/settings.json` (or per-project `.zed/settings.json`): - - ```jsonc - { - "agent_servers": { - "operator": { - "type": "custom", - "command": "/Users/you/Documents/gbqr-us/operator/target/debug/operator", - "args": ["acp"], - "env": { - "RUST_LOG": "operator=debug" - } - } - } - } - ``` - - This override takes precedence over the extension-provided `[agent_servers.operator]` block, so you can run an unreleased build of operator without rebuilding the extension. - - ## Verify it works - - 1. Open Zed's agent panel - 2. Pick **Operator** from the agent selector - 3. Open a new thread - 4. Type `hello` — you should see streamed output - - ## Release mode - - Install the extension from the Zed extension registry. Zed fetches the matching `operator-{os}-{arch}.tar.gz` archive automatically; no `settings.json` changes needed. - - ## Known issues - - (Populated as Task 7 surfaces them.) - ```` - -- [ ] **Step 2:** Cross-link from `zed-extension/README.md` and the operator-side `docs/cli/index.md` ACP section. -- [ ] **Step 3:** Stop for commit review. - ---- - -### Task 5: Rewrite README + TODO to reflect dual-surface - -`zed-extension/README.md` and `zed-extension/TODO.md` currently document only the slash-command surface and list many features as "Not Possible in Zed." ACP unblocks several. Update them honestly. - -**Files:** -- Modify: `zed-extension/README.md` -- Modify: `zed-extension/TODO.md` - -- [ ] **Step 1:** In `README.md`, add a top-level "Two ways to use the extension" section: - 1. **Slash commands** — existing, REST-backed status queries surfaced in the AI assistant - 2. **Agent panel** — new, ACP-backed full sessions inside Zed -- [ ] **Step 2:** State explicitly that the two surfaces are intentionally parallel — they cover the same operator concepts (queue, tickets, agents, kanban) but from different entry points. Task 6 enforces this with tests. -- [ ] **Step 3:** In `TODO.md`, audit each "Not Possible in Zed" row honestly: - - Sidebar Views → still N/A (ACP doesn't help here) - - Webhook Server → still N/A - - **Terminal Management** → N/A in extension, but ACP sessions provide an in-IDE chat surface - - **Status Bar** → still N/A - - **File System Watching** → still N/A in WASM, but the ACP path lets the agent see filesystem state via `fs/read_text_file` requests routed to Zed -- [ ] **Step 4:** Be specific about what ACP does and doesn't add. Don't oversell. -- [ ] **Step 5:** Stop for commit review. - ---- - -### Task 6: Structural-parity tests between slash-command and ACP surfaces - -The two surfaces (slash commands, ACP threads) must stay in structural sync: adding a new operator capability shouldn't expose it on only one side. Drive both surfaces from a single shared inventory of operator capabilities and let CI enforce the contract. - -**Files:** -- Create: `src/integrations/inventory.rs` (operator crate) -- Create: `tests/surface_parity.rs` (operator crate) -- Create: `zed-extension/tests/acp_smoke.sh` - -- [ ] **Step 1: Add `src/integrations/inventory.rs`** (operator-side, not WASM) enumerating user-facing operator capabilities. One entry per: - ```rust - pub struct Capability { - pub id: &'static str, // e.g. "queue.list" - pub description: &'static str, - pub rest_endpoint: Option<&'static str>, // path matched against OpenAPI - pub slash_command_id: Option<&'static str>, // e.g. "op-queue" - pub acp_tool_id: Option<&'static str>, // e.g. "operator__queue_list" - } - - pub const INVENTORY: &[Capability] = &[ /* ... */ ]; - ``` - Use the existing OpenAPI generation output as the source-of-truth for `rest_endpoint` values. - -- [ ] **Step 2: Add `tests/surface_parity.rs`** asserting: - 1. Every `slash_command_id` in the inventory corresponds to a registered slash command in `zed-extension/extension.toml` (parse the TOML, check the `[slash_commands]` table). - 2. Every `acp_tool_id` is exposed by the operator ACP agent. The exact mechanism depends on what the upstream ACP plan ships — if v1 only delegates to Claude/Codex/Gemini, the ACP surface may expose operator-specific tools through the co-shipped MCP server (covered by the MCP plan at `2026-05-16-mcp-stdio-and-tickets.md`). - 3. Every entry has BOTH a `slash_command_id` AND an `acp_tool_id`, OR an explicit allow-list reason in a separate `tests/fixtures/surface_exceptions.toml`. The default is parity; deviations require an explicit rationale. - -- [ ] **Step 3: Add `zed-extension/tests/acp_smoke.sh`** for runtime smoke (separate from parity): - ```bash - #!/usr/bin/env bash - set -euo pipefail - cd "$(dirname "$0")/../.." - cargo build --bin operator - printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' \ - | ./target/debug/operator acp \ - | head -1 \ - | jq -e '.result.agentCapabilities' - ``` - -- [ ] **Step 4: Wire both into CI.** Parity runs as `cargo test --test surface_parity`. Smoke runs as a shell job. Any failure blocks releases. - -- [ ] **Step 5: Document the contract in `zed-extension/README.md`:** "Adding a new operator capability requires registering it in `src/integrations/inventory.rs`. CI will fail if the new entry lacks either a slash command or an ACP tool (without an explicit exception entry)." - -- [ ] **Step 6:** Run full validation: - ```bash - cargo fmt && cargo clippy -- -D warnings && cargo test - bash zed-extension/tests/acp_smoke.sh - ``` - Expected: green. - -- [ ] **Step 7:** Stop for commit review. - ---- - -### Task 7: User-facing verification in Zed - -Before declaring the integration shipped, manually verify in the real editor — CI cannot prove this. - -- [ ] **Step 1:** Install the extension as dev: - ```bash - cd zed-extension && cargo build --release --target wasm32-wasip1 - mkdir -p ~/.local/share/zed/extensions/installed/operator-dev/ - cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/ - cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm - ``` -- [ ] **Step 2:** Apply the `settings.json` override from Task 4. -- [ ] **Step 3:** Open a Zed project that has `.tickets/` (the operator repo itself works). -- [ ] **Step 4:** Open agent panel → confirm **Operator** appears in the agent selector. -- [ ] **Step 5:** Open a thread → confirm the prompt arrives and streams a response from the configured delegator. -- [ ] **Step 6:** Cancel a thread mid-stream → confirm the delegator process exits (per upstream plan's `session/cancel` task — may be a v1.1 follow-up). -- [ ] **Step 7:** Document any rough edges in `zed-extension/docs/acp-setup.md` under "Known issues." -- [ ] **Step 8:** Stop for user to commit. - ---- - -## Verification - -End-to-end acceptance passes when: -1. `cargo build --release --target wasm32-wasip1` from `zed-extension/` produces a valid WASM artifact. -2. `bash zed-extension/tests/acp_smoke.sh` exits 0. -3. `cargo test --test surface_parity` exits 0. -4. In Zed with the dev override: opening the agent panel → Operator → new thread → typing `hello` → streamed text returns. (Human verification — primary gate.) -5. The four release archive URLs in `extension.toml` resolve to real artifacts whose SHA256 matches. - -## Self-Review - -**Spec coverage:** -- Operator-side ACP confirmation (Task 1) — covered -- Extension manifest (Task 2) — covered -- Release pipeline (Task 3) — covered -- Dev-mode override docs (Task 4) — covered -- README + TODO updates (Task 5) — covered -- Structural-parity tests + smoke (Task 6) — covered -- Manual Zed verification (Task 7) — covered - -**Open assumptions:** -- The operator-side ACP plan ships first. This plan is gated on `operator acp` working — without it there's nothing for Zed to connect to. -- Operator's release pipeline can produce per-target archives. If today's pipeline only produces a single platform, Task 3 expands. -- The Zed `[agent_servers.]` manifest schema is stable. Zed documents it publicly, but the schema is newer than the slash-command API and may shift. - -**Explicit non-goals (v1):** -- No JetBrains, Emacs, Kiro, etc. integration. The operator-side plan generates config snippets (Task 8 there) covering those — they don't need a per-editor extension because they read user config directly. -- No deprecation of slash commands. They serve different workflows. -- No sidebar / status bar / file watcher work — ACP doesn't unblock these in Zed's current extension API. -- No per-ticket "Open in Operator agent panel" deep-link from slash commands. Conceivable but out of scope here. - -**Resolved decisions:** -1. Release archives are in scope for v1 (Task 3 ships per-platform tarballs + SHA256 + `extension.toml` pinning). -2. Slash commands stay as a parallel surface. Task 6 enforces structural parity between the two surfaces with tests so they cannot drift silently. -3. Plan file lives at `docs/superpowers/plans/2026-05-16-acp-zed-extension.md` (sibling of the operator-side ACP plan). diff --git a/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md deleted file mode 100644 index 2004b80c..00000000 --- a/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md +++ /dev/null @@ -1,1580 +0,0 @@ -# Operator MCP — Stdio Transport, Ticket Tools, and Status Integration - -> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -> -> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. - -**Goal:** Add stdio transport for operator's MCP server, expand the tool surface to cover ticket queue read/write operations, advertise the stdio entrypoint through the existing `McpDescriptorResponse` so the vscode-extension can pick it up, and surface MCP lifecycle through operator's existing `StatusSection` pattern so users can toggle it and copy client configs from the dashboard. - -**Architecture:** The HTTP/SSE MCP transport (`src/mcp/transport.rs`) already implements the protocol bound to `ApiState` and exposes seven read-only tools. `src/mcp/descriptor.rs` already publishes a discovery endpoint that the vscode-extension consumes. The structural move is to (1) extract the JSON-RPC dispatch core into a transport-agnostic module, (2) add a stdio transport that reads line-delimited JSON-RPC from stdin and writes to stdout, (3) add an `operator mcp` CLI subcommand as the entrypoint MCP clients launch, (4) expand the tool surface with ticket-queue operations that call into the existing `src/queue/Queue` (sync API, wrapped via `tokio::task::spawn_blocking`) and `TicketCreator`, (5) extend the existing descriptor with an optional `stdio: StdioCommand` field so IDE extensions can switch transports without a new endpoint, and (6) add a row to `ConnectionsSection` mirroring the `Operator API` lifecycle pattern. Stdio is the dominant MCP transport across Claude Code, Cursor, VS Code, Zed, and JetBrains; the existing HTTP transport stays as-is for network use. - -**Tech Stack:** Rust 1.88+, tokio, serde_json, axum (existing for HTTP), clap (CLI), ratatui (status integration), ts-rs + schemars (for config + descriptor binding regeneration). No new top-level dependencies required — `tokio::io::{AsyncBufReadExt, AsyncWriteExt}` is sufficient for the stdio loop, and `EditFile` action + existing file I/O cover the client-config snippet delivery (no clipboard dependency). - ---- - -## Pre-Flight (verify before starting) - -Run each of these from the operator project root and confirm the output matches the assumption. If any differ, fix the relevant task before implementing. - -1. `rg -n "pub fn claim_ticket|pub fn complete_ticket|pub fn list_queue" src/queue/` - → should find sync methods in `src/queue/mod.rs` around lines 134-158. -2. `rg -n "pub struct McpDescriptorResponse" src/mcp/` - → should find `src/mcp/descriptor.rs:14`. Confirms the descriptor already exists (do not re-create it). -3. `rg -n "mcp_sessions" src/rest/state.rs` - → should find the field on `ApiState` typed `Arc>>` where `Mutex` is `tokio::sync::Mutex` (line 7 imports). -4. `head -50 vscode-extension/src/mcp-connect.ts` - → confirm the consumer reads `server_name`, `transport_url` from the descriptor. The descriptor extension in Task 6.5 must stay additive (Option field with `skip_serializing_if`). -5. `cargo build --release && ls target/release/operator` - → confirms the binary path that `client_configs::current_exe()` will return. -6. `rg -n "pub struct TicketCreator|pub fn create_ticket_with_values" src/queue/creator.rs` - → should find the existing creator at lines 16-68. Confirms the headless variant in Task 5.5 is additive. - ---- - -## Critical Structural Approach - -Three decisions lock the rest of the plan: - -1. **Transport-agnostic handler.** The function `handle_jsonrpc(&JsonRpcRequest, &ApiState) -> JsonRpcResponse` in `src/mcp/transport.rs` already has the right shape but lives in a file named after HTTP. Extract it to `src/mcp/handler.rs` unchanged. Both transports import it. - -2. **`ApiState` is the shared substrate; `Queue` is the ticket-write surface.** Existing tools call `routes::*` handlers, which take `State`. New ticket-queue tools should construct `crate::queue::Queue::new(&state.config)` and call its **sync** methods (`list_queue`, `claim_ticket(&Ticket)`, `complete_ticket(&Ticket)`, `return_to_queue(&Ticket)`) inside `tokio::task::spawn_blocking`. Creation uses `crate::queue::creator::TicketCreator` via a new headless variant (Task 5.5). Do **not** introduce an in-process HTTP roundtrip. - -3. **No new server lifecycle for stdio.** Stdio MCP is spawned by the client (Claude Code, Cursor, VS Code, …) as a subprocess — it does not run inside the operator TUI. The HTTP-MCP toggle (`config.mcp.http_enabled`) is implemented by conditionally including the MCP routes in `build_router`; flipping it requires an API restart. There is **no** `McpStdioServer` struct, no shutdown channels for stdio. Status display in `ConnectionsSection` reflects (a) whether HTTP MCP routes are mounted on the current API server and (b) whether the stdio entrypoint is advertised in the descriptor. - ---- - -## File Structure - -**Create:** -- `src/mcp/handler.rs` — transport-agnostic `handle_jsonrpc` + JSON-RPC types -- `src/mcp/stdio.rs` — line-delimited stdio JSON-RPC loop -- `src/mcp/tickets.rs` — ticket-queue tools (separated from REST-wrapping `tools.rs`) -- `src/mcp/resources.rs` — MCP resources (tickets exposed as URIs) -- `src/mcp/client_configs.rs` — generates copy-paste config snippets for Claude Code, Claude Desktop, Cursor, VS Code, Zed -- `tests/mcp_stdio_integration.rs` — end-to-end test: spawn `operator mcp`, send init + tools/list over a pipe - -**Modify:** -- `src/mcp/mod.rs` — add `handler`, `stdio`, `tickets`, `resources`, `client_configs` modules -- `src/mcp/transport.rs` — import `handle_jsonrpc` from `handler.rs`; delete the local copy -- `src/mcp/tools.rs` — merge ticket tools from `tickets.rs` into `all_tool_definitions` and `execute_tool`; update tool-count assertion -- `src/mcp/descriptor.rs` — extend existing `McpDescriptorResponse` with `stdio: Option`; inject `State` into the handler so it can read `config.mcp.stdio_advertised` -- `src/rest/mod.rs` — gate MCP route mounting on `config.mcp.http_enabled` -- `src/queue/creator.rs` — add `create_ticket_headless` (no editor launch) -- `src/main.rs` — add `Commands::Mcp` variant and `cmd_mcp` async fn -- `src/config.rs` — add `McpConfig` struct (fields: `http_enabled`, `stdio_advertised`, `expose_ticket_write_tools`) with `JsonSchema + TS` derives, and field on `Config` -- `src/ui/status_panel.rs` — add `mcp_http_status: McpHttpStatus` + `mcp_stdio_advertised: bool` + `mcp_active_sessions: usize` to `StatusSnapshot`; add new `StatusAction` variants: `ToggleMcpHttp`, `WriteAndOpenMcpClientConfig { client: String }`, `OpenMcpDocs` -- `src/ui/sections/connections_section.rs` — add an "MCP" row after the "Operator API" row -- `src/ui/dashboard.rs` — populate the new `StatusSnapshot` fields at the construction site -- `src/app/status_actions.rs` — handle the three new `StatusAction` variants - -Session files live under `/operator/` (see `src/rest/server.rs:27`). Generated client-config snippets go to `/operator/mcp/.json`. - ---- - -## Tasks - -### Task 1: Extract the JSON-RPC handler to a transport-agnostic module - -**Files:** -- Create: `src/mcp/handler.rs` -- Modify: `src/mcp/transport.rs` -- Modify: `src/mcp/mod.rs:7-9` - -- [ ] **Step 1: Move types and dispatch to `handler.rs`** - -Create `src/mcp/handler.rs` with the contents below. These are the existing types from `transport.rs:24-51` plus the existing `handle_jsonrpc` fn from `transport.rs:136-233`, with `pub` added to `handle_jsonrpc` and `JsonRpcResponse`/`JsonRpcError` so other transports can use them. - -```rust -//! Transport-agnostic JSON-RPC handler for MCP. -//! -//! Both the HTTP/SSE transport (`transport.rs`) and the stdio transport -//! (`stdio.rs`) dispatch through `handle_jsonrpc`. - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - -use crate::mcp::tools; -use crate::rest::state::ApiState; - -#[derive(Debug, Deserialize)] -pub struct JsonRpcRequest { - #[allow(dead_code)] - pub jsonrpc: String, - pub id: Option, - pub method: String, - #[serde(default)] - pub params: Value, -} - -#[derive(Debug, Serialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - pub id: Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Serialize)] -pub struct JsonRpcError { - pub code: i64, - pub message: String, -} - -pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { - let id = request.id.clone().unwrap_or(Value::Null); - match request.method.as_str() { - "initialize" => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "protocolVersion": "2024-11-05", - "capabilities": { "tools": {}, "resources": { "subscribe": false, "listChanged": false } }, - "serverInfo": { "name": "operator", "version": env!("CARGO_PKG_VERSION") } - })), - error: None, - }, - "notifications/initialized" => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({})), - error: None, - }, - "tools/list" => { - let tool_defs = tools::all_tool_definitions(); - let tools_json: Vec = tool_defs.into_iter().map(|t| json!({ - "name": t.name, - "description": t.description, - "inputSchema": t.input_schema, - })).collect(); - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ "tools": tools_json })), - error: None, - } - } - "tools/call" => { - let tool_name = request.params.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let arguments = request.params.get("arguments").cloned().unwrap_or_else(|| json!({})); - match tools::execute_tool(tool_name, arguments, state).await { - Ok(result) => { - let text = serde_json::to_string_pretty(&result).unwrap_or_default(); - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ "content": [{ "type": "text", "text": text }] })), - error: None, - } - } - Err(e) => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { code: -32000, message: e }), - }, - } - } - _ => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { - code: -32601, - message: format!("Method not found: {}", request.method), - }), - }, - } -} -``` - -Note: the `initialize` capabilities object already advertises `resources` here so Task 6 doesn't need to re-edit it. - -- [ ] **Step 2: Update `transport.rs` to import from `handler.rs`** - -Replace lines 24-51 and the entire `handle_jsonrpc` function (lines 136-233) in `src/mcp/transport.rs` with: - -```rust -use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; -``` - -at the top, and update the `message_handler` call site (line 125) to use the imported `handle_jsonrpc`. The local `JsonRpcResponse` and `JsonRpcError` types are no longer needed in `transport.rs` — delete them. - -- [ ] **Step 3: Wire the new module into `src/mcp/mod.rs`** - -Edit `src/mcp/mod.rs` to add the new modules (some don't exist yet — comment them out until the corresponding task creates the file, or add them all and let `cargo check` fail until the files land): - -```rust -//! Model Context Protocol (MCP) integration for Operator. - -pub mod client_configs; -pub mod descriptor; -pub mod handler; -pub mod resources; -pub mod stdio; -pub mod tickets; -pub mod tools; -pub mod transport; -``` - -- [ ] **Step 4: Move the existing handler tests to `handler.rs`** - -The six tests in `src/mcp/transport.rs:236-371` (`test_handle_initialize`, `test_handle_tools_list`, `test_handle_tools_call_health`, `test_handle_tools_call_unknown`, `test_handle_unknown_method`, `test_handle_notifications_initialized`) all test `handle_jsonrpc` directly. Move them verbatim to a `#[cfg(test)] mod tests { ... }` block in `src/mcp/handler.rs`. Update `test_handle_initialize` to also assert the `resources` capability is present. - -- [ ] **Step 5: Verify** - -Run: `cargo test mcp::handler` -Expected: All six tests PASS (with the updated capabilities assertion). - -Run: `cargo test mcp::transport` -Expected: Compiles, no tests left in transport.rs. - -Run: `cargo clippy -- -D warnings` -Expected: No warnings. - -- [ ] **Step 6: Stop for user commit review** - -This task is structurally complete (refactor only, plus the additive resources capability). Surface the diff to the user. - ---- - -### Task 2: Add stdio transport - -**Files:** -- Create: `src/mcp/stdio.rs` -- Test: inline `#[cfg(test)]` block - -- [ ] **Step 1: Write a failing test for one round-trip** - -In `src/mcp/stdio.rs`, write the function shell and a test that pipes a JSON-RPC request through it. The test uses a `Vec` for both input and output. Tests use `tempfile::TempDir` because `ApiState::new` initializes templates on disk. - -```rust -//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout. -//! -//! Each line on stdin is one JSON-RPC request. Each response is one JSON -//! object written to stdout terminated by `\n`. Logs and diagnostics go to -//! stderr (via `tracing`). This is the transport MCP clients use when they -//! spawn `operator mcp` as a subprocess. - -use std::io; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - -use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; -use crate::rest::state::ApiState; - -/// Run the stdio MCP loop until stdin closes. -/// -/// `reader`/`writer` are generic for testability; production callers pass -/// `tokio::io::stdin()` and `tokio::io::stdout()`. -pub async fn run(state: ApiState, reader: R, mut writer: W) -> io::Result<()> -where - R: tokio::io::AsyncRead + Unpin, - W: tokio::io::AsyncWrite + Unpin, -{ - let mut lines = BufReader::new(reader).lines(); - while let Some(line) = lines.next_line().await? { - if line.trim().is_empty() { - continue; - } - let request: JsonRpcRequest = match serde_json::from_str(&line) { - Ok(r) => r, - Err(e) => { - tracing::warn!(error = %e, line = %line, "Malformed JSON-RPC request"); - continue; - } - }; - let response = handle_jsonrpc(&request, &state).await; - let json = serde_json::to_string(&response) - .unwrap_or_else(|_| r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"#.to_string()); - writer.write_all(json.as_bytes()).await?; - writer.write_all(b"\n").await?; - writer.flush().await?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - - fn test_state() -> ApiState { - let temp = tempfile::TempDir::new().unwrap(); - // ApiState::new writes default templates into tickets_path; tempdir handles cleanup. - ApiState::new(Config::default(), temp.path().to_path_buf()) - } - - #[tokio::test] - async fn test_stdio_roundtrip_initialize() { - let state = test_state(); - let input = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} -"#; - let mut output: Vec = Vec::new(); - run(state, &input[..], &mut output).await.unwrap(); - - let response_str = std::str::from_utf8(&output).unwrap(); - let response: serde_json::Value = serde_json::from_str(response_str.trim()).unwrap(); - assert_eq!(response["jsonrpc"], "2.0"); - assert_eq!(response["id"], 1); - assert_eq!(response["result"]["serverInfo"]["name"], "operator"); - } - - #[tokio::test] - async fn test_stdio_ignores_blank_lines() { - let state = test_state(); - let input = b"\n\n"; - let mut output: Vec = Vec::new(); - run(state, &input[..], &mut output).await.unwrap(); - assert!(output.is_empty()); - } - - #[tokio::test] - async fn test_stdio_malformed_line_is_skipped() { - let state = test_state(); - let input = b"not json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; - let mut output: Vec = Vec::new(); - run(state, &input[..], &mut output).await.unwrap(); - let response_str = std::str::from_utf8(&output).unwrap(); - // Only one response should be present (the valid one) - assert_eq!(response_str.matches('\n').count(), 1); - } -} -``` - -Confirm `tempfile` is already a dev-dependency (it's used elsewhere in the project). If not, add `tempfile = "3"` under `[dev-dependencies]`. - -- [ ] **Step 2: Run the tests** - -Run: `cargo test mcp::stdio` -Expected: 3 tests PASS. - -- [ ] **Step 3: Stop for commit review** - ---- - -### Task 3: Add `operator mcp` CLI subcommand - -**Files:** -- Modify: `src/main.rs` (add variant, match arm, async fn) - -- [ ] **Step 1: Add the `Mcp` variant to the `Commands` enum** - -Edit `src/main.rs`. Insert after the `Api { port: Option }` variant (around line 230): - -```rust - /// Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, JetBrains, etc.). - /// - /// Reads line-delimited JSON-RPC from stdin and writes responses to stdout. - /// Log output goes to stderr. Intended to be spawned by an MCP-capable client. - Mcp, -``` - -- [ ] **Step 2: Add the match arm** - -In the main `match cli.command` block (around `src/main.rs:281`), add a new arm before `Some(Commands::Setup { ... })`: - -```rust - Some(Commands::Mcp) => { - cmd_mcp(&config).await?; - } -``` - -- [ ] **Step 3: Implement `cmd_mcp`** - -Add to the bottom of `src/main.rs`, alongside `cmd_api`: - -```rust -async fn cmd_mcp(config: &Config) -> Result<()> { - use crate::rest::state::ApiState; - let state = ApiState::new(config.clone(), config.tickets_path()); - tracing::info!("Starting MCP stdio server"); - crate::mcp::stdio::run(state, tokio::io::stdin(), tokio::io::stdout()).await?; - tracing::info!("MCP stdio server stopped (stdin closed)"); - Ok(()) -} -``` - -- [ ] **Step 4: Verify it runs and responds** - -Run: `cargo build --release` -Run interactively in a shell: -``` -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | ./target/release/operator mcp -``` -Expected: One line of JSON output containing `"serverInfo":{"name":"operator"`. Process exits cleanly after stdin closes. - -- [ ] **Step 5: Stop for commit review** - ---- - -### Task 4: Add ticket-queue MCP read tool (`operator_list_tickets`) - -**Files:** -- Create: `src/mcp/tickets.rs` -- Modify: `src/mcp/tools.rs:23-99` (definitions), `:101-150` (dispatch), `:162` (count assertion) - -- [ ] **Step 1: Write a failing test for `operator_list_tickets`** - -In `src/mcp/tickets.rs`: - -```rust -//! Ticket-queue MCP tools. -//! -//! Reads/writes via `crate::queue::Queue` which uses blocking `std::fs`, -//! so all calls are wrapped in `tokio::task::spawn_blocking`. - -use serde_json::{json, Value}; - -use crate::queue::ticket::Ticket; -use crate::queue::Queue; -use crate::rest::state::ApiState; - -fn ticket_to_json(t: &Ticket) -> Value { - json!({ - "id": t.id, - "filename": t.filename, - "project": t.project, - "ticket_type": t.ticket_type, - "summary": t.summary, - "priority": t.priority, - "status": t.status, - "branch": t.branch, - "external_id": t.external_id, - "external_url": t.external_url, - "external_provider": t.external_provider, - }) -} - -pub async fn list_tickets(args: Value, state: &ApiState) -> Result { - let status = args.get("status").and_then(|v| v.as_str()).unwrap_or("queue").to_string(); - let config = (*state.config).clone(); - let tickets = tokio::task::spawn_blocking(move || -> Result, String> { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - match status.as_str() { - "queue" => queue.list_queue().map_err(|e| e.to_string()), - "in-progress" => queue.list_in_progress().map_err(|e| e.to_string()), - "completed" => queue.list_completed().map_err(|e| e.to_string()), - other => Err(format!("Unknown ticket status: {other}")), - } - }) - .await - .map_err(|e| e.to_string())??; - - let json_tickets: Vec = tickets.iter().map(ticket_to_json).collect(); - Ok(json!({ "tickets": json_tickets, "count": json_tickets.len() })) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - - fn test_state() -> ApiState { - let temp = tempfile::TempDir::new().unwrap(); - // Leak the tempdir so it survives the test; in real test isolation use a guard. - let path = temp.into_path(); - ApiState::new(Config::default(), path) - } - - #[tokio::test] - async fn test_list_tickets_empty_queue() { - let state = test_state(); - let result = list_tickets(json!({}), &state).await.unwrap(); - assert_eq!(result["count"], 0); - } - - #[tokio::test] - async fn test_list_tickets_unknown_status_errors() { - let state = test_state(); - let err = list_tickets(json!({ "status": "bogus" }), &state).await.unwrap_err(); - assert!(err.contains("Unknown ticket status")); - } -} -``` - -Verify the actual `Queue::new` signature first (Pre-Flight #1). If it takes a different argument (e.g. `&Config` vs. owned `Config`), adjust the closure capture. The `(*state.config).clone()` pattern handles the `Arc` deref. - -Run: `cargo test mcp::tickets::tests::test_list_tickets_empty_queue` -Expected: PASS. - -- [ ] **Step 2: Register the tool in `tools.rs`** - -In `src/mcp/tools.rs:23-99`, append to the `vec!` in `all_tool_definitions`: - -```rust - McpToolDefinition { - name: "operator_list_tickets".to_string(), - description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "status": { "type": "string", "enum": ["queue", "in-progress", "completed"], "default": "queue" } - }, - "required": [] - }), - }, -``` - -In `execute_tool` (around `src/mcp/tools.rs:103`), add the dispatch arm before the catch-all `_ =>`: - -```rust - "operator_list_tickets" => crate::mcp::tickets::list_tickets(args, state).await, -``` - -- [ ] **Step 3: Update the tool-count assertions** - -In `src/mcp/handler.rs::tests::test_handle_tools_list` (moved in Task 1, Step 4) and `src/mcp/tools.rs:162`'s count assertion, change the expected count from `7` to `8`. - -- [ ] **Step 4: Run everything** - -Run: `cargo test mcp::` -Expected: All MCP tests PASS. - -- [ ] **Step 5: Stop for commit review** - ---- - -### Task 5: Add ticket-queue MCP write tools (claim, complete, return-to-queue) - -**Files:** -- Modify: `src/mcp/tickets.rs` (three new fns + tests) -- Modify: `src/mcp/tools.rs` (three definitions, three dispatch arms, count assertion → 11) - -All three write tools follow the same pattern: look up the ticket by `id` in the appropriate source list, call the corresponding `Queue` method on it, return the new path. They share a permission gate on `config.mcp.expose_ticket_write_tools` (added in Task 7). - -- [ ] **Step 1: Add the shared lookup helper in `tickets.rs`** - -```rust -async fn find_ticket(state: &ApiState, id: &str, in_status: &str) -> Result { - let id = id.to_string(); - let in_status = in_status.to_string(); - let config = (*state.config).clone(); - tokio::task::spawn_blocking(move || -> Result { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - let list = match in_status.as_str() { - "queue" => queue.list_queue(), - "in-progress" => queue.list_in_progress(), - "completed" => queue.list_completed(), - other => return Err(format!("Unknown status: {other}")), - } - .map_err(|e| e.to_string())?; - list.into_iter() - .find(|t| t.id == id) - .ok_or_else(|| format!("Ticket {id} not found in {in_status}")) - }) - .await - .map_err(|e| e.to_string())? -} -``` - -- [ ] **Step 2: Implement `claim_ticket`** - -```rust -pub async fn claim_ticket(args: Value, state: &ApiState) -> Result { - let id = args.get("id").and_then(|v| v.as_str()).ok_or("Missing required arg: id")?; - let ticket = find_ticket(state, id, "queue").await?; - let config = (*state.config).clone(); - let id_str = id.to_string(); - tokio::task::spawn_blocking(move || -> Result<(), String> { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - queue.claim_ticket(&ticket).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - Ok(json!({ "id": id_str, "moved_to": "in-progress" })) -} -``` - -Add test that creates a temp tickets dir, writes a fake ticket file into `queue/`, calls `claim_ticket`, then asserts the file exists in `in-progress/` and not in `queue/`. Use a real timestamped filename matching the project's expected pattern (see `src/queue/ticket.rs` for parse rules). - -- [ ] **Step 3: Implement `complete_ticket` and `return_to_queue`** - -Identical shape — source status is `"in-progress"` for both, target differs: - -```rust -pub async fn complete_ticket(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.complete_ticket */ } -pub async fn return_to_queue(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.return_to_queue */ } -``` - -Add a test for each. - -- [ ] **Step 4: Register the three tools in `tools.rs` with the permission gate** - -Append three `McpToolDefinition` entries to `all_tool_definitions`: - -```rust - McpToolDefinition { - name: "operator_claim_ticket".to_string(), - description: "Move a ticket from queue to in-progress. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { "id": { "type": "string", "description": "Ticket id (e.g. FEAT-1234)" } }, - "required": ["id"] - }), - }, - McpToolDefinition { - name: "operator_complete_ticket".to_string(), - description: "Move a ticket from in-progress to completed.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { "id": { "type": "string" } }, - "required": ["id"] - }), - }, - McpToolDefinition { - name: "operator_return_to_queue".to_string(), - description: "Move a ticket from in-progress back to queue (un-claim).".to_string(), - input_schema: json!({ - "type": "object", - "properties": { "id": { "type": "string" } }, - "required": ["id"] - }), - }, -``` - -In `execute_tool`, add three dispatch arms with the shared permission gate. Extract the gate to a helper: - -```rust -fn require_write_tools(state: &ApiState) -> Result<(), String> { - if !state.config.mcp.expose_ticket_write_tools { - Err("Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)".to_string()) - } else { - Ok(()) - } -} -``` - -```rust - "operator_claim_ticket" => { - require_write_tools(state)?; - crate::mcp::tickets::claim_ticket(args, state).await - } - "operator_complete_ticket" => { - require_write_tools(state)?; - crate::mcp::tickets::complete_ticket(args, state).await - } - "operator_return_to_queue" => { - require_write_tools(state)?; - crate::mcp::tickets::return_to_queue(args, state).await - } -``` - -- [ ] **Step 5: Add a gate test** - -In `tickets.rs::tests`, assert that `claim_ticket` returns the gate error when `config.mcp.expose_ticket_write_tools = false`. Construct the state with a config where the flag is false, then call `execute_tool("operator_claim_ticket", ...)` and assert the error string. - -- [ ] **Step 6: Update tool-count assertions to 11** - -(8 existing + 3 new write tools = 11. Task 5.5 will add a 12th, Task 6 doesn't add tools.) - -- [ ] **Step 7: Verify** - -Run: `cargo test mcp::` -Expected: All MCP tests PASS. - -- [ ] **Step 8: Stop for commit review** - ---- - -### Task 5.5: Add `operator_create_ticket` write tool - -**Files:** -- Modify: `src/queue/creator.rs` (add `create_ticket_headless`) -- Modify: `src/mcp/tickets.rs` (add `create_ticket` MCP fn) -- Modify: `src/mcp/tools.rs` (one definition, one dispatch arm, count → 12) - -The existing `TicketCreator::create_ticket_with_values` (lines 33-68) opens `$EDITOR` after writing the file. That's wrong for MCP — there's no terminal. Add a headless variant that returns the path without launching an editor. - -- [ ] **Step 1: Add `create_ticket_headless` to `TicketCreator`** - -In `src/queue/creator.rs`, beside the existing `create_ticket_with_values`, add: - -```rust -/// Create a ticket without opening it in an editor (for MCP / API use). -pub fn create_ticket_headless( - &self, - template_type: TemplateType, - values: &HashMap, -) -> Result { - let now = Utc::now(); - let timestamp = now.format("%Y%m%d-%H%M").to_string(); - let type_str = template_type.as_str(); - let project = values - .get("project") - .filter(|p| !p.is_empty()) - .cloned() - .unwrap_or_else(|| "global".to_string()); - - let filename = format!("{timestamp}-{type_str}-{project}-new-ticket.md"); - let filepath = self.queue_path.join(&filename); - - let template = template_type.template_content(); - let content = render_template(template, values)?; - fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; - fs::write(&filepath, &content).context("Failed to write ticket file")?; - - Ok(filepath) -} -``` - -Refactor `create_ticket_with_values` to call `create_ticket_headless` and then `open_in_editor` (DRY). Run existing tests to confirm no regression. - -- [ ] **Step 2: Add MCP fn `create_ticket` in `tickets.rs`** - -```rust -pub async fn create_ticket(args: Value, state: &ApiState) -> Result { - use crate::queue::creator::TicketCreator; - use crate::templates::TemplateType; - use std::collections::HashMap; - - let template_str = args.get("template").and_then(|v| v.as_str()).ok_or("Missing required arg: template")?; - let template_type = TemplateType::from_str(template_str).map_err(|e| e.to_string())?; - let mut values: HashMap = HashMap::new(); - if let Some(obj) = args.get("values").and_then(|v| v.as_object()) { - for (k, v) in obj { - if let Some(s) = v.as_str() { - values.insert(k.clone(), s.to_string()); - } - } - } - - let config = (*state.config).clone(); - let path = tokio::task::spawn_blocking(move || -> Result { - let creator = TicketCreator::new(&config); - creator.create_ticket_headless(template_type, &values).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - - Ok(json!({ "path": path.to_string_lossy(), "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or("") })) -} -``` - -Verify `TemplateType::from_str` exists. If not, use the project's actual enum-parsing convention. - -- [ ] **Step 3: Register `operator_create_ticket` in `tools.rs`** - -```rust - McpToolDefinition { - name: "operator_create_ticket".to_string(), - description: "Create a new ticket from a template (FEAT, FIX, INV, SPIKE, etc.) and write it to the queue. Returns the filename. Gated by [mcp].expose_ticket_write_tools.".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "template": { "type": "string", "description": "Template type (FEAT, FIX, INV, SPIKE, ...)" }, - "values": { "type": "object", "description": "Handlebars values for the template (project, summary, etc.)" } - }, - "required": ["template"] - }), - }, -``` - -```rust - "operator_create_ticket" => { - require_write_tools(state)?; - crate::mcp::tickets::create_ticket(args, state).await - } -``` - -- [ ] **Step 4: Add test** - -Temp tickets dir, call `create_ticket` with `template = "FEAT", values = { "summary": "test", "project": "demo" }`, assert a `.md` file lands in `queue/` with a name containing `demo`. - -- [ ] **Step 5: Update tool-count assertions to 12** - -- [ ] **Step 6: Verify** - -Run: `cargo test mcp::` -Expected: PASS. - -- [ ] **Step 7: Stop for commit review** - ---- - -### Task 6: MCP resources capability — expose tickets as resources - -**Files:** -- Modify: `src/mcp/handler.rs` (add `resources/list` and `resources/read` handlers; the capability is already advertised after Task 1 Step 1) -- Create: `src/mcp/resources.rs` - -MCP clients can subscribe to resources to read context. Expose each ticket as a resource with URI `operator://tickets/{status}/{id}`. This is the highest-leverage capability for IDEs that want to surface tickets natively. - -- [ ] **Step 1: Add `resources/list` and `resources/read` handlers** - -After the `tools/call` arm in `handle_jsonrpc`: - -```rust - "resources/list" => { - let resources = crate::mcp::resources::list_resources(state).await - .unwrap_or_else(|_| vec![]); - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ "resources": resources })), - error: None, - } - } - "resources/read" => { - let uri = request.params.get("uri").and_then(|v| v.as_str()).unwrap_or(""); - match crate::mcp::resources::read_resource(uri, state).await { - Ok(contents) => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ "contents": [{ "uri": uri, "mimeType": "text/markdown", "text": contents }] })), - error: None, - }, - Err(e) => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { code: -32000, message: e }), - }, - } - } -``` - -- [ ] **Step 2: Implement `src/mcp/resources.rs` via `Queue`** - -```rust -//! MCP resources — exposes tickets as URI-addressable resources. - -use serde_json::{json, Value}; - -use crate::queue::Queue; -use crate::rest::state::ApiState; - -pub async fn list_resources(state: &ApiState) -> Result, String> { - let config = (*state.config).clone(); - tokio::task::spawn_blocking(move || -> Result, String> { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - let mut all = Vec::new(); - for (status, list) in [ - ("queue", queue.list_queue()), - ("in-progress", queue.list_in_progress()), - ("completed", queue.list_completed()), - ] { - for t in list.map_err(|e| e.to_string())? { - all.push(json!({ - "uri": format!("operator://tickets/{status}/{}", t.id), - "name": t.filename, - "mimeType": "text/markdown", - "description": t.summary, - })); - } - } - Ok(all) - }) - .await - .map_err(|e| e.to_string())? -} - -pub async fn read_resource(uri: &str, state: &ApiState) -> Result { - let prefix = "operator://tickets/"; - let rest = uri.strip_prefix(prefix).ok_or_else(|| format!("Unknown URI scheme: {uri}"))?; - let (status, id) = rest.split_once('/').ok_or_else(|| format!("Malformed URI: {uri}"))?; - - let config = (*state.config).clone(); - let status = status.to_string(); - let id = id.to_string(); - tokio::task::spawn_blocking(move || -> Result { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - let list = match status.as_str() { - "queue" => queue.list_queue(), - "in-progress" => queue.list_in_progress(), - "completed" => queue.list_completed(), - other => return Err(format!("Unknown status: {other}")), - } - .map_err(|e| e.to_string())?; - let ticket = list.into_iter().find(|t| t.id == id).ok_or_else(|| format!("Ticket {id} not found"))?; - std::fs::read_to_string(&ticket.filepath).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - - fn test_state() -> ApiState { - let temp = tempfile::TempDir::new().unwrap(); - ApiState::new(Config::default(), temp.into_path()) - } - - #[tokio::test] - async fn test_list_resources_empty() { - let state = test_state(); - let resources = list_resources(&state).await.unwrap(); - assert!(resources.is_empty()); - } - - #[tokio::test] - async fn test_read_resource_unknown_scheme() { - let state = test_state(); - let err = read_resource("file:///tmp/x", &state).await.unwrap_err(); - assert!(err.contains("Unknown URI scheme")); - } - - #[tokio::test] - async fn test_read_resource_malformed() { - let state = test_state(); - let err = read_resource("operator://tickets/queue", &state).await.unwrap_err(); - assert!(err.contains("Malformed URI")); - } -} -``` - -- [ ] **Step 3: Verify** - -Run: `cargo test mcp::` -Expected: All PASS. - -- [ ] **Step 4: Stop for commit review** - ---- - -### Task 6.5: Extend `McpDescriptorResponse` with stdio command (vscode-extension Phase 2 enabler) - -**Files:** -- Modify: `src/mcp/descriptor.rs` - -The existing descriptor at `src/mcp/descriptor.rs:14-30` is consumed by `vscode-extension/src/mcp-connect.ts` to register operator as an SSE MCP server. Extending it with an optional `stdio` field is purely additive (gated by `skip_serializing_if`) and unlocks the Phase 2 work where the extension can choose to spawn `operator mcp` instead of (or alongside) the SSE transport. - -- [ ] **Step 1: Add `StdioCommand` and the optional field** - -Edit `src/mcp/descriptor.rs`: - -```rust -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct StdioCommand { - /// Absolute path to the operator binary (the same binary serving this descriptor) - pub command: String, - /// Args to pass: typically ["mcp"] - pub args: Vec, - /// Working directory the client should set when spawning. Defaults to the - /// operator process's current working directory. - pub cwd: String, -} - -// Add to McpDescriptorResponse: - /// Stdio transport entrypoint. Present when [mcp].stdio_advertised = true. - /// Clients may spawn this as a subprocess instead of using transport_url. - #[serde(skip_serializing_if = "Option::is_none")] - pub stdio: Option, -``` - -- [ ] **Step 2: Inject `State` into the handler and populate `stdio`** - -Change the handler signature from `descriptor(Host(host): Host)` to `descriptor(State(state): State, Host(host): Host)` and populate: - -```rust -pub async fn descriptor( - State(state): State, - Host(host): Host, -) -> Json { - let base = format!("http://{host}"); - - let stdio = if state.config.mcp.stdio_advertised { - let command = std::env::current_exe() - .ok() - .and_then(|p| p.to_str().map(|s| s.to_string())) - .unwrap_or_else(|| "operator".to_string()); - let cwd = std::env::current_dir() - .ok() - .and_then(|p| p.to_str().map(|s| s.to_string())) - .unwrap_or_default(); - Some(StdioCommand { - command, - args: vec!["mcp".to_string()], - cwd, - }) - } else { - None - }; - - Json(McpDescriptorResponse { - server_name: "operator".to_string(), - server_id: "operator-mcp".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - transport_url: format!("{base}/api/v1/mcp/sse"), - label: "Operator MCP Server".to_string(), - openapi_url: Some(format!("{base}/api-docs/openapi.json")), - stdio, - }) -} -``` - -The route registration in `src/rest/mod.rs` already passes `ApiState` (other handlers use it), but verify the descriptor's route line and add the `State` extractor if it currently uses a different shape. - -- [ ] **Step 3: Update existing descriptor tests + add stdio coverage** - -Update both `test_descriptor_response` and `test_descriptor_custom_port` to construct a test `ApiState` and pass it. Add two new tests: -- `test_descriptor_stdio_present_when_advertised` — config with `stdio_advertised = true` → `resp.stdio.is_some()` and `resp.stdio.unwrap().args == vec!["mcp"]`. -- `test_descriptor_stdio_absent_when_disabled` — config with `stdio_advertised = false` → `resp.stdio.is_none()`. - -(Both tests need Task 7's `McpConfig` to exist. Either land Task 7 first, or temporarily inline the field default behind a feature flag. **Preferred order:** Task 7 before Task 6.5 — see ordering note below.) - -- [ ] **Step 4: Regenerate TypeScript bindings** - -Run: `cargo test` — `ts-rs` regenerates `bindings/` (or wherever the project configures `#[ts(export)]` output) including the new `StdioCommand` type. - -- [ ] **Step 5: Verify** - -Run: `cargo test mcp::descriptor` -Expected: PASS, including the two new stdio tests. - -Run: `cargo clippy -- -D warnings` -Expected: clean. - -- [ ] **Step 6: Stop for commit review** - -> **Ordering note:** This task reads `config.mcp.stdio_advertised`, which is defined in Task 7. If you're executing strictly in numeric order, swap: do Task 7 first, then return to Task 6.5. Tasks 1-6 are independent of `McpConfig`. - ---- - -### Task 7: Add `[mcp]` config section - -**Files:** -- Modify: `src/config.rs` (Config struct around lines 28-74; new `McpConfig` struct alongside `RestApiConfig` and `RelayConfig`) - -- [ ] **Step 1: Add the `McpConfig` struct** - -Add in `src/config.rs`, near other sub-structs like `RestApiConfig` (`src/config.rs:263-291`) and `RelayConfig`: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export)] -pub struct McpConfig { - /// Whether to mount MCP HTTP/SSE endpoints on the REST API server. - /// Toggling requires an API restart (no hot-swap of the axum router). - #[serde(default = "default_true")] - pub http_enabled: bool, - /// Whether the descriptor endpoint advertises the `operator mcp` stdio - /// command. Set to false on multi-tenant/remote deployments where clients - /// shouldn't spawn local subprocesses. - #[serde(default = "default_true")] - pub stdio_advertised: bool, - /// Whether to expose ticket-mutating tools (claim, complete, return-to-queue, - /// create) over MCP. Defaults to `false` because any MCP client can call them. - #[serde(default)] - pub expose_ticket_write_tools: bool, -} - -impl Default for McpConfig { - fn default() -> Self { - Self { - http_enabled: true, - stdio_advertised: true, - expose_ticket_write_tools: false, - } - } -} - -fn default_true() -> bool { true } -``` - -The `JsonSchema + TS` derive pair matches the rest of the codebase (`src/config.rs`'s other structs). The `TS` derive triggers TypeScript binding regeneration consumed by `vscode-extension/scripts/copy-types.js`. - -- [ ] **Step 2: Add the field to `Config`** - -Add to the `Config` struct (alongside `relay: RelayConfig`): - -```rust - #[serde(default)] - pub mcp: McpConfig, -``` - -Update `Config::default()` to include `mcp: McpConfig::default()`. - -- [ ] **Step 3: Wire `http_enabled` into the router build** - -In `src/rest/mod.rs` (around lines 175-181, where MCP routes are currently mounted unconditionally), wrap the MCP route registrations: - -```rust -if state.config.mcp.http_enabled { - router = router - .route("/api/v1/mcp/descriptor", get(descriptor::descriptor)) - .route("/api/v1/mcp/sse", get(transport::sse_handler)) - .route("/api/v1/mcp/message", post(transport::message_handler)); -} -``` - -(Exact shape depends on the existing router-building pattern. Verify by reading `src/rest/mod.rs:175-181`.) - -- [ ] **Step 4: Verify the gate from Task 5 now compiles** - -Run: `cargo test mcp::` -Expected: PASS, including the disabled-write-tools gate test from Task 5. - -- [ ] **Step 5: Regen docs and TS bindings** - -``` -cargo run -- docs --only config # regenerates docs/configuration/index.md with [mcp] section -cargo test # ts-rs regenerates bindings -``` - -Then refresh the vscode-extension's copy (Phase 2 will need this): - -``` -cd vscode-extension && npm run copy-types -``` - -If `copy-types` isn't yet a script in `package.json`, fall back to the manual path the project uses (`scripts/copy-types.js`). - -Verify the generated `docs/configuration/index.md` now describes `[mcp]`. - -- [ ] **Step 6: Stop for commit review** - ---- - -### Task 8: Client config snippet generator - -**Files:** -- Create: `src/mcp/client_configs.rs` - -Operator users adopting MCP need to be told "paste this into your client config." Generate snippets at runtime so they always carry the correct absolute path to the operator binary and the project's working directory. - -- [ ] **Step 0: Verify modern client config shapes** - -Before writing snippets, confirm the current expected shape for each: -- Run: `head -100 vscode-extension/src/mcp-connect.ts` — verify what the extension currently writes to workspace `mcp.servers` (or the modern `.vscode/mcp.json` `servers` shape). The snippet for VS Code must match what the extension expects. -- Cursor: `~/.cursor/mcp.json` uses the `mcpServers` shape (same as Claude Code's `claude.json`). -- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` uses `mcpServers`. -- Zed: `settings.json` under `context_servers`. - -If any shape has drifted, update the snippet in Step 1 before testing. - -- [ ] **Step 1: Implement `client_configs.rs`** - -```rust -//! Generates copy-paste MCP client configuration snippets pointing at this operator binary. - -use serde_json::{json, Value}; -use std::path::{Path, PathBuf}; - -pub fn current_exe() -> PathBuf { - std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) -} - -fn mcp_servers_shape(cwd: &Path) -> Value { - // Used by Claude Code (~/.claude.json), Claude Desktop, and Cursor (~/.cursor/mcp.json). - json!({ - "mcpServers": { - "operator": { - "command": current_exe().to_string_lossy(), - "args": ["mcp"], - "cwd": cwd.to_string_lossy(), - } - } - }) -} - -pub fn claude_code_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } -pub fn claude_desktop_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } - -/// Cursor's `~/.cursor/mcp.json` uses the same `mcpServers` shape as Claude Code. -pub fn cursor_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } - -/// VS Code (1.94+) per-workspace `.vscode/mcp.json` uses a `servers` block with explicit `type`. -pub fn vscode_snippet(cwd: &Path) -> Value { - json!({ - "servers": { - "operator": { - "type": "stdio", - "command": current_exe().to_string_lossy(), - "args": ["mcp"], - "cwd": cwd.to_string_lossy(), - } - } - }) -} - -/// Zed config under `context_servers` in user settings. -pub fn zed_snippet(cwd: &Path) -> Value { - json!({ - "context_servers": { - "operator": { - "command": { "path": current_exe().to_string_lossy(), "args": ["mcp"], "env": {} }, - "settings": { "cwd": cwd.to_string_lossy() } - } - } - }) -} - -/// Dispatch by client name. Returns `None` for unknown clients. -pub fn snippet_for(client: &str, cwd: &Path) -> Option { - match client { - "claude-code" => Some(claude_code_snippet(cwd)), - "claude-desktop" => Some(claude_desktop_snippet(cwd)), - "cursor" => Some(cursor_snippet(cwd)), - "vscode" => Some(vscode_snippet(cwd)), - "zed" => Some(zed_snippet(cwd)), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_claude_code_snippet_shape() { - let cfg = claude_code_snippet(&PathBuf::from("/work")); - assert_eq!(cfg["mcpServers"]["operator"]["args"][0], "mcp"); - assert_eq!(cfg["mcpServers"]["operator"]["cwd"], "/work"); - } - - #[test] - fn test_cursor_snippet_matches_claude_code() { - let cursor = cursor_snippet(&PathBuf::from("/work")); - let claude = claude_code_snippet(&PathBuf::from("/work")); - assert_eq!(cursor, claude); - } - - #[test] - fn test_vscode_snippet_uses_servers_with_type() { - let cfg = vscode_snippet(&PathBuf::from("/work")); - assert_eq!(cfg["servers"]["operator"]["type"], "stdio"); - assert_eq!(cfg["servers"]["operator"]["args"][0], "mcp"); - } - - #[test] - fn test_zed_snippet_uses_context_servers() { - let cfg = zed_snippet(&PathBuf::from("/work")); - assert!(cfg["context_servers"]["operator"]["command"]["path"].is_string()); - } - - #[test] - fn test_snippet_for_unknown_client_is_none() { - assert!(snippet_for("notepad++", &PathBuf::from("/w")).is_none()); - } -} -``` - -- [ ] **Step 2: Run tests** - -Run: `cargo test mcp::client_configs` -Expected: PASS. - -- [ ] **Step 3: Stop for commit review** - ---- - -### Task 9: Integrate MCP into `StatusSnapshot` and `ConnectionsSection` - -**Files:** -- Modify: `src/ui/status_panel.rs:401-431` (StatusSnapshot fields), `:143-174` (StatusAction) -- Modify: `src/ui/sections/connections_section.rs:70-138` (children) -- Modify: `src/ui/dashboard.rs:203-317` (snapshot construction) -- Modify: `src/app/status_actions.rs` (action handlers) - -Mirror the existing `Operator API` row pattern exactly. No new clipboard dependency — `WriteAndOpenMcpClientConfig` writes the snippet to a file and dispatches the existing `EditFile(path)` action. - -- [ ] **Step 1: Add `McpHttpStatus` enum** - -In `src/rest/server.rs` (alongside `RestApiStatus`) or a new `src/mcp/status.rs` if you prefer to keep MCP types together: - -```rust -#[derive(Debug, Clone, PartialEq)] -pub enum McpHttpStatus { - /// MCP HTTP routes mounted on the REST API server on the given port. - Mounted { port: u16 }, - /// MCP HTTP routes disabled via [mcp].http_enabled = false. - NotMounted, -} -``` - -- [ ] **Step 2: Add MCP fields to `StatusSnapshot`** - -In `src/ui/status_panel.rs` around line 425, before the closing `}`: - -```rust - /// MCP HTTP transport status (mounted on the API server, or disabled by config). - pub mcp_http_status: McpHttpStatus, - /// Whether the descriptor advertises the stdio entrypoint. - pub mcp_stdio_advertised: bool, - /// Currently active MCP SSE sessions on the HTTP transport. - pub mcp_active_sessions: usize, -``` - -- [ ] **Step 3: Add new `StatusAction` variants** - -In `src/ui/status_panel.rs:143-174`, add before `None`: - -```rust - /// Toggle [mcp].http_enabled (requires API restart to take effect). - ToggleMcpHttp, - /// Generate a client config snippet, write it to /operator/mcp/.json, - /// and open it in $EDITOR. `client` is one of: "claude-code", "claude-desktop", "cursor", "vscode", "zed". - WriteAndOpenMcpClientConfig { client: String }, - /// Open the operator MCP docs page in the default browser. - OpenMcpDocs, -``` - -- [ ] **Step 4: Add the "MCP" row in `ConnectionsSection::children`** - -In `src/ui/sections/connections_section.rs` after the "Operator API" row push (around line 117), insert: - -```rust - rows.push(TreeRow { - section_id: SectionId::Connections, - depth: 1, - label: "MCP".into(), - description: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised, snapshot.mcp_active_sessions) { - (McpHttpStatus::Mounted { port }, true, n) if n > 0 => format!(":{port} + stdio · {n} sessions"), - (McpHttpStatus::Mounted { port }, true, _) => format!(":{port} + stdio"), - (McpHttpStatus::Mounted { port }, false, _) => format!(":{port} (HTTP only)"), - (McpHttpStatus::NotMounted, true, _) => "stdio only".into(), - (McpHttpStatus::NotMounted, false, _) => "Disabled".into(), - }, - icon: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised) { - (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Plug, - _ => StatusIcon::Cross, - }, - is_header: false, - actions: ActionSet { - primary: StatusAction::WriteAndOpenMcpClientConfig { client: "claude-code".to_string() }, - back: StatusAction::None, - special: StatusAction::ToggleMcpHttp, - special_meta: Some(ActionMeta { title: "HTTP", tooltip: "Toggle the MCP HTTP transport (restart required)" }), - refresh: StatusAction::OpenMcpDocs, - refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open MCP setup docs in browser" }), - }, - health: SectionHealth::Gray, - }); -``` - -`McpHttpStatus` and `StatusIcon::Plug` need imports added at the top of the file. - -- [ ] **Step 5: Populate the snapshot in `dashboard.rs`** - -In `src/ui/dashboard.rs:203-317`'s `build_status_snapshot`, populate the three new fields: - -```rust - mcp_http_status: if self.config.mcp.http_enabled { - match &self.rest_api_status { - RestApiStatus::Running { port } => McpHttpStatus::Mounted { port: *port }, - _ => McpHttpStatus::NotMounted, - } - } else { - McpHttpStatus::NotMounted - }, - mcp_stdio_advertised: self.config.mcp.stdio_advertised, - mcp_active_sessions: self.api_state.as_ref() - .map(|s| s.mcp_sessions.try_lock().map(|m| m.len()).unwrap_or(0)) - .unwrap_or(0), -``` - -Verify the `Dashboard` struct's field that holds `ApiState` (might not be `api_state`; grep for `ApiState` in `src/ui/dashboard.rs`). If the dashboard doesn't currently hold an `ApiState` reference, route the session count through the `RestApiServer` lifecycle handle (which already holds the state) or default to `0` until the API is running. - -- [ ] **Step 6: Wire the action handlers in `src/app/status_actions.rs`** - -Add three new match arms in the existing dispatcher (around `src/app/status_actions.rs:66`): - -```rust -StatusAction::ToggleMcpHttp => { - // Flip config.mcp.http_enabled in the running Config and surface a notice. - // No hot-swap: tell the user to restart the API. - self.config.mcp.http_enabled = !self.config.mcp.http_enabled; - self.dashboard.set_status(if self.config.mcp.http_enabled { - "MCP HTTP enabled — restart the API to mount routes" - } else { - "MCP HTTP disabled — restart the API to unmount routes" - }); -} - -StatusAction::WriteAndOpenMcpClientConfig { client } => { - use crate::mcp::client_configs; - let cwd = std::env::current_dir().unwrap_or_default(); - let Some(snippet) = client_configs::snippet_for(&client, &cwd) else { - self.dashboard.set_status(&format!("Unknown MCP client: {client}")); - return; - }; - let dir = self.config.tickets_path().join("operator/mcp"); - if let Err(e) = std::fs::create_dir_all(&dir) { - self.dashboard.set_status(&format!("Failed to create {}: {e}", dir.display())); - return; - } - let path = dir.join(format!("{client}.json")); - let body = serde_json::to_string_pretty(&snippet).unwrap_or_default(); - if let Err(e) = std::fs::write(&path, body) { - self.dashboard.set_status(&format!("Failed to write {}: {e}", path.display())); - return; - } - // Reuse the existing EditFile dispatcher. - self.dispatch(StatusAction::EditFile(path.to_string_lossy().into_owned())); -} - -StatusAction::OpenMcpDocs => { - // Use the existing open_in_browser helper. - if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") { - self.dashboard.set_status(&format!("Failed to open docs: {e}")); - } -} -``` - -Confirm the docs URL before merging (TODO marker; pick the actual operator docs URL). - -- [ ] **Step 7: Update all test snapshots** - -Search test files for `StatusSnapshot {` (`rg "StatusSnapshot \{" --type rust`) and add the three new fields with defaults: - -```rust - mcp_http_status: McpHttpStatus::Mounted { port: 7008 }, - mcp_stdio_advertised: true, - mcp_active_sessions: 0, -``` - -- [ ] **Step 8: Add a test for the new MCP row** - -Append to `src/ui/sections/connections_section.rs::tests`: - -```rust - #[test] - fn test_connections_mcp_row_present() { - let section = ConnectionsSection; - let snap = base_snapshot(); - let children = section.children(&snap); - let mcp_row = children.iter().find(|r| r.label == "MCP"); - assert!(mcp_row.is_some(), "MCP row should always be present"); - } - - #[test] - fn test_connections_mcp_row_description_disabled() { - let section = ConnectionsSection; - let mut snap = base_snapshot(); - snap.mcp_http_status = McpHttpStatus::NotMounted; - snap.mcp_stdio_advertised = false; - let children = section.children(&snap); - let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); - assert_eq!(mcp_row.description, "Disabled"); - } -``` - -- [ ] **Step 9: Verify** - -Run: `cargo fmt && cargo clippy -- -D warnings && cargo test` -Expected: All PASS, no warnings. - -- [ ] **Step 10: Stop for commit review** - ---- - -### Task 10: End-to-end integration test - -**Files:** -- Create: `tests/mcp_stdio_integration.rs` - -- [ ] **Step 1: Write the test** - -```rust -//! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip -//! a real JSON-RPC handshake. - -use std::process::Stdio; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::Command; - -#[tokio::test] -async fn test_operator_mcp_stdio_initialize_and_list_tools() { - let exe = env!("CARGO_BIN_EXE_operator"); - let mut child = Command::new(exe) - .arg("mcp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn operator mcp"); - - let mut stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - let mut reader = BufReader::new(stdout).lines(); - - stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n").await.unwrap(); - stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n").await.unwrap(); - stdin.flush().await.unwrap(); - - let line1 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap(); - let resp1: serde_json::Value = serde_json::from_str(&line1).unwrap(); - assert_eq!(resp1["id"], 1); - assert_eq!(resp1["result"]["serverInfo"]["name"], "operator"); - - let line2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap(); - let resp2: serde_json::Value = serde_json::from_str(&line2).unwrap(); - assert_eq!(resp2["id"], 2); - // 8 read + 4 write tools = 12 (or whichever count Task 5.5 left it at) - assert!(resp2["result"]["tools"].as_array().unwrap().len() >= 8); - - drop(stdin); - let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; -} -``` - -- [ ] **Step 2: Run** - -Run: `cargo test --test mcp_stdio_integration -- --nocapture` -Expected: PASS (a few seconds to build the binary the first time). - -- [ ] **Step 3: Final verification** - -``` -cargo fmt -cargo clippy -- -D warnings -cargo test -cargo run -- docs --only config # confirm [mcp] appears in regenerated docs -cd vscode-extension && npm run copy-types && cd .. # confirm new TS types regenerated -``` - -Expected: green across the board, generated docs and TS bindings updated. - -- [ ] **Step 4: Stop for user to commit** - ---- - -## Integration Handoff (sets up Phase 2 + Phase 3) - -This plan does **not** modify vscode-extension or write a Cursor integration. It sets the stage so the next two plans can be written and executed independently. - -**Phase 2 — vscode-extension refinement (separate follow-up plan):** -- The existing `vscode-extension/src/mcp-connect.ts:34-97` already consumes `/api/v1/mcp/descriptor`. After Task 6.5, the response carries an optional `stdio: StdioCommand` field. -- Phase 2 will modify `mcp-connect.ts` to: (a) detect the new field, (b) offer a workspace setting `operator.mcpTransport: "sse" | "stdio" | "auto"`, (c) when stdio is chosen/auto-selected, register operator via VS Code's modern MCP API as a stdio server using `descriptor.stdio.command` + `descriptor.stdio.args` + `descriptor.stdio.cwd`. Fallback remains SSE. -- The TypeScript binding for `StdioCommand` will be available via the existing `scripts/copy-types.js` flow once Task 7 Step 5 runs. - -**Phase 3 — Cursor integration (separate follow-up plan):** -- Cursor has no extension; it consumes `~/.cursor/mcp.json` directly. Task 8's `cursor_snippet()` already produces the right shape. -- Phase 3 will add either: (a) a `operator mcp install --client cursor` CLI subcommand that writes the snippet to `~/.cursor/mcp.json` (merging with existing servers), or (b) a docs page rendering the snippet with the current binary path, or (c) both. The dashboard's `WriteAndOpenMcpClientConfig { client: "cursor" }` action (Task 9) already covers the local-workspace path. -- Phase 3 should also document the JetBrains/Claude Desktop install flow using the same `snippet_for(client, cwd)` dispatch since those clients use the same `mcpServers` shape. - ---- - -## Self-Review - -**Spec coverage:** -- Stdio transport — Tasks 2, 3 -- Expanded tool surface (read + 4 write tools) — Tasks 4, 5, 5.5 -- Resources capability — Task 6 -- Descriptor stdio handoff for vscode-extension — Task 6.5 -- Config section — Task 7 -- Client config snippets — Task 8 -- Status integration (toggle + write-and-open snippet + docs link) — Task 9 -- End-to-end test — Task 10 -- Phase 2/3 handoff — Integration Handoff section - -**Open assumptions verified in Pre-Flight:** -1. ✓ `Queue` API at `src/queue/mod.rs:134-158` (sync methods). -2. ✓ `McpDescriptorResponse` exists at `src/mcp/descriptor.rs:14`. -3. ✓ `mcp_sessions: Arc>` — `.await` correct. -4. ⚠ Docs URL for `OpenMcpDocs` — placeholder used (`https://operator.untra.io/mcp/`); confirm before merging. -5. ⚠ VS Code MCP shape — verify against current extension behaviour in Task 8 Step 0. -6. ⚠ Dashboard's holding of `ApiState` — Task 9 Step 5 grep verifies; fallback to 0 sessions if not available. - -**Tradeoffs locked in:** -- HTTP and stdio MCP share the handler core. HTTP behavior is unchanged unless `config.mcp.http_enabled = false` (then routes are not mounted at startup; toggle requires restart). -- Ticket write tools are off by default. Users opt in via `[mcp].expose_ticket_write_tools = true`. -- The descriptor extension is additive (`Option`, `skip_serializing_if`) so existing vscode-extension code keeps working until Phase 2 chooses to use the new field. -- Snippet delivery is "write to file + open in editor," reusing `EditFile` — no clipboard dependency. -- Stdio resource subscription is `listChanged: false` — clients re-list rather than subscribe. Simpler; revisit if a real client demands push. diff --git a/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md deleted file mode 100644 index 235944ab..00000000 --- a/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md +++ /dev/null @@ -1,426 +0,0 @@ -# Operator Licensing Platform — Template Bootstrap Plan - -## Context - -Operator is a Rust TUI that the author (Sam / `untra`) wants to license and sell. Beyond Operator itself, the author intends to build other billable software products under the `untra` umbrella. The licensing/billing infrastructure must therefore be **reusable across future untra products**, not bespoke to Operator. - -Two concerns were brainstormed in this session: - -- **X. Operator's licensing & billing system** — sub-projects A (entitlement model) through F (admin tool). Roadmapped here; detailed designs deferred to follow-up sessions. -- **Y. Untra platform template & deployment skeleton** — the reusable cloud + DNS + auth + storefront skeleton that every untra product plugs into. **This is the focus of this session.** Y was promoted from a child of X to a top-level peer once the templating goal was made explicit. - -Goal of this session: produce a plan for creating six template repositories at `../templates/` — one per archetype. Each template gets a `README.md` and a `HANDOFF.md` and nothing else. A top-level `../templates/README.md` indexes the six archetypes for anyone landing in the directory. The templates are then fleshed out in independent follow-up Claude sessions, one per template, using the handoff briefs. - -Cost-deferral is a hard requirement: **no paid commitments should be necessary to complete Phase 0–1**. The first paid commitment is the root-domain registration (~$12/yr), happening at Phase 2. - ---- - -## Reading of the request - -This plan rests on one interpretation of the user's template list (`iac api app auth admin license`). Surfacing it loudly so it can be corrected at review time: - -- **`api`** — generic product-side backend. Cloud Run service in the public per-project monorepo. Exposes (1) an **unauthenticated** `/version` endpoint and (2) **authenticated** endpoints (verified against the entitlement JWT signed by `license`) that report user details and serve product business logic. Receives the **LemonSqueezy purchase webhook** and, on a successful sale, calls `license` over a signed internal request to mint a license record. -- **`license`** — privileged entitlement microservice. Lives in the **private** sister repo (`operator-private` for Operator; `-private` for other untra products). Holds the Ed25519 signing key in Secret Manager. Only `auth` and `api` may call it. Verifies a posted license key, returns/issues a signed entitlement JWT (booleans + integer limits, ~14-day TTL). Maintains the revocation list. -- **`auth`** — identity orchestrator at `auth.`. Wraps Firebase Auth (sign-in UI). After the user signs in, `auth` accepts a license key, calls `license` to verify it, and packages the resulting entitlements into a JWT (signed by `license`) returned to the client. Acts as the trust bridge between Firebase identity and license entitlements. - -If this reading is wrong, sections below collapse. Push back at spec review. - ---- - -## Roadmap - -### X. Operator licensing sub-projects (decomposition only) - -| # | Component | Lives in | Status | -|---|---|---|---| -| A | Entitlement model & license-key format | shared schema crate / proto | **Deferred** — detailed spec in a follow-up session. Eight decisions already locked (see "Entitlement model — locked decisions" below). | -| B | License verification service | `operator-private`, generated from `templates/license` | Built via Y. Detailed implementation in a follow-up session. | -| C | Operator client integration | this repo (`operator/`) | Future ticket. Reads entitlement JWT, exposes flags via OpenFeature provider. | -| D | Storefront + billing | LemonSqueezy hosted; no template. Adapters in `templates/app` + `templates/api`. | Built via Y. | -| E | Customer account site | `templates/app` instance | Built via Y. | -| F | Admin tool | `templates/admin` instance | Built via Y. | - -### Y. Untra platform template (this session) - -Six archetype template repos at `../templates/`: - -``` -templates/ -├── iac/ # Terraform/OpenTofu modules: Cloudflare, GCP, Firebase, Neon, IAP, Secret Manager -├── api/ # Generic product backend (Rust + Axum + Cloud Run) -├── app/ # Customer-facing web app (TypeScript SPA, sign-in via Firebase, license mgmt UI) -├── auth/ # Identity orchestrator (auth.): Firebase Auth UI + license-exchange endpoint -├── admin/ # Admin console (IAP-gated): plan editor, license mgmt, revocation -└── license/ # Privileged entitlement service (vendors into *-private repos only) -``` - -### Entitlement model — locked decisions (referenced by `license`) - -These were agreed earlier in the session. They become the "Entitlement Model — frozen" section in `license/README.md`. Detailed token schema/crypto is part of the deferred A-spec. - -1. **Vintage model:** Adobe-style year-versioned major releases (`standard-2025`, `enterprise-2026`). Each vintage is a distinct SKU. -2. **Access duration:** Perpetual one-time buy per vintage. No subscription expiry. -3. **Feature types:** Booleans + integer limits (e.g. `acp_enabled=true`, `max_projects=20`). -4. **Verification model:** Hybrid — short opaque license key + server-issued signed entitlement JWT cached for ~14 days. -5. **License scope:** Single user, soft cap of 3 machines per license. -6. **Account model:** One account owns many licenses over time. -7. **OpenFeature shape:** Custom OpenFeature provider in client (Rust SDK) reads flags from cached entitlement JWT. -8. **Revocation:** Revocation list + TTL-driven propagation. Already-cached tokens expire within ~14 days of revocation. - ---- - -## Service trust model - -``` - Firebase ID token - user ──signin──▶ auth. ──┐ - │ (verify license key + Firebase identity) - ▼ - license. - (Ed25519 sign entitlement JWT) - │ - client ◀───── entitlement JWT ───┘ - │ - ▼ Bearer JWT - api. ◀── verifies JWT against license public key (embedded) - │ - └── LemonSqueezy webhook ──▶ signed internal call ──▶ license (mint license) -``` - -**Trust boundary:** `license`'s signing key never leaves the private GCP project. `auth` and `api` only hold `license`'s public key. Public-monorepo CI cannot touch `license`'s secrets. - ---- - -## Architectural decisions (named, not buried) - -### D1. Two-repo structure per product (public + private) - -Every untra product produces **two** GitHub repos at provisioning time: - -- `/` — public monorepo, vendors `iac` + `api` + `app` + `auth` + `admin`. -- `-private/` — private monorepo, vendors `license` and a separate slice of `iac` (the private-side state, signing-key Secret Manager, separate Neon project). - -The two repos correspond to two separate GCP projects with no cross-project IAM. The only runtime coupling is the signed internal HTTPS call from `api` (public) to `license` (private). - -### D2. LemonSqueezy webhook lands in `api`, not `license` - -Webhooks have public, unauthenticated ingress by design. `api` already has a public surface and a Neon DB for user records — it's the right place to receive them. `api` then makes a **signed internal request** (HMAC over webhook payload + nonce) to `license` to mint the actual license. `license` never receives unauthenticated external traffic; the only external endpoints on `license` are token-signing endpoints called by `auth`. - -### D3. The first untra product (Operator) eats its own dog food - -Operator is both: -- a **consumer** of the platform (it has license keys, calls `auth` to refresh entitlements, gates features via OpenFeature) -- the **bootstrap operator** for future products (per its CLAUDE.md, "self-starting work multiplexor") - -Therefore Operator's licensing integration (sub-project C) and the platform templates (Y) must be designed so Operator can later orchestrate Phase 2 deployments for *new* untra products. This session does not implement that orchestration; it only avoids painting it into a corner. - ---- - -## Cost-deferral phases - -- **Phase 0** *(this session, $0)*: six templates exist at `../templates//`, each `git init`'d, each containing only `README.md` and `HANDOFF.md`. -- **Phase 1** *(follow-up Claude sessions, $0)*: each template gets fleshed out by a fresh Claude session using its HANDOFF.md. Output: working code, Dockerfiles, IaC modules. Still local; no cloud accounts needed. -- **Phase 2** *(first deployment, ~$12/yr)*: register Operator's root domain. Set up Cloudflare zone (free), GCP project (uses $300 trial credit), Firebase Auth (free tier), Neon Postgres (free tier), Google Secret Manager (free tier). Deploy all services to Cloud Run with `min_instances=0`. Cost ceiling: ~$12/yr for the year, possibly $0 within trial credit. -- **Phase 3** *(first sale)*: LemonSqueezy onboarding (~30 min, no business entity required). First transaction triggers the first revenue and the first 5%+50¢ fee. No upfront commitment. - ---- - -## What this session WILL produce (post-ExitPlanMode) - -**Top-level index file:** -1. Write `../templates/README.md` — a one-page index explaining the six archetype repos, the platform stack, and how they fit together. Section outline below. - -**Per archetype** (`iac`, `api`, `app`, `auth`, `admin`, `license`): -1. Create directory `../templates//`. -2. Run `git init` inside it. -3. Write `README.md` per the outline below. -4. Write `HANDOFF.md` per the outline below. -5. **Do not commit.** Per user instruction (memory: `feedback_no_commits.md`), the user handles all commits. - -**Promotion step:** -1. After the templates exist, copy this plan file to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` so it survives outside `~/.claude/plans/`. (User confirmed promotion at plan approval.) - -That is the entirety of the action. **No source files, no Dockerfiles, no Terraform.** Those are Phase 1, in separate sessions. - ---- - -## Top-level `../templates/README.md` outline - -A short index file at the root of the templates directory. Anyone who `cd`s into `../templates/` should understand the platform in under a minute. Sections in order: - -``` -# untra platform templates - -## What this directory is -One paragraph: these are archetype templates for untra's billable-SaaS platform. -Each subdirectory is its own git repo; they get vendored into per-product -monorepos at Phase 2. - -## The six archetypes -A table: archetype name | one-line role | vendors into (public/private). - -## Platform stack (defaults) -The cost-deferral stack table from the master spec, abbreviated: -domain (Cloudflare), compute (Cloud Run min=0), identity (Firebase Auth), -data (Neon Postgres), storefront (LemonSqueezy MoR), CI (GitHub Actions), -IaC (OpenTofu). Cost-at-zero-traffic ceiling: ~$12/yr (domain only). - -## Trust model -The auth → license → JWT → api diagram, in ASCII. - -## How a new product is provisioned -Cross-reference templates/iac/README.md "Quickstart" section for the manual -checklist. - -## Status -"Phase 0: READMEs and handoff briefs only. See each subdirectory's HANDOFF.md -to start implementation in a fresh Claude session." - -## Master spec -Pointer to operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md -``` - ---- - -## README.md outline (per template) - -A common shape, plus per-archetype detail. Each `README.md` should contain these sections in this order: - -``` -# — untra platform template - -## Purpose -One paragraph describing what an instance of this template does in a deployed untra product. - -## Role in the platform -A short list of which other archetypes this template talks to and how. - -## Tech stack -Language, framework, deployment target. From the locked Y stack. - -## Repository layout -The directory shape this template prescribes. - -## Quickstart (instantiation) -How a Phase 2 operator clones this template into a new product monorepo. - -## Configuration -Parameters that must be set per product (e.g. `{root_domain}`, `{gcp_project_id}`). - -## Cost profile -Free-tier story; what triggers paid usage. - -## Status -"Phase 0: README and handoff brief only. See HANDOFF.md to start implementation." - -## Links -Pointer to master spec, the platform stack table, related archetypes. -``` - -### Per-archetype README key facts - -**`templates/iac/README.md`:** -- Purpose: Terraform/OpenTofu modules that provision a single untra product's cloud (public + private sides). -- Modules to enumerate: `cloudflare_zone`, `gcp_project`, `cloud_run_service`, `firebase_auth`, `neon_project`, `secret_manager`, `iap_admin`, `github_oidc_wif`. -- Two top-level compositions: `iac/public/` and `iac/private/`, run against separate GCP projects. -- Backend state: GCS bucket per product, configured via `terraform init -backend-config`. - -**`templates/api/README.md`:** -- Purpose: generic product backend. Rust + Axum + Cloud Run. -- Endpoints (initial): `GET /version` (unauth), `GET /me` (entitlement-JWT-auth, returns user + active license summary), `POST /webhooks/lemonsqueezy` (HMAC-verified). -- Verifies entitlement JWT against `license` public key, embedded at build time. -- Talks to: Neon Postgres (user table, license cache table); calls `license` over signed HMAC internal request. - -**`templates/app/README.md`:** -- Purpose: customer-facing web app at `app.`. TypeScript + React + Vite + Firebase JS SDK. -- Routes (initial): `/` (landing/download), `/signin` (redirects to `auth.`), `/account` (signed-in: shows licenses, machines), `/licenses/:id` (manage machines for a license). -- Calls `auth.` for sign-in flow; calls `api.` for authenticated data. -- Static-friendly: deployable to Cloud Storage + Cloud CDN or Cloudflare Pages. - -**`templates/auth/README.md`:** -- Purpose: identity orchestrator at `auth.`. Cloud Run service + small static UI. -- Flow: user signs in via Firebase (email/password, magic-link, OAuth) → static UI captures Firebase ID token → `auth` calls `license.` to verify entitlement → returns entitlement JWT to client. Also handles license-key claim (first-time activation) and machine registration (machine-fingerprint binding within the 3-machine cap). -- Endpoints: `POST /claim` (Firebase token + license key + machine fingerprint), `POST /refresh` (Firebase token + machine fingerprint). - -**`templates/admin/README.md`:** -- Purpose: admin console at `admin.`, gated by GCP IAP (no Firebase Auth — admin is internal). -- Routes (initial): `/plans` (define plans + feature bundles via OpenFeature schema), `/licenses` (search, view, revoke), `/users` (view accounts), `/billing` (LemonSqueezy passthrough links). -- Calls `api` for read data, calls `license` for write actions (mint license manually, revoke). - -**`templates/license/README.md`:** -- Purpose: privileged entitlement service. Rust + Axum + Cloud Run. Lives in private sister repo only. -- **Embed the eight locked entitlement decisions verbatim** (see "Entitlement model — locked decisions" above) as a "Frozen entitlement model" section. -- Endpoints: `POST /internal/verify` (called by `auth`, HMAC-authed, returns entitlement JWT), `POST /internal/mint` (called by `api`, HMAC-authed, creates a license record), `POST /internal/revoke` (called by `admin`, HMAC-authed). All endpoints are internal-only (Cloud Run with IAM-based ingress restriction). -- Crypto: Ed25519 signing key in Secret Manager; public key available at a public, unauth, cacheable `GET /.well-known/license-public-key` endpoint (used by client-side OpenFeature provider and by `api` for JWT verification). -- Detailed token schema and key-rotation policy: **deferred to A-spec follow-up.** - ---- - -## HANDOFF.md outline (per template) - -Fixed structure across all six templates so the briefs are interchangeable. Each `HANDOFF.md` should contain these sections in this order: - -``` -# Handoff brief — - -> Read this file before starting implementation. You are a fresh Claude session -> with no prior context. The README.md in this same directory has the role and -> stack. This file tells you what "done" looks like for the first milestone. - -## Pointer to master spec -Path: ~/.claude/plans/this-project-operator-is-rustling-tome.md -(or wherever the user has moved it after approval — check first). - -## Role (one paragraph) -Restate the role from README. Confirms shared interpretation. - -## Acceptance criteria (testable) -Concrete, runnable checks. Examples: -- "Produces a Cloud Run service that responds HTTP 200 to /healthz" -- "Returns HTTP 401 to /me when no Authorization header is present" -- "Terraform plan against a clean GCP project produces zero errors" - -## Public interface -- HTTP endpoints / UI routes / Terraform variables / library exports. -- Schemas where they exist (link to A-spec for entitlement JWT schema). - -## Dependencies -- Which other archetypes this calls (by name). -- Which other archetypes call this (by name). -- External services (Firebase, Neon, Cloudflare, LemonSqueezy). - -## Non-goals -Explicit list of things NOT to build in this session. Examples for `api`: -- Do not implement license signing — that's `license`'s job. -- Do not build the admin endpoints — those live in `admin`. -- Do not add OpenTelemetry exporters yet; add `tracing` only. - -## First milestone (smallest deployable slice) -A specific, minimal end-to-end slice that proves the template works. -Example for `api`: "GET /version returns {\"version\":\"0.1.0\"} as JSON, -deployed to Cloud Run min=0, served at api.." - -## Out-of-scope flags for later milestones -List of features to leave as `// TODO(milestone-2)` comments so the next -session knows what comes next. -``` - -### Per-archetype HANDOFF key facts - -**`templates/iac/HANDOFF.md`:** -- First milestone: a `terraform plan` for a hypothetical product `example-product` against a fresh GCP project produces a valid plan (no apply required this milestone). -- Non-goals: do not write a GitHub Actions workflow that runs `terraform apply` yet; do not provision DNS records for the private domain (private side is its own composition). - -**`templates/api/HANDOFF.md`:** -- First milestone: `GET /version` returns the current version as JSON; service builds into a Cloud Run image via the included Dockerfile; `curl localhost:8080/version` works locally. -- Non-goals: do not implement webhook signature verification yet (stub it); do not implement `/me` yet; do not connect to Neon yet (stub the DB layer). - -**`templates/app/HANDOFF.md`:** -- First milestone: a static site that renders a landing page with a "Download Operator" button and a "Sign In" link to `auth./signin`; `npm run build` produces a deployable `dist/`. -- Non-goals: no `/account` page yet; no API integration; no Firebase wiring in JS yet (just a link). - -**`templates/auth/HANDOFF.md`:** -- First milestone: a Cloud Run service exposing a static `/signin` page (Firebase UI or hand-rolled email-link form) that successfully signs a user in and shows their Firebase UID; `POST /claim` is stubbed and returns `501 Not Implemented`. -- Non-goals: do not call `license` yet (stub the call); do not implement machine-fingerprint logic yet; do not implement `/refresh`. - -**`templates/admin/HANDOFF.md`:** -- First milestone: a Cloud Run service with IAP enforcement that renders a "Hello, {user.email}" page sourced from `X-Goog-Authenticated-User-Email`. -- Non-goals: no plan editor yet; no license search; no revocation UI; no API integration. - -**`templates/license/HANDOFF.md`:** -- First milestone: a Cloud Run service with a `/healthz` endpoint and a `/.well-known/license-public-key` endpoint that returns a hardcoded Ed25519 public key (real key generation deferred to A-spec). -- Non-goals: do not implement `/internal/verify`, `/internal/mint`, or `/internal/revoke` yet; do not implement the revocation list; do not implement HMAC validation of internal callers yet (return `501` with a `TODO` comment); do not freeze the entitlement JWT schema (waits on A-spec follow-up). - ---- - -## Critical files to be created - -``` -../templates/README.md (top-level index, not in any git repo) -../templates/iac/README.md -../templates/iac/HANDOFF.md -../templates/iac/.git/ (git init only) -../templates/api/README.md -../templates/api/HANDOFF.md -../templates/api/.git/ -../templates/app/README.md -../templates/app/HANDOFF.md -../templates/app/.git/ -../templates/auth/README.md -../templates/auth/HANDOFF.md -../templates/auth/.git/ -../templates/admin/README.md -../templates/admin/HANDOFF.md -../templates/admin/.git/ -../templates/license/README.md -../templates/license/HANDOFF.md -../templates/license/.git/ -``` - -Plus the promotion copy: - -``` -operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md -``` - -Total: 13 files written, 6 directories `git init`'d, 1 spec file promoted. **Zero commits.** - ---- - -## Verification - -After implementation: - -```bash -# 0. Top-level index exists. -test -f ../templates/README.md && echo "top README OK" || echo "TOP README MISSING" - -# 1. All directories exist and are git repos. -for t in iac api app auth admin license; do - test -d ../templates/$t/.git && echo "$t: git OK" || echo "$t: GIT MISSING" -done - -# 2. Both files exist per template. -for t in iac api app auth admin license; do - test -f ../templates/$t/README.md && test -f ../templates/$t/HANDOFF.md \ - && echo "$t: files OK" || echo "$t: files MISSING" -done - -# 3. No stray files inside template repos. -find ../templates -mindepth 2 -maxdepth 2 -type f \ - ! -name README.md ! -name HANDOFF.md ! -path '*/.git/*' \ - | grep -v "^$" && echo "stray files present" || echo "no stray files" - -# 4. No commits yet (user commits manually). -for t in iac api app auth admin license; do - ( cd ../templates/$t && test -z "$(git log 2>/dev/null)" \ - && echo "$t: no commits OK" || echo "$t: HAS COMMITS" ) -done - -# 5. Promoted spec exists. -test -f operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md \ - && echo "promoted spec OK" || echo "PROMOTED SPEC MISSING" -``` - -After Phase 1 (separate sessions, out of scope here): each template implements its first-milestone acceptance criteria. - ---- - -## Non-goals of this plan (explicit) - -- No code beyond `README.md` and `HANDOFF.md`. -- No commits anywhere. -- No registration of any cloud account, domain, or LemonSqueezy account. -- No detailed entitlement-JWT schema or key rotation policy (that's A-spec follow-up). -- No work in `operator/` itself in this session (Operator's licensing-client integration C is a future ticket). -- No GitHub Actions workflows, Dockerfiles, or Terraform code. -- No bootstrap script (user chose "manual checklist" — checklist content lives in `templates/iac/README.md` Quickstart section, not as separate code). - ---- - -## Follow-ups queued after this plan executes - -1. **A-spec brainstorm session** — flesh out the entitlement-JWT schema, key format, key rotation, machine-fingerprint algorithm. Produces the detailed `license` data model. -2. **Per-template implementation sessions (6 of them)** — each starts a fresh Claude in the relevant `../templates//` directory and works from HANDOFF.md. -3. **Operator integration (C)** — separate ticket in `operator/` to add the OpenFeature provider, license-key UI, refresh loop. Depends on A-spec being done. -4. **Promote this plan** — confirmed at approval. Copy to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` as part of this session's deliverable (user commits manually). diff --git a/docs/taxonomy/index.md b/docs/taxonomy/index.md index b521b5d3..130ed09f 100644 --- a/docs/taxonomy/index.md +++ b/docs/taxonomy/index.md @@ -6,8 +6,6 @@ layout: doc -# Project Taxonomy - This document defines the **25 project Kinds** organized into **5 tiers**. Each Kind represents a category of project that can be classified by Operator. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files. diff --git a/docs/terms-of-service.md b/docs/terms-of-service.md index f710cd0b..23481c95 100644 --- a/docs/terms-of-service.md +++ b/docs/terms-of-service.md @@ -4,13 +4,11 @@ description: "Terms of Service for Operator!" layout: doc --- -# Operator Terms of Service - _Last Updated: 3/3/2026_ These Operator Terms of Service are entered into between UNTRA, LLC., a Colorado limited liability corporation having its principal place of business at 300 w 11th ave, apt 5D, Denver, CO 80204 ("we," "us," or the "Company"), and the person, company or other legal entity accepting these terms and conditions on behalf of itself and any entity that directly or indirectly controls, is controlled by, or is under common control with it (an "Affiliate") (such company or entity and its Affiliates, collectively, "you" or "Customer"). -This Operator Terms of Service sets forth the terms and conditions that govern your access to and use of the Solution (as defined herein below). You and Company may enter into order form(s) that further specifies the Solution to be provided by Company and that contains pricing, usage quantities, additional terms, conditions and limitations that apply to the Solution ordered by you. If you are placing your order via our Website (as defined herein below), you may select the initial subscription period, the usage quantities and the pricing applicable to such initial subscription period and usage quantities upon checking out on our Website. Any order forms entered into by you and Company and any order you place by checking out on our Website are each referred to herein as an "Order." These Operator Terms of Service, together with any Orders entered into by both parties, are referred to collectively as the "Agreement." By entering into an Order, your Affiliate agrees to be bound by the terms of this Agreement as if it were an original party to this Agreement. +This Operator Terms of Service sets forth the terms and conditions that govern your access to and use of the Solution (as defined herein below). You and Company may enter into order forms that further specifies the Solution to be provided by Company and that contains pricing, usage quantities, additional terms, conditions and limitations that apply to the Solution ordered by you. If you are placing your order via our Website (as defined herein below), you may select the initial subscription period, the usage quantities and the pricing applicable to such initial subscription period and usage quantities upon checking out on our Website. Any order forms entered into by you and Company and any order you place by checking out on our Website are each referred to herein as an "Order." These Operator Terms of Service, together with any Orders entered into by both parties, are referred to collectively as the "Agreement." By entering into an Order, your Affiliate agrees to be bound by the terms of this Agreement as if it were an original party to this Agreement. Please review this Agreement carefully before accessing or using the Company Properties (as defined herein below). By accessing or using any Company Property or by accepting this Agreement (whether by completing the registration process, by clicking a box that indicates acceptance or by executing an Order that references this Agreement), Customer agrees to all of the terms and conditions of this Agreement. If Customer does not agree to all of the terms and conditions of this Agreement, Customer must not access or use the Company Properties. @@ -22,7 +20,7 @@ Please review this Agreement carefully before accessing or using the Company Pro 1.2.1 Registration. Customer agrees to provide true, accurate, current and complete information as prompted by Company’s registration process, and to maintain and promptly update such information to keep it true, accurate, current and complete. Customer represents and warrants that (a) Customer has read, understands, and agrees to be bound by this Agreement; (b) the person entering into this Agreement has the authority to enter into this Agreement on behalf of the company or other entity named as the user, and to bind that company or entity to this Agreement; and (c) Customer is not barred from using the Company Properties under the laws of the United States, its place of residence or any other applicable jurisdiction. -1.2.2 Free Services. If Customer registers with Company for a free trial or no charge version of the Services ("Free Services"), Company shall make the Free Services available to Customer free of charge until the earlier of (a) the end of the free period for which Customer registered to use the applicable Free Service(s), or (b) the start date of any Purchased Services (as defined below in Section 1.2.3). ANY DATA CUSTOMER ENTERS INTO THE FREE SERVICES WILL BE PERMANENTLY LOST UNLESS CUSTOMER PURCHASES A SUBSCRIPTION TO THE SAME SERVICES AS THE FREE SERVICES OR TO UPGRADED SERVICES BEFORE THE END OF THE FREE PERIOD. NOTWITHSTANDING ANYTHING IN THIS AGREEMENT TO THE CONTRARY, ALL FREE SERVICES ARE PROVIDED "AS-IS" WITHOUT ANY WARRANTY. +1.2.2 Free Services. If Customer registers with Company for a free trial or no charge version of the Services ("Free Services"), Company shall make the Free Services available to Customer free of charge until the earlier of (a) the end of the free period for which Customer registered to use the applicable Free Services, or (b) the start date of any Purchased Services (as defined below in Section 1.2.3). ANY DATA CUSTOMER ENTERS INTO THE FREE SERVICES WILL BE PERMANENTLY LOST UNLESS CUSTOMER PURCHASES A SUBSCRIPTION TO THE SAME SERVICES AS THE FREE SERVICES OR TO UPGRADED SERVICES BEFORE THE END OF THE FREE PERIOD. NOTWITHSTANDING ANYTHING IN THIS AGREEMENT TO THE CONTRARY, ALL FREE SERVICES ARE PROVIDED "AS-IS" WITHOUT ANY WARRANTY. 1.2.3 Purchased Services. The Services offerings are available in various packages that offer different usage limits and varying levels of support. The Services that Customer purchases pursuant to an Order on the parameters specified in such Order are the "Purchased Services." Purchased Services exclude any Free Services or any other services made available to Customer free of charge. diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index 049d495e..6da1b00c 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +49,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -129,9 +129,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -898,7 +898,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -1185,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1692,15 +1692,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1734,30 +1725,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -1770,12 +1744,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -1788,12 +1756,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -1806,24 +1768,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -1836,12 +1786,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -1854,12 +1798,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -1872,12 +1810,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -1890,12 +1822,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/scripts/ci/check-coder-module.sh b/scripts/ci/check-coder-module.sh new file mode 100755 index 00000000..2364ec15 --- /dev/null +++ b/scripts/ci/check-coder-module.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Render coder-module/run.sh the way main.tf's templatefile() does, then syntax-check and shellcheck the result. +# The rendered coder_script actually runs in a workspace, so a bash syntax error here is a broken module. +# templatefile() only escapes `${`, which makes over-escaped `$$(cmd)` a silent breakage. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RUN_SH="$ROOT_DIR/coder-module/run.sh" +MAIN_TF="$ROOT_DIR/coder-module/main.tf" + +# CI installs terraform; local dev machines may only have OpenTofu. +if command -v terraform &>/dev/null; then + TF=terraform +elif command -v tofu &>/dev/null; then + TF=tofu +else + echo "Neither terraform nor tofu found; install one to run this check." >&2 + exit 1 +fi + +# Vars mirroring the templatefile() call in main.tf (values follow the +# variable defaults). Keys are parity-checked against main.tf below, so a +# newly added template var fails with a clear message instead of a render +# error deep inside CI. +VARS=' + VERSION = "0.0.0", + PORT = 7008, + INSTALL_PREFIX = "/tmp/operator", + LOG_PATH = "/tmp/operator.log", + CONFIG_TOML = "", + MAX_PARALLEL = 2, + SESSION_WRAPPER = "tmux", + OFFLINE = false, + USE_CACHED = false, + AGENT_TEMPLATE = "operator-agent", + CODER_TOKEN_ENV = "CODER_SESSION_TOKEN", + CALLBACK_URL = "", +' + +extract_keys() { + awk -F= 'NF > 1 { gsub(/[ \t,]/, "", $1); if ($1 ~ /^[A-Z_]+$/) print $1 }' | sort +} + +tf_keys="$(awk '/templatefile\(/ { f = 1; next } f && /\}\)/ { exit } f' "$MAIN_TF" | extract_keys)" +local_keys="$(extract_keys <<<"$VARS")" + +if [ "$tf_keys" != "$local_keys" ]; then + echo "Template var mismatch between coder-module/main.tf and $(basename "$0"):" >&2 + diff <(echo "$tf_keys") <(echo "$local_keys") | sed 's/^/ /' >&2 || true + echo "Update the VARS map in this script to match main.tf's templatefile() call." >&2 + exit 1 +fi + +RENDER="$(mktemp -d)" +trap 'rm -rf "$RENDER"' EXIT + +cat > "$RENDER/main.tf" </dev/null +"$TF" -chdir="$RENDER" apply -auto-approve -input=false >/dev/null +"$TF" -chdir="$RENDER" output -raw s > "$RENDER/rendered.sh" + +bash -n "$RENDER/rendered.sh" +shellcheck -S error "$RENDER/rendered.sh" +echo "coder-module rendered startup script OK" diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh index af6f92b2..915246c2 100755 --- a/scripts/cicdprep.sh +++ b/scripts/cicdprep.sh @@ -165,6 +165,7 @@ needs_operator() { needs_opr8r() { has_changes '^opr8r/'; } needs_vscode() { has_changes '^(vscode-extension/|icons/)'; } needs_zed() { has_changes '^zed-extension/'; } +needs_coder() { has_changes '^(coder-module/|\.github/workflows/coder-module\.yaml$|scripts/ci/check-coder-module\.sh$)'; } needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json|src/collections/|collections/|src/schemas/|webcomponents/|src/workflow_gen/)'; } # A bun project needs a lockfile check whenever its package.json or bun.lock @@ -297,7 +298,27 @@ else skip "zed-extension" fi -# --- 5. docs --- +# --- 5. coder-module --- + +if needs_coder; then + section "coder-module" + require_tool shellcheck "coder-module rendered script" + require_tool bun "coder-module tests" + if ! command -v terraform &>/dev/null && ! command -v tofu &>/dev/null; then + echo -e "${RED}Missing required tool: ${BOLD}terraform or tofu${RESET}${RED} (needed for coder-module)${RESET}" + exit 1 + fi + TF_BIN=$(command -v terraform >/dev/null && echo terraform || echo tofu) + + run_step "coder-module fmt" "$TF_BIN" -chdir=coder-module fmt -check -diff + run_step "coder-module validate" bash -c "$TF_BIN -chdir=coder-module init -input=false -backend=false >/dev/null && $TF_BIN -chdir=coder-module validate" + run_step "coder-module rendered shellcheck" scripts/ci/check-coder-module.sh + run_step "coder-module test" bash -c "cd coder-module && bun test" +else + skip "coder-module" +fi + +# --- 6. docs --- if needs_docs; then section "docs" diff --git a/shared/types.ts b/shared/types.ts index efaaa640..9180ce69 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -295,6 +295,10 @@ model_servers: Array, * from `DelegatorLaunchConfig.host`. */ hosts: Array, +/** + * Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. + */ +targets: Array, /** * Relay MCP injection configuration */ @@ -488,7 +492,11 @@ capabilities: ToolCapabilities, /** * CLI flags for YOLO (auto-accept) mode */ -yolo_flags: Array, }; +yolo_flags: Array, +/** + * Whether the tool passed its health check at detection on startup + */ +health_ok: boolean, }; export type ToolCapabilities = { /** @@ -636,7 +644,8 @@ use_worktrees: boolean | null, */ create_branch: boolean | null, /** - * Run in docker container (None = use global `launch.docker.enabled`) + * DEPRECATED: prefer `target`. Run in docker container + * (None = fall back to `launch.docker.enabled`, then local). */ docker: boolean | null, /** @@ -652,10 +661,16 @@ prompt_suffix: string | null, */ operator_relay: boolean | null, /** - * Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent - * CLI on over SSH. `None` = launch locally. + * DEPRECATED: prefer `target`. Name of a declared `RemoteHost` (from + * `Config.hosts`) to launch the agent CLI on over SSH. `None` = local. */ -host?: string | null, }; +host?: string | null, +/** + * Name of an execution target: an explicit `[[targets]]` entry, the + * synthesized `local`/`docker` targets, or a `[[hosts]]` name. + * Supersedes `docker` and `host`. + */ +target?: string | null, }; export type AgentProfile = { /** @@ -848,19 +863,19 @@ step_started_at: string | null, */ last_content_change: string | null, /** - * PR URL if created during "pr" step + * PR/MR URL if created during the "pr" step */ pr_url: string | null, /** - * PR number for GitHub API tracking + * Code review request (PR/MR) number */ pr_number: bigint | null, /** - * GitHub repo in format "owner/repo" + * Repository in "owner/repo" format on the configured git provider */ -github_repo: string | null, +repo: string | null, /** - * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + * Last known PR/MR status ("open", "approved", "`changes_requested`", "merged", "closed") */ pr_status: string | null, /** @@ -876,7 +891,8 @@ llm_tool: string | null, */ llm_model: string | null, /** - * Launch mode: "default", "yolo", "docker", "docker-yolo" + * Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` + * (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) */ launch_mode: string | null, /** @@ -895,7 +911,15 @@ worktree_path: string | null, /** * Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) */ -remote_host: string | null, }; +remote_host: string | null, +/** + * Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. + */ +step_launch_context: StepLaunchContext | null, +/** + * Name of the resolved execution target this agent launched on + */ +target_name: string | null, }; export type CompletedTicket = { ticket_id: string, ticket_type: string, project: string, summary: string, completed_at: string, pr_url: string | null, output_tickets: Array, }; @@ -925,19 +949,19 @@ export type CreateFieldRequest = { name: string, description: string, field_type export type StepResponse = { name: string, display_name: string | null, prompt: string, outputs: Array, allowed_tools: Array, /** - * Type of review required: "none", "plan", "visual", "pr" + * Type of review required: "none", "plan", "visual", "pr", "proof" */ review_type: string, next_step: string | null, permission_mode: string, }; export type CreateStepRequest = { name: string, display_name: string | null, prompt: string, outputs: Array, allowed_tools: Array, /** - * Type of review required: "none", "plan", "visual", "pr" + * Type of review required: "none", "plan", "visual", "pr", "proof" */ review_type: string, next_step: string | null, permission_mode: string, }; export type UpdateStepRequest = { display_name: string | null, prompt: string | null, outputs: Array | null, allowed_tools: Array | null, /** - * Type of review required: "none", "plan", "visual", "pr" + * Type of review required: "none", "plan", "visual", "pr", "proof" */ review_type: string | null, next_step: string | null, permission_mode: string | null, }; @@ -1374,9 +1398,15 @@ prompt_suffix?: string | null, */ operator_relay?: boolean | null, /** - * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + * Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local). + * DEPRECATED: prefer `target`. */ -host?: string | null, }; +host?: string | null, +/** + * Name of an execution target (explicit `[[targets]]` entry, synthesized + * `local`/`docker`, or a `[[hosts]]` name). Supersedes `docker`/`host`. + */ +target?: string | null, }; export type LlmTask = { /** @@ -1662,7 +1692,11 @@ yoloMode: boolean, /** * Resume from existing session (uses `session_id` from ticket) */ -resumeSession: boolean, }; +resumeSession: boolean, +/** + * Execution-target override by name (None = delegator/default resolution) + */ +target?: string, }; export type VsCodeTicketMetadata = { /** diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index 2215a565..4d895d8e 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -306,6 +306,7 @@ mod tests { allowed_tools: vec![], review_type: ReviewType::None, visual_config: None, + proof_config: None, on_reject: None, next_step: None, permissions: None, diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 22233234..59efadef 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -5,7 +5,7 @@ use crate::agents::LaunchOptions; use crate::config::{ implicit_model_server_for_tool, Config, Delegator, DelegatorLaunchConfig, LlmProvider, - ModelServer, + ModelServer, TargetDef, TargetKind, TARGET_DOCKER, TARGET_LOCAL, }; /// Issuetype/step agent context for delegator resolution during launch. @@ -43,6 +43,8 @@ pub enum ResolutionError { "Delegator launch_config references unknown host '{0}' (no [[hosts]] entry with that name)" )] UnknownRemoteHost(String), + #[error("Unknown execution target '{name}' (known targets: {known})")] + UnknownTarget { name: String, known: String }, #[error("Remote host '{host}' cannot be combined with {feature} in v1")] RemoteHostConflict { host: String, feature: &'static str }, } @@ -124,12 +126,94 @@ fn adhoc_model_server_env( Ok(crate::api::providers::model_server::env_for_server(&server)) } +/// Resolve the execution target for a launch — the one pure decision point. +/// +/// Precedence: +/// 1. `target` name set → look up (explicit `[[targets]]`, builtin +/// `local`/`docker`, or a `[[hosts]]` name); unknown = hard error +/// 2. `host` name set (deprecated) → ssh target of that name +/// 3. `docker: Some(true)` (deprecated) → synthesized docker target +/// 4. `docker: Some(false)` → local +/// 5. `launch.docker.enabled` → synthesized docker target +/// 6. → local +/// +/// Deprecated combinations resolve deterministically (`target` wins over +/// `host`/`docker`; `host` wins over `docker: true`) with one deprecation +/// warning per process instead of the former hard error. +pub fn resolve_target( + launch_config: Option<&DelegatorLaunchConfig>, + config: &Config, +) -> Result { + static TARGET_WINS: std::sync::Once = std::sync::Once::new(); + static HOST_WINS: std::sync::Once = std::sync::Once::new(); + + if let Some(lc) = launch_config { + if let Some(ref name) = lc.target { + if lc.docker.is_some() || lc.host.is_some() { + TARGET_WINS.call_once(|| { + tracing::warn!( + "launch_config sets `target` together with deprecated `docker`/`host`; \ + `target` wins" + ); + }); + } + return resolve_named_target(config, name); + } + if let Some(ref host_name) = lc.host { + if lc.docker == Some(true) { + HOST_WINS.call_once(|| { + tracing::warn!( + "launch_config sets both `docker` and `host` (deprecated); host wins — \ + migrate to `target`" + ); + }); + } + let host = config + .hosts + .iter() + .find(|h| h.name == *host_name) + .ok_or_else(|| ResolutionError::UnknownRemoteHost(host_name.clone()))?; + return Ok(TargetDef::from_host(host)); + } + match lc.docker { + Some(true) => return Ok(TargetDef::docker(config.launch.docker.clone())), + Some(false) => return Ok(TargetDef::local()), + None => {} + } + } + if config.launch.docker.enabled { + return Ok(TargetDef::docker(config.launch.docker.clone())); + } + Ok(TargetDef::local()) +} + +/// Name lookup including synthesis: explicit `[[targets]]` entries first, then +/// the builtin `local`/`docker` targets, then one ssh target per `[[hosts]]`. +pub fn resolve_named_target(config: &Config, name: &str) -> Result { + if let Some(def) = config.targets.iter().find(|t| t.name == name) { + return Ok(def.clone()); + } + match name { + TARGET_LOCAL => return Ok(TargetDef::local()), + TARGET_DOCKER => return Ok(TargetDef::docker(config.launch.docker.clone())), + _ => {} + } + if let Some(host) = config.hosts.iter().find(|h| h.name == name) { + return Ok(TargetDef::from_host(host)); + } + Err(ResolutionError::UnknownTarget { + name: name.to_string(), + known: crate::config::known_target_names(config).join(", "), + }) +} + /// Apply a delegator's launch config to launch options. /// -/// Resolves `launch_config.host` against `config.hosts` and enforces the v1 -/// remote-launch constraints: worktrees and relay injection are forced off -/// (both assume the local filesystem), and docker mode or a zellij session -/// wrapper are hard conflicts rather than silent degradations. +/// Resolves the execution target via [`resolve_target`] (rows 5-6 apply even +/// when there is no launch config) and enforces the v1 remote-launch +/// constraints: worktrees and relay injection are forced off for ssh/coder +/// targets (both assume the local filesystem), and the zellij session wrapper +/// is a hard conflict rather than a silent degradation. pub(crate) fn apply_delegator_launch_config( options: &mut LaunchOptions, launch_config: &Option, @@ -138,39 +222,36 @@ pub(crate) fn apply_delegator_launch_config( if let Some(ref lc) = launch_config { options.yolo_mode = options.yolo_mode || lc.yolo; options.extra_flags.clone_from(&lc.flags); - if let Some(docker) = lc.docker { - options.docker_mode = docker; - } options.use_worktrees_override = lc.use_worktrees; options.create_branch_override = lc.create_branch; options.prompt_prefix.clone_from(&lc.prompt_prefix); options.prompt_suffix.clone_from(&lc.prompt_suffix); options.operator_relay = lc.operator_relay; + } - if let Some(ref host_name) = lc.host { - let host = config - .hosts - .iter() - .find(|h| h.name == *host_name) - .cloned() - .ok_or_else(|| ResolutionError::UnknownRemoteHost(host_name.clone()))?; - if options.docker_mode { - return Err(ResolutionError::RemoteHostConflict { - host: host.name, - feature: "docker mode", - }); - } - if config.sessions.wrapper == crate::config::SessionWrapperType::Zellij { - return Err(ResolutionError::RemoteHostConflict { - host: host.name, - feature: "the zellij session wrapper", - }); - } - options.use_worktrees_override = Some(false); - options.operator_relay = Some(false); - options.remote_host = Some(host); + let target = resolve_target(launch_config.as_ref(), config)?; + apply_target_to_options(options, target, config) +} + +/// Install a resolved target on launch options, enforcing the v1 remote +/// constraints (worktrees + relay off for ssh/coder; zellij is a hard +/// conflict). Also used for per-request target overrides. +pub(crate) fn apply_target_to_options( + options: &mut LaunchOptions, + target: TargetDef, + config: &Config, +) -> Result<(), ResolutionError> { + if matches!(target.kind, TargetKind::Ssh(_) | TargetKind::Coder(_)) { + if config.sessions.wrapper == crate::config::SessionWrapperType::Zellij { + return Err(ResolutionError::RemoteHostConflict { + host: target.name, + feature: "the zellij session wrapper", + }); } + options.use_worktrees_override = Some(false); + options.operator_relay = Some(false); } + options.target = target; Ok(()) } @@ -421,6 +502,7 @@ mod tests { ssh_alias: "vm-alias".to_string(), workdir: "/srv/agents".to_string(), display_name: None, + ssh_config_path: None, }); let mut d = make_delegator("claude-remote", "claude", "opus"); d.launch_config = Some(DelegatorLaunchConfig { @@ -444,7 +526,10 @@ mod tests { None, ) .unwrap(); - let host = options.remote_host.expect("remote host resolved"); + let host = options + .target + .as_remote_host() + .expect("remote host resolved"); assert_eq!(host.name, "gpu-vm"); assert_eq!(host.ssh_alias, "vm-alias"); assert_eq!(host.workdir, "/srv/agents"); @@ -475,7 +560,7 @@ mod tests { .push(make_delegator("local", "claude", "opus")); let options = resolve_launch_options(&config, Some("local"), None, None, None, false, None).unwrap(); - assert!(options.remote_host.is_none()); + assert!(options.target.as_remote_host().is_none()); } #[test] @@ -499,10 +584,12 @@ mod tests { } #[test] - fn test_remote_host_plus_docker_errors() { + fn test_remote_host_plus_docker_resolves_to_host() { + // Legacy both-set configs now resolve deterministically to the host + // (with a deprecation warning) instead of a hard error. let mut config = make_remote_config("gpu-vm"); config.delegators[0].launch_config.as_mut().unwrap().docker = Some(true); - let err = resolve_launch_options( + let options = resolve_launch_options( &config, Some("claude-remote"), None, @@ -511,8 +598,10 @@ mod tests { false, None, ) - .unwrap_err(); - assert!(matches!(err, ResolutionError::RemoteHostConflict { .. })); + .unwrap(); + let host = options.target.as_remote_host().expect("host wins"); + assert_eq!(host.name, "gpu-vm"); + assert!(!options.is_docker()); } #[test] @@ -650,6 +739,7 @@ mod tests { prompt_suffix: Some("SUFFIX".to_string()), operator_relay: None, host: None, + target: None, }), remote_agent: None, x_agnt: None, @@ -660,7 +750,7 @@ mod tests { let options = resolve_launch_options(&config, Some("full"), None, None, None, false, None).unwrap(); assert!(options.yolo_mode); - assert!(options.docker_mode); + assert!(options.is_docker()); assert_eq!(options.use_worktrees_override, Some(true)); assert_eq!(options.create_branch_override, Some(false)); assert_eq!(options.extra_flags, vec!["--verbose".to_string()]); @@ -752,4 +842,175 @@ mod tests { let options = resolve_launch_options(&config, None, None, None, None, true, None).unwrap(); assert!(options.yolo_mode); } + + // ======================================== + // resolve_target() precedence table + // ======================================== + + fn lc(f: impl FnOnce(&mut DelegatorLaunchConfig)) -> DelegatorLaunchConfig { + let mut lc = DelegatorLaunchConfig::default(); + f(&mut lc); + lc + } + + #[test] + fn test_target_row1_target_name_beats_host_and_docker() { + let mut config = Config::default(); + config.hosts.push(crate::config::RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu".to_string(), + workdir: "/p".to_string(), + display_name: None, + ssh_config_path: None, + }); + config.targets.push(TargetDef { + name: "sandbox".to_string(), + display_name: None, + kind: TargetKind::Docker(crate::config::DockerConfig { + image: "img:explicit".to_string(), + ..Default::default() + }), + }); + let launch = lc(|l| { + l.target = Some("sandbox".to_string()); + l.host = Some("gpu-vm".to_string()); + l.docker = Some(true); + }); + let target = resolve_target(Some(&launch), &config).unwrap(); + assert_eq!(target.name, "sandbox"); + assert!( + matches!(&target.kind, TargetKind::Docker(d) if d.image == "img:explicit"), + "explicit target payload must win: {target:?}" + ); + } + + #[test] + fn test_target_row2_host_beats_docker_true() { + let mut config = Config::default(); + config.hosts.push(crate::config::RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu".to_string(), + workdir: "/p".to_string(), + display_name: Some("GPU".to_string()), + ssh_config_path: None, + }); + let launch = lc(|l| { + l.host = Some("gpu-vm".to_string()); + l.docker = Some(true); + }); + let target = resolve_target(Some(&launch), &config).unwrap(); + let host = target.as_remote_host().expect("host wins over docker"); + assert_eq!(host.name, "gpu-vm"); + assert_eq!(host.display_name.as_deref(), Some("GPU")); + } + + #[test] + fn test_target_row3_docker_true_synthesizes_docker() { + let mut config = Config::default(); + config.launch.docker.image = "img:global".to_string(); + let launch = lc(|l| l.docker = Some(true)); + let target = resolve_target(Some(&launch), &config).unwrap(); + assert_eq!(target.name, crate::config::TARGET_DOCKER); + assert!(matches!(&target.kind, TargetKind::Docker(d) if d.image == "img:global")); + } + + #[test] + fn test_target_row4_docker_false_shields_enabled_fallback() { + let mut config = Config::default(); + config.launch.docker.enabled = true; + let launch = lc(|l| l.docker = Some(false)); + let target = resolve_target(Some(&launch), &config).unwrap(); + assert_eq!(target.kind, TargetKind::Local); + } + + #[test] + fn test_target_row5_enabled_true_now_targets_docker_for_auto_launches() { + // BEHAVIOR CHANGE (approved): launch.docker.enabled was previously only + // a TUI dialog gate; it is now a real resolution fallback, so REST/CLI/ + // auto launches with enabled = true run in docker. + let mut config = Config::default(); + config.launch.docker.enabled = true; + for launch in [None, Some(lc(|_| {}))] { + let target = resolve_target(launch.as_ref(), &config).unwrap(); + assert!( + matches!(target.kind, TargetKind::Docker(_)), + "enabled=true must resolve to docker (input: {launch:?})" + ); + } + } + + #[test] + fn test_target_row6_default_is_local() { + let config = Config::default(); + assert_eq!( + resolve_target(None, &config).unwrap().kind, + TargetKind::Local + ); + assert_eq!( + resolve_target(Some(&DelegatorLaunchConfig::default()), &config) + .unwrap() + .kind, + TargetKind::Local + ); + } + + #[test] + fn test_resolve_named_target_builtins_and_synthesis() { + let mut config = Config::default(); + config.launch.docker.image = "img:g".to_string(); + config.hosts.push(crate::config::RemoteHost { + name: "legacy".to_string(), + ssh_alias: "l".to_string(), + workdir: "/p".to_string(), + display_name: None, + ssh_config_path: None, + }); + + assert_eq!( + resolve_named_target(&config, "local").unwrap().kind, + TargetKind::Local + ); + assert!(matches!( + resolve_named_target(&config, "docker").unwrap().kind, + TargetKind::Docker(_) + )); + let legacy = resolve_named_target(&config, "legacy").unwrap(); + assert!(matches!(legacy.kind, TargetKind::Ssh(_))); + assert_eq!(legacy.name, "legacy"); + } + + #[test] + fn test_resolve_named_target_unknown_is_hard_error_listing_known() { + let config = Config::default(); + let launch = lc(|l| l.target = Some("nope".to_string())); + let err = resolve_target(Some(&launch), &config).unwrap_err(); + let msg = err.to_string(); + assert!( + matches!(err, ResolutionError::UnknownTarget { .. }), + "unknown target must never silently fall back to local" + ); + assert!(msg.contains("local") && msg.contains("docker"), "{msg}"); + } + + #[test] + fn test_legacy_host_and_target_synthesis_equivalent() { + // A [[hosts]] entry and an equivalent [[targets]] ssh entry resolve to + // the same RemoteHost shape. + let host = crate::config::RemoteHost { + name: "vm".to_string(), + ssh_alias: "vm-a".to_string(), + workdir: "/w".to_string(), + display_name: None, + ssh_config_path: None, + }; + let mut host_config = Config::default(); + host_config.hosts.push(host.clone()); + let via_host = resolve_named_target(&host_config, "vm").unwrap(); + + let mut target_config = Config::default(); + target_config.targets.push(TargetDef::from_host(&host)); + let via_target = resolve_named_target(&target_config, "vm").unwrap(); + + assert_eq!(via_host.as_remote_host(), via_target.as_remote_host()); + } } diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index a56106f5..151f9555 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -14,14 +14,15 @@ use crate::queue::Ticket; use super::interpolation::PromptInterpolator; use super::llm_command::{ - apply_yolo_flags, build_docker_command, build_llm_command_with_permissions_for_tool, - get_default_model, + apply_yolo_flags, build_llm_command_with_permissions_for_tool, get_default_model, + wrap_for_target, }; use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, write_prompt_file, OperatorEnvVars, }; +use super::step_command; use super::SESSION_PREFIX; /// Result of launching in cmux — includes refs needed for state tracking @@ -151,14 +152,14 @@ pub fn launch_in_cmux_with_options( // Remote launch: the local workspace runs a wrapper that ships the prompt // and payload over SSH and execs into a remote tmux session through a // reverse tunnel. Relay injection is skipped (unix socket is local-only). - if let Some(ref host) = options.remote_host { + if let Some(host) = options.remote_host() { let session_name = super::remote::launch_remote_in_session( config, ticket, &session_name, &session_uuid, &step_name, - host, + &host, &tool_name, &model, &prompt_file, @@ -197,15 +198,22 @@ pub fn launch_in_cmux_with_options( llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool_name); } - if options.docker_mode { - llm_cmd = build_docker_command( - config, - &llm_cmd, - project_path, - options.provider.as_ref().map(|p| &p.env), - )?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + if step_command::chain_step(config, ticket, &step_name) { + let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + config, + &llm_cmd, + project_path, + &options.target, + options.provider.as_ref().map(|p| &p.env), + )?; + // Write the command to a shell script file let command_file = write_command_file( config, @@ -366,15 +374,22 @@ pub fn launch_in_cmux_with_relaunch_options( llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool_name); } - if options.launch_options.docker_mode { - llm_cmd = build_docker_command( - config, - &llm_cmd, - project_path, - options.launch_options.provider.as_ref().map(|p| &p.env), - )?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + if step_command::chain_step(config, ticket, &step_name) { + let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + config, + &llm_cmd, + project_path, + &options.launch_options.target, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; + // Write and send command let command_file = write_command_file( config, diff --git a/src/agents/launcher/coder.rs b/src/agents/launcher/coder.rs new file mode 100644 index 00000000..7231f85b --- /dev/null +++ b/src/agents/launcher/coder.rs @@ -0,0 +1,499 @@ +//! Coder workspace target: lifecycle + SSH alias provisioning. +//! +//! A coder target's execution shape is an SSH target with a +//! dynamically-provisioned alias — the launch itself reuses `remote.rs` +//! unchanged. This module owns only what is Coder-specific: deterministic +//! workspace naming, create/start lifecycle (never delete), the SSH config +//! fragment (`ProxyCommand coder ssh --stdio`), and the git checkout on the +//! workspace. Identity is a plain user session token resolved from the +//! environment **by name** and never written to disk. + +use std::path::PathBuf; +use std::process::Command; + +use anyhow::{Context, Result}; + +use crate::config::{CoderConfig, Config, RemoteHost}; + +use super::prompt::shell_escape; + +/// Coder caps workspace names at 32 characters. +const MAX_WORKSPACE_NAME: usize = 32; +/// Over budget: keep this much of the readable key, then `-` + 6 hex of hash. +const TRUNCATED_KEY_LEN: usize = 25; + +/// Resolved Coder credentials — env values read at launch time, held only in +/// memory and injected into `coder` child processes under the CLI's standard +/// variable names. +#[derive(Debug)] +pub(crate) struct CoderSession { + pub url: String, + pub token: String, +} + +/// Resolve URL and token from the environment by the configured variable +/// names. Missing either fails fast, naming the variable. +pub(crate) fn resolve_session(coder: &CoderConfig) -> Result { + let url = std::env::var(&coder.url_env).map_err(|_| { + anyhow::anyhow!( + "Coder target requires the '{}' environment variable (deployment URL); it is not set", + coder.url_env + ) + })?; + let token = std::env::var(&coder.token_env).map_err(|_| { + anyhow::anyhow!( + "Coder target requires the '{}' environment variable (session token); it is not set", + coder.token_env + ) + })?; + Ok(CoderSession { url, token }) +} + +/// Deterministic per-ticket workspace name: `{prefix}-{project}-{ticket_id}`, +/// lowercased, non-alphanumerics collapsed to single hyphens. Determinism is +/// what makes "never delete" an asset: the same ticket always maps to the +/// same workspace, so relaunch reuses it. Names over Coder's 32-char cap are +/// truncated and suffixed with 6 hex chars of a hash of the full key. +pub fn workspace_name(prefix: &str, project: &str, ticket_id: &str) -> String { + let full = sanitize(&format!("{prefix}-{project}-{ticket_id}")); + if full.len() <= MAX_WORKSPACE_NAME { + return full; + } + let hash = fnv1a(&full); + let truncated = full[..TRUNCATED_KEY_LEN].trim_end_matches('-'); + format!("{truncated}-{:06x}", hash & 0xFF_FFFF) +} + +fn sanitize(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_hyphen = false; + for c in s.to_lowercase().chars() { + if c.is_ascii_alphanumeric() { + out.push(c); + prev_hyphen = false; + } else if !prev_hyphen { + out.push('-'); + prev_hyphen = true; + } + } + out.trim_matches('-').to_string() +} + +fn fnv1a(s: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.bytes() { + hash ^= u64::from(b); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// The provisioned ssh alias for a workspace. +pub fn workspace_alias(workspace: &str) -> String { + format!("op-coder-{workspace}") +} + +/// SSH config fragment content: `coder ssh --stdio` as a `ProxyCommand`, so +/// real `ssh` — with the full flag set (`-t`, `-R`) — works over the Coder +/// tailnet. Operator writes its own fragment rather than running +/// `coder config-ssh`, which rewrites the user's `~/.ssh/config`. +pub fn ssh_fragment(workspace: &str) -> String { + format!( + "Host {alias}\n ProxyCommand coder ssh --stdio {workspace}\n User coder\n", + alias = workspace_alias(workspace), + ) +} + +/// Write the per-workspace fragment under `.tickets/operator/ssh/` and return +/// its path. Idempotent — keyed by workspace name. +pub(crate) fn write_ssh_fragment(config: &Config, workspace: &str) -> Result { + let ssh_dir = config.tickets_path().join("operator/ssh"); + std::fs::create_dir_all(&ssh_dir).context("Failed to create ssh fragment directory")?; + let path = ssh_dir.join(format!("{workspace}.config")); + std::fs::write(&path, ssh_fragment(workspace)).context("Failed to write ssh fragment")?; + Ok(path) +} + +/// A workspace as reported by `coder list --output json` (fields we consume). +#[derive(Debug, serde::Deserialize)] +pub(crate) struct WorkspaceInfo { + pub name: String, + pub template_name: String, +} + +/// What provisioning must do for a workspace, decided from `coder list` +/// output. Pure — directly unit-testable. +#[derive(Debug, PartialEq)] +pub(crate) enum WorkspaceAction { + /// Exists on our template: `coder start` (no-op if running) + Start, + /// Absent: `coder create --template -y` + Create, + /// Exists on a DIFFERENT template: refuse — guards against colliding + /// with a human's workspace of the same name. + Refuse { existing_template: String }, +} + +pub(crate) fn decide_workspace_action( + existing: Option<&WorkspaceInfo>, + template: &str, +) -> WorkspaceAction { + match existing { + None => WorkspaceAction::Create, + Some(ws) if ws.template_name == template => WorkspaceAction::Start, + Some(ws) => WorkspaceAction::Refuse { + existing_template: ws.template_name.clone(), + }, + } +} + +/// Git checkout script run on the workspace over ssh: reuse a matching +/// checkout (fetch + ticket branch), otherwise clone then branch. Branch +/// naming stays in Rust (the caller passes the `git.branch_format`-derived +/// name) — never duplicated into a Coder template. +pub(crate) fn checkout_script(workdir: &str, remote_url: &str, branch: &str) -> String { + let dir = shell_escape(workdir); + let url = shell_escape(remote_url); + let br = shell_escape(branch); + format!( + "set -e\nif [ -d {dir}/.git ] && [ \"$(git -C {dir} remote get-url origin)\" = {url} ]; then\n git -C {dir} fetch origin\nelse\n rm -rf {dir}\n git clone {url} {dir}\nfi\ngit -C {dir} checkout -B {br}\n" + ) +} + +/// Run a `coder` CLI invocation with the session injected under the CLI's +/// standard env names. Errors surface Coder's stderr verbatim — quota and +/// permission failures are the control plane's message, not ours to +/// reinterpret. +fn run_coder(session: &CoderSession, args: &[&str]) -> Result { + let output = Command::new("coder") + .args(args) + .env("CODER_URL", &session.url) + .env("CODER_SESSION_TOKEN", &session.token) + .output() + .context("Failed to run the `coder` CLI")?; + if !output.status.success() { + anyhow::bail!( + "`coder {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Look up a workspace by exact name via `coder list --output json`. +fn find_workspace(session: &CoderSession, name: &str) -> Result> { + let out = run_coder( + session, + &[ + "list", + "--search", + &format!("name:{name}"), + "--output", + "json", + ], + )?; + let all: Vec = serde_json::from_str(out.trim()).unwrap_or_default(); + Ok(all.into_iter().find(|w| w.name == name)) +} + +/// Provision the workspace for a ticket and return the `RemoteHost` the +/// shared remote launch tail consumes. Blocking — workspace creation is +/// bounded by `create_timeout_secs`. +pub(crate) fn provision_workspace( + config: &Config, + coder: &CoderConfig, + project: &str, + ticket_id: &str, + remote_url: Option<&str>, + branch: Option<&str>, +) -> Result { + // Fail fast before any lifecycle action: credentials, then CLI presence. + let session = resolve_session(coder)?; + if !cli_available() { + anyhow::bail!( + "Coder target requires the `coder` CLI on PATH; install it from your deployment" + ); + } + + let workspace = workspace_name(&coder.name_prefix, project, ticket_id); + match decide_workspace_action( + find_workspace(&session, &workspace)?.as_ref(), + &coder.template, + ) { + WorkspaceAction::Refuse { existing_template } => anyhow::bail!( + "Workspace '{workspace}' exists on template '{existing_template}', not \ + '{}'; refusing to reuse a workspace Operator did not create", + coder.template + ), + WorkspaceAction::Start => { + run_coder(&session, &["start", &workspace, "--no-wait"]).map(|_| ())?; + } + WorkspaceAction::Create => { + let mut args: Vec = vec![ + "create".to_string(), + workspace.clone(), + "--template".to_string(), + coder.template.clone(), + "-y".to_string(), + ]; + let mut params: Vec<_> = coder.parameters.iter().collect(); + params.sort(); + for (k, v) in params { + args.push("--parameter".to_string()); + args.push(format!("{k}={v}")); + } + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run_coder(&session, &arg_refs).map(|_| ())?; + } + } + + let fragment = write_ssh_fragment(config, &workspace)?; + let alias = workspace_alias(&workspace); + let workdir = coder + .workdir + .clone() + .unwrap_or_else(|| format!("/home/coder/{project}")); + + wait_for_ssh(&fragment, &alias, coder.create_timeout_secs)?; + + // Ensure the checkout before the agent lands in the workdir. + if let (Some(url), Some(branch)) = (remote_url, branch) { + let script = checkout_script(&workdir, url, branch); + run_ssh(&fragment, &alias, &script) + .with_context(|| format!("Failed to prepare checkout on workspace '{workspace}'"))?; + } + + Ok(RemoteHost { + name: workspace.clone(), + ssh_alias: alias, + workdir, + display_name: Some(format!("coder/{workspace}")), + ssh_config_path: Some(fragment.to_string_lossy().to_string()), + }) +} + +/// Stop the workspace (never delete — reclamation is the Coder admin's +/// autostop/autodelete policy). Best-effort by design. +pub fn stop_workspace(coder: &CoderConfig, workspace: &str) -> Result<()> { + let session = resolve_session(coder)?; + run_coder(&session, &["stop", workspace, "--yes"]).map(|_| ()) +} + +fn cli_available() -> bool { + Command::new("which") + .arg("coder") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Poll ssh connectivity through the provisioned alias until the workspace +/// agent answers, bounded by `timeout_secs`. +fn wait_for_ssh(fragment: &std::path::Path, alias: &str, timeout_secs: u64) -> Result<()> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); + loop { + let ok = Command::new("ssh") + .args(["-F".as_ref(), fragment.as_os_str()]) + .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]) + .arg(alias) + .arg("true") + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + anyhow::bail!( + "Workspace agent did not become reachable over ssh within {timeout_secs}s \ + (alias '{alias}')" + ); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + } +} + +fn run_ssh(fragment: &std::path::Path, alias: &str, script: &str) -> Result<()> { + let status = Command::new("ssh") + .args(["-F".as_ref(), fragment.as_os_str()]) + .args(["-o", "BatchMode=yes"]) + .arg(alias) + .arg(script) + .status() + .context("Failed to run ssh against the workspace")?; + if !status.success() { + anyhow::bail!("ssh command on workspace '{alias}' exited with {status}"); + } + Ok(()) +} + +/// Best-effort `stop_on_complete` for a finished agent: parses the persisted +/// launch mode, finds the coder target by the persisted target name, and +/// stops the workspace recorded as the agent's remote host. +pub fn stop_on_complete_for_agent(config: &Config, agent: &crate::state::AgentState) { + use crate::agents::{parse_launch_mode, LaunchModeKind}; + let is_coder = agent + .launch_mode + .as_deref() + .map(parse_launch_mode) + .is_some_and(|m| m.kind == LaunchModeKind::Coder); + if !is_coder { + return; + } + let Some(workspace) = agent.remote_host.clone() else { + return; + }; + let coder = agent.target_name.as_deref().and_then(|name| { + config + .targets + .iter() + .find(|t| t.name == name) + .and_then(|t| { + if let crate::config::TargetKind::Coder(c) = &t.kind { + Some(c.clone()) + } else { + None + } + }) + }); + let Some(coder) = coder else { + tracing::warn!( + agent = %agent.id, + "Cannot stop coder workspace: target no longer configured" + ); + return; + }; + if !coder.stop_on_complete { + return; + } + match stop_workspace(&coder, &workspace) { + Ok(()) => tracing::info!(workspace = %workspace, "Stopped coder workspace on completion"), + Err(e) => tracing::warn!(workspace = %workspace, error = %e, "Failed to stop workspace"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_name_deterministic_and_sanitized() { + let a = workspace_name("op", "MyProj", "FEAT-42"); + let b = workspace_name("op", "MyProj", "FEAT-42"); + assert_eq!(a, b, "same ticket must always map to the same workspace"); + assert_eq!(a, "op-myproj-feat-42"); + } + + #[test] + fn test_workspace_name_collapses_special_chars() { + assert_eq!(workspace_name("op", "a_b.c", "X--1"), "op-a-b-c-x-1"); + } + + #[test] + fn test_workspace_name_respects_32_char_cap() { + let name = workspace_name("op", "a-very-long-project-name-here", "FEATURE-12345"); + assert!( + name.len() <= 32, + "coder caps names at 32 chars, got {} ({name})", + name.len() + ); + // Truncated names stay deterministic and keep a readable prefix. + let again = workspace_name("op", "a-very-long-project-name-here", "FEATURE-12345"); + assert_eq!(name, again); + assert!(name.starts_with("op-a-very-long-project")); + // Distinct long keys must not collide after truncation. + let other = workspace_name("op", "a-very-long-project-name-here", "FEATURE-12346"); + assert_ne!(name, other); + } + + #[test] + fn test_ssh_fragment_shape() { + let frag = ssh_fragment("op-proj-feat-1"); + assert!(frag.contains("Host op-coder-op-proj-feat-1")); + assert!(frag.contains("ProxyCommand coder ssh --stdio op-proj-feat-1")); + assert!(frag.contains("User coder")); + } + + #[test] + fn test_decide_workspace_action_template_mismatch_refuses() { + let existing = WorkspaceInfo { + name: "ws".to_string(), + template_name: "someone-elses".to_string(), + }; + assert_eq!( + decide_workspace_action(Some(&existing), "operator-agent"), + WorkspaceAction::Refuse { + existing_template: "someone-elses".to_string() + } + ); + } + + #[test] + fn test_decide_workspace_action_matching_starts_absent_creates() { + let existing = WorkspaceInfo { + name: "ws".to_string(), + template_name: "operator-agent".to_string(), + }; + assert_eq!( + decide_workspace_action(Some(&existing), "operator-agent"), + WorkspaceAction::Start + ); + assert_eq!( + decide_workspace_action(None, "operator-agent"), + WorkspaceAction::Create + ); + } + + #[test] + fn test_resolve_session_missing_env_names_variable() { + let coder = CoderConfig { + template: "t".to_string(), + url_env: "OPERATOR_TEST_CODER_URL_UNSET".to_string(), + token_env: "OPERATOR_TEST_CODER_TOKEN_UNSET".to_string(), + name_prefix: "op".to_string(), + workdir: None, + stop_on_complete: true, + create_timeout_secs: 300, + callback_url: None, + parameters: std::collections::HashMap::new(), + }; + let err = resolve_session(&coder).unwrap_err().to_string(); + assert!( + err.contains("OPERATOR_TEST_CODER_URL_UNSET"), + "failure must name the missing variable: {err}" + ); + } + + #[test] + fn test_checkout_script_reuses_matching_clone_and_branches_in_rust() { + let script = checkout_script("/home/coder/proj", "git@github.com:u/r.git", "feat/x-42"); + assert!(script.contains("git clone 'git@github.com:u/r.git'")); + assert!(script.contains("fetch origin")); + assert!( + script.contains("checkout -B 'feat/x-42'"), + "branch name comes from Rust, never a template: {script}" + ); + assert!(script.starts_with("set -e\n")); + } + + #[test] + fn test_write_ssh_fragment_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + paths: crate::config::PathsConfig { + tickets: temp.path().to_string_lossy().to_string(), + projects: temp.path().to_string_lossy().to_string(), + state: temp.path().join("s").to_string_lossy().to_string(), + worktrees: temp.path().join("w").to_string_lossy().to_string(), + }, + ..Default::default() + }; + let p1 = write_ssh_fragment(&config, "ws-1").unwrap(); + let p2 = write_ssh_fragment(&config, "ws-1").unwrap(); + assert_eq!(p1, p2); + assert!(std::fs::read_to_string(&p1) + .unwrap() + .contains("ProxyCommand coder ssh --stdio ws-1")); + } +} diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 961e302e..9b4dc510 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -34,6 +34,32 @@ pub fn build_llm_command_with_permissions_for_tool( ticket: Option<&Ticket>, project_path: Option<&str>, operator_relay: Option, +) -> Result { + build_llm_command_impl( + config, + tool_name, + model, + session_id, + prompt_file, + ticket, + project_path, + operator_relay, + &crate::llm::verify_tool_health, + ) +} + +/// Injectable-verifier variant so tests don't depend on what is installed locally. +#[allow(clippy::too_many_arguments)] +fn build_llm_command_impl( + config: &Config, + tool_name: &str, + model: &str, + session_id: &str, + prompt_file: &std::path::Path, + ticket: Option<&Ticket>, + project_path: Option<&str>, + operator_relay: Option, + verify_health: &dyn Fn(&str) -> bool, ) -> Result { // Find the specified tool let tool = get_detected_tool(config, tool_name).ok_or_else(|| { @@ -42,6 +68,15 @@ pub fn build_llm_command_with_permissions_for_tool( ) })?; + // A recorded pass is trusted; anything else is re-verified here rather than + // failing on a value the launching process never checked (only the TUI runs + // startup detection — `launch`, `api`, `mcp` and `acp` do not). + if !tool.health_ok && !verify_health(tool_name) { + anyhow::bail!( + "LLM tool '{tool_name}' is not launchable: its health check failed. Check the binary is installed and on PATH, or fix its detection.health_command." + ); + } + // Build model flag based on tool's arg_mapping let model_flag = format!("--model {model} "); @@ -89,14 +124,78 @@ pub fn apply_yolo_flags(config: &Config, cmd: &str, tool_name: &str) -> String { cmd.to_string() } -/// Build a docker command that wraps the LLM command +/// Wrap an inner LLM command for a resolved execution target. +/// +/// `Local` is the identity; `Docker` wraps in `docker run`. `Ssh` and `Coder` +/// are NOT command wraps — they dispatch through the remote session launch +/// path before the command pipeline, so reaching them here is a bug. +pub fn wrap_for_target( + config: &Config, + inner_cmd: &str, + working_dir: &str, + target: &crate::config::TargetDef, + operator_env: Option<&std::collections::HashMap>, +) -> Result { + wrap_for_target_impl( + config, + inner_cmd, + working_dir, + target, + operator_env, + is_containerized(), + ) +} + +fn wrap_for_target_impl( + config: &Config, + inner_cmd: &str, + working_dir: &str, + target: &crate::config::TargetDef, + operator_env: Option<&std::collections::HashMap>, + containerized: bool, +) -> Result { + use crate::config::TargetKind; + match &target.kind { + TargetKind::Local => Ok(inner_cmd.to_string()), + TargetKind::Docker(docker_config) => { + if containerized { + anyhow::bail!( + "Refusing docker target '{}' inside a container: when Operator runs \ + containerised, the container IS the sandbox. Use a local target, or a \ + coder/ssh target for brokered isolation.", + target.name + ); + } + build_docker_command(config, docker_config, inner_cmd, working_dir, operator_env) + } + TargetKind::Ssh(_) | TargetKind::Coder(_) => anyhow::bail!( + "internal error: target '{}' reached the command-wrap pipeline; \ + remote targets dispatch via the session launch path", + target.name + ), + } +} + +/// Whether Operator itself is running inside a container (docker/podman). +fn is_containerized() -> bool { + std::path::Path::new("/.dockerenv").exists() + || std::path::Path::new("/run/.containerenv").exists() +} + +/// Build a docker command that wraps the LLM command. +/// +/// `docker_config` is the resolved target's payload (not necessarily the +/// global `launch.docker`). Values are shell-escaped where they can contain +/// hostile characters; `extra_args` are passed verbatim — users may embed +/// their own quoting. pub fn build_docker_command( config: &Config, + docker_config: &crate::config::DockerConfig, inner_cmd: &str, project_path: &str, operator_env: Option<&std::collections::HashMap>, ) -> Result { - let docker_config = &config.launch.docker; + use super::prompt::{shell_escape, shell_escape_if_needed}; if docker_config.image.is_empty() { anyhow::bail!( @@ -111,37 +210,59 @@ pub fn build_docker_command( "--rm".to_string(), "-it".to_string(), "-v".to_string(), - format!("{}:{}:rw", project_path, docker_config.mount_path), + shell_escape_if_needed(&format!("{}:{}:rw", project_path, docker_config.mount_path)), "-w".to_string(), - docker_config.mount_path.clone(), + shell_escape_if_needed(&docker_config.mount_path), ]; + // Mount the tickets tree at its host path so prompt files, per-session + // configs, and opr8r step payloads resolve inside the container. + let tickets_path = config.tickets_path().to_string_lossy().to_string(); + docker_args.push("-v".to_string()); + docker_args.push(shell_escape_if_needed(&format!( + "{tickets_path}:{tickets_path}:rw" + ))); + // Add environment variables for env_var in &docker_config.env_vars { docker_args.push("-e".to_string()); - docker_args.push(env_var.clone()); + docker_args.push(shell_escape_if_needed(env_var)); } - // Add extra args from config + // Add extra args from config (verbatim; see docstring) for arg in &docker_config.extra_args { docker_args.push(arg.clone()); } + // Route the containerised opr8r's completion POST to the host-side REST + // API. On Linux the gateway alias must be mapped explicitly — omitting it + // produces a silent stall, not an error. Injected before operator_env so + // an explicit caller value (e.g. a callback URL) wins (last -e wins). + #[cfg(target_os = "linux")] + docker_args.push("--add-host=host.docker.internal:host-gateway".to_string()); + docker_args.push("-e".to_string()); + docker_args.push(format!( + "OPERATOR_API_URL=http://host.docker.internal:{}", + config.rest_api.port + )); + // Add operator environment variables (if provided) if let Some(env) = operator_env { for (key, value) in env { docker_args.push("-e".to_string()); - docker_args.push(format!("{key}={value}")); + docker_args.push(shell_escape_if_needed(&format!("{key}={value}"))); } } // Add the image docker_args.push(docker_config.image.clone()); - // Add the inner command (use sh -c to handle complex commands) + // Add the inner command. Quoted as one argument to the container's shell — + // unquoted, every flag after the binary name would bind to $0/$1 and be + // silently dropped. docker_args.push("sh".to_string()); docker_args.push("-c".to_string()); - docker_args.push(inner_cmd.to_string()); + docker_args.push(shell_escape(inner_cmd)); Ok(docker_args.join(" ")) } @@ -471,6 +592,30 @@ fn relay_mcp_config_flag_with_command( write_mcp_server_config(session_dir, "relay", relay_entry) } +/// Locate the opr8r binary itself — alongside the running operator binary +/// first (primary: signed distribution), then on PATH. Used by the step +/// wrapper. Distinct from `locate_relay_command`, which also accepts the +/// legacy standalone relay binary. +pub(crate) fn locate_opr8r_binary() -> Option { + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let candidate = parent.join("opr8r"); + if candidate.exists() { + return Some(candidate); + } + } + } + if std::process::Command::new("which") + .arg("opr8r") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Some(PathBuf::from("opr8r")); + } + None +} + /// Locate the relay command, returning `(binary_path, extra_args)`. /// /// Discovery order: @@ -574,6 +719,7 @@ mod tests { supports_headless: true, }, yolo_flags: vec!["--dangerously-skip-permissions".to_string()], + health_ok: true, } } @@ -667,8 +813,13 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = - build_docker_command(&config, "claude --model sonnet", "/home/user/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude --model sonnet", + "/home/user/project", + None, + ); assert!(result.is_ok()); let cmd = result.unwrap(); @@ -681,7 +832,13 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude", "/home/user/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/home/user/project", + None, + ); let cmd = result.unwrap(); assert!( @@ -696,7 +853,13 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude", "/home/user/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/home/user/project", + None, + ); let cmd = result.unwrap(); assert!( @@ -713,7 +876,13 @@ mod tests { config.launch.docker.env_vars = vec!["ANTHROPIC_API_KEY".to_string(), "HOME=/root".to_string()]; - let result = build_docker_command(&config, "claude", "/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/project", + None, + ); let cmd = result.unwrap(); assert!( @@ -726,6 +895,84 @@ mod tests { ); } + #[test] + fn test_docker_command_injects_operator_api_url() { + let mut config = Config::default(); + config.launch.docker.image = "my-claude:latest".to_string(); + config.rest_api.port = 7008; + + let cmd = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/project", + None, + ) + .unwrap(); + + assert!( + cmd.contains("-e OPERATOR_API_URL=http://host.docker.internal:7008"), + "containerised opr8r must reach the host API via the gateway, got: {cmd}" + ); + } + + #[test] + fn test_docker_command_adds_host_gateway_on_linux() { + let mut config = Config::default(); + config.launch.docker.image = "my-claude:latest".to_string(); + + let cmd = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/project", + None, + ) + .unwrap(); + + #[cfg(target_os = "linux")] + assert!( + cmd.contains("--add-host=host.docker.internal:host-gateway"), + "Linux docker needs the host gateway mapped explicitly, got: {cmd}" + ); + #[cfg(not(target_os = "linux"))] + assert!( + !cmd.contains("--add-host=host.docker.internal"), + "host.docker.internal is native off Linux; no --add-host, got: {cmd}" + ); + } + + #[test] + fn test_docker_command_caller_env_overrides_api_url() { + let mut config = Config::default(); + config.launch.docker.image = "my-claude:latest".to_string(); + let mut env = std::collections::HashMap::new(); + env.insert( + "OPERATOR_API_URL".to_string(), + "http://callback.example:9999".to_string(), + ); + + let cmd = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/project", + Some(&env), + ) + .unwrap(); + + let injected = cmd + .find("OPERATOR_API_URL=http://host.docker.internal") + .unwrap(); + let caller = cmd + .find("OPERATOR_API_URL=http://callback.example:9999") + .unwrap(); + assert!( + caller > injected, + "caller-supplied OPERATOR_API_URL must come later (docker: last -e wins): {cmd}" + ); + } + #[test] fn test_build_docker_command_extra_args() { let mut config = Config::default(); @@ -734,7 +981,13 @@ mod tests { config.launch.docker.extra_args = vec!["--network=host".to_string(), "--privileged".to_string()]; - let result = build_docker_command(&config, "claude", "/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/project", + None, + ); let cmd = result.unwrap(); assert!(cmd.contains("--network=host"), "Should include extra arg 1"); @@ -745,7 +998,13 @@ mod tests { fn test_build_docker_command_no_image_errors() { let config = Config::default(); // image is empty by default - let result = build_docker_command(&config, "claude", "/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/project", + None, + ); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -761,12 +1020,120 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude --model sonnet", "/project", None); + let result = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude --model sonnet", + "/project", + None, + ); let cmd = result.unwrap(); assert!( - cmd.contains("sh -c claude --model sonnet"), - "Should wrap inner command with sh -c, got: {cmd}" + cmd.contains("sh -c 'claude --model sonnet'"), + "inner command must be quoted as ONE sh -c argument — unquoted, every \ + flag after the binary binds to $0/$1 and is silently dropped, got: {cmd}" + ); + } + + #[test] + fn test_build_docker_command_flags_survive_quoting() { + let mut config = Config::default(); + config.launch.docker.image = "img:1".to_string(); + let inner = "opr8r --ticket-id=FEAT-1 -- claude --model sonnet \"$(cat /p/f.txt)\""; + + let cmd = + build_docker_command(&config, &config.launch.docker.clone(), inner, "/proj", None) + .unwrap(); + + assert!( + cmd.ends_with(&format!("sh -c '{inner}'")), + "full inner command incl. flags and command substitution must survive as one \ + argument, got: {cmd}" + ); + } + + #[test] + fn test_build_docker_command_escapes_env_with_spaces() { + let mut config = Config::default(); + config.launch.docker.image = "img:1".to_string(); + let mut env = std::collections::HashMap::new(); + env.insert("GREETING".to_string(), "hello world".to_string()); + + let cmd = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/proj", + Some(&env), + ) + .unwrap(); + + assert!( + cmd.contains("-e 'GREETING=hello world'"), + "env values with spaces must be quoted, got: {cmd}" + ); + } + + #[test] + fn test_build_docker_command_mounts_tickets_tree() { + let mut config = Config::default(); + config.launch.docker.image = "img:1".to_string(); + + let cmd = build_docker_command( + &config, + &config.launch.docker.clone(), + "claude", + "/proj", + None, + ) + .unwrap(); + + let tickets = config.tickets_path().to_string_lossy().to_string(); + assert!( + cmd.contains(&format!("{tickets}:{tickets}:rw")), + "tickets tree must be mounted at its host path so prompts/configs/payloads \ + resolve inside the container, got: {cmd}" + ); + } + + #[test] + fn test_wrap_for_target_local_is_identity() { + let config = Config::default(); + let target = crate::config::TargetDef::local(); + let cmd = + wrap_for_target(&config, "claude --model sonnet", "/proj", &target, None).unwrap(); + assert_eq!(cmd, "claude --model sonnet"); + } + + #[test] + fn test_wrap_for_target_ssh_is_internal_error() { + let config = Config::default(); + let target = crate::config::TargetDef { + name: "gpu".to_string(), + display_name: None, + kind: crate::config::TargetKind::Ssh(crate::config::SshTarget { + ssh_alias: "gpu".to_string(), + workdir: "/p".to_string(), + ssh_config_path: None, + }), + }; + let err = wrap_for_target(&config, "claude", "/proj", &target, None).unwrap_err(); + assert!(err.to_string().contains("session launch path"), "{err}"); + } + + #[test] + fn test_wrap_for_target_docker_inside_container_errors() { + // DinD is an enforced non-goal: when Operator runs containerised, the + // container IS the sandbox. + let mut config = Config::default(); + config.launch.docker.image = "img:1".to_string(); + let target = crate::config::TargetDef::docker(config.launch.docker.clone()); + let err = + wrap_for_target_impl(&config, "claude", "/proj", &target, None, true).unwrap_err(); + assert!( + err.to_string().contains("container IS the sandbox"), + "{err}" ); } @@ -850,6 +1217,59 @@ mod tests { ); } + #[test] + fn test_build_llm_command_unhealthy_tool_errors() { + let mut tool = make_detected_tool(); + tool.health_ok = false; + let config = make_test_config_with_tool(tool); + + let result = build_llm_command_impl( + &config, + "claude", + "opus", + "sess-abc", + Path::new("/tmp/prompt.md"), + None, + None, + None, + &|_| false, + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("health check"), + "Error should mention the failed health check, got: {err}" + ); + } + + #[test] + fn test_build_llm_command_stale_health_revalidates() { + // A config written before `health_ok` existed (or by a stale run) must + // not permanently block launching a tool that is actually healthy. + let mut tool = make_detected_tool(); + tool.health_ok = false; + let config = make_test_config_with_tool(tool); + + let result = build_llm_command_impl( + &config, + "claude", + "opus", + "sess-abc", + Path::new("/tmp/prompt.md"), + None, + None, + None, + &|_| true, + ); + + assert!( + result.is_ok(), + "Live verification should override a stale health_ok, got: {:?}", + result.err() + ); + } + #[test] fn test_build_llm_command_template_interpolation() { let tool = make_detected_tool(); diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 693b7563..36c566d5 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -6,11 +6,13 @@ #![allow(dead_code)] mod cmux_session; +pub(crate) mod coder; pub mod interpolation; pub(crate) mod llm_command; mod options; pub(crate) mod prompt; pub(crate) mod remote; +pub(crate) mod step_command; mod step_config; mod tmux_session; pub mod worktree_setup; @@ -36,7 +38,9 @@ use crate::queue::{Queue, Ticket}; use crate::state::State; use cmux_session::{launch_in_cmux_with_options, launch_in_cmux_with_relaunch_options}; -pub use options::{LaunchOptions, RelaunchOptions}; +pub use options::{ + parse_launch_mode, LaunchModeKind, LaunchOptions, ParsedLaunchMode, RelaunchOptions, +}; use prompt::generate_prompt; use tmux_session::{launch_in_tmux_with_options, launch_in_tmux_with_relaunch_options}; use worktree_setup::setup_worktree_for_ticket; @@ -44,8 +48,8 @@ use zellij_session::{launch_in_zellij_with_options, launch_in_zellij_with_relaun use self::interpolation::PromptInterpolator; use self::llm_command::{ - apply_yolo_flags, build_docker_command, build_llm_command_with_permissions_for_tool, - get_default_model, + apply_yolo_flags, build_llm_command_with_permissions_for_tool, get_default_model, + wrap_for_target, }; use self::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_prompt_file, @@ -64,6 +68,28 @@ fn apply_prompt_wrapping(prompt: String, options: &LaunchOptions) -> String { } } +/// Read back the session uuid a session backend minted for this launch. +/// +/// tmux, cmux and zellij all record it on the in-progress ticket under the +/// step being launched, so the ticket file is the one place every backend +/// agrees on. Best-effort: `None` when the ticket was not (yet) written. +fn launched_session_uuid(config: &Config, ticket: &Ticket) -> Option { + let step_name = if ticket.step.is_empty() { + "initial" + } else { + &ticket.step + }; + let path = config + .tickets_path() + .join("in-progress") + .join(&ticket.filename); + Ticket::from_file(&path) + .ok()? + .sessions + .get(step_name) + .cloned() +} + /// Result of preparing a launch without executing it /// /// Contains all the information needed to launch an agent in any wrapper @@ -235,6 +261,7 @@ impl Launcher { ticket: &Ticket, options: LaunchOptions, ) -> Result { + let mut options = options; // Clone ticket so we can update worktree info let mut ticket = ticket.clone(); @@ -306,6 +333,9 @@ impl Launcher { let initial_prompt = generate_prompt(&self.config, &ticket); let initial_prompt = apply_prompt_wrapping(initial_prompt, &options); + self.provision_target(&ticket, &working_dir_str, &mut options) + .await?; + let (agent_id, _session) = self .launch_one_sub_agent(&ticket, &working_dir_str, &initial_prompt, &options) .await?; @@ -315,6 +345,36 @@ impl Launcher { /// Dispatch a single sub-agent launch: wrapper dispatch, state registration, /// worktree-path persistence, step recording, and start-up notification. /// Returns `(agent_id, session_name)`. + /// Provision non-local infrastructure for the resolved target before any + /// session is created. Coder targets: create/start the workspace, write + /// the ssh fragment, prepare the checkout, and point the launch at the + /// provisioned alias (`callback_url` overrides the tunnel API URL). + async fn provision_target( + &self, + ticket: &Ticket, + working_dir_str: &str, + options: &mut LaunchOptions, + ) -> Result<()> { + if let crate::config::TargetKind::Coder(coder_cfg) = options.target.kind.clone() { + let remote_url = + crate::git::GitCli::get_remote_url(std::path::Path::new(working_dir_str)) + .await + .ok(); + let branch = ticket.branch_name(); + let host = coder::provision_workspace( + &self.config, + &coder_cfg, + &ticket.project, + &ticket.id, + remote_url.as_deref(), + Some(&branch), + )?; + options.api_url_override = coder_cfg.callback_url.clone().filter(|u| !u.is_empty()); + options.provisioned_host = Some(host); + } + Ok(()) + } + async fn launch_one_sub_agent( &self, ticket: &Ticket, @@ -344,12 +404,12 @@ impl Launcher { // Remote launches: verify the host is reachable and provisioned before // any session or workspace is created. - if let Some(ref host) = options.remote_host { + if let Some(host) = options.remote_host() { let tool = options .provider .as_ref() .map_or("claude", |p| p.tool.as_str()); - remote::run_preflight(host, tool)?; + remote::run_preflight(&host, tool)?; } // Dispatch based on session wrapper type @@ -448,7 +508,7 @@ impl Launcher { } // Store remote host so the dashboard can annotate the agent - if let Some(ref host) = options.remote_host { + if let Some(host) = options.remote_host() { state.update_agent_remote_host(&agent_id, &host.name)?; } @@ -457,13 +517,24 @@ impl Launcher { state.update_agent_step(&agent_id, &ticket.step)?; } + state.update_agent_target_name(&agent_id, &options.target.name)?; + + // Persist the launch context for multi-step exec-chain transitions. + // The session backends mint the uuid and record it on the ticket; read + // it back so `complete_step` can match this agent by session id. + let session_uuid = launched_session_uuid(&self.config, ticket); + state.update_agent_step_launch_context( + &agent_id, + self.step_launch_context_for(options, session_uuid.as_deref()), + )?; + // Send notification if self.config.notifications.enabled && self.config.notifications.on_agent_start { - let mode_suffix = match (options.docker_mode, options.yolo_mode) { - (true, true) => " [docker-yolo]", - (true, false) => " [docker]", - (false, true) => " [yolo]", - (false, false) => "", + let mode = options.launch_mode_string(); + let mode_suffix = if mode == "default" { + String::new() + } else { + format!(" [{mode}]") }; let worktree_suffix = if ticket.worktree_path.is_some() { " [worktree]" @@ -776,6 +847,38 @@ impl Launcher { /// returns the command and details instead of executing in tmux. /// /// Use this for launching via VS Code terminals or other wrappers. + /// Build the persisted step launch context from resolved options. + /// Tool/model defaulting mirrors the session backends. + fn step_launch_context_for( + &self, + options: &LaunchOptions, + session_id: Option<&str>, + ) -> step_command::StepLaunchContext { + let (tool, model) = if let Some(ref provider) = options.provider { + (provider.tool.clone(), provider.model.clone()) + } else { + let default_tool = self + .config + .llm_tools + .detected + .first() + .map_or_else(|| "claude".to_string(), |t| t.name.clone()); + let default_model = + get_default_model(&self.config).unwrap_or_else(|| "sonnet".to_string()); + (default_tool, default_model) + }; + step_command::StepLaunchContext { + delegator: options.delegator_name.clone(), + tool, + model, + yolo: options.yolo_mode, + session_id: session_id.map(str::to_string), + opr8r: step_command::resolve_opr8r_invocation(options.is_docker()), + operator_relay: options.operator_relay, + extra_flags: options.extra_flags.clone(), + } + } + pub async fn prepare_launch( &self, ticket: &Ticket, @@ -855,6 +958,11 @@ impl Launcher { "OPERATOR_UI_PORT".to_string(), self.config.rest_api.port.to_string(), ); + // Explicit API URL so opr8r never depends on api-session.json discovery + env_vars.insert( + "OPERATOR_API_URL".to_string(), + format!("http://127.0.0.1:{}", self.config.rest_api.port), + ); // Store the session UUID in the ticket file (now in in-progress) let ticket_in_progress_path = self @@ -943,9 +1051,27 @@ impl Launcher { llm_cmd = format!("{} {}", llm_cmd, options.extra_flags.join(" ")); } - // Wrap in docker command if docker mode is enabled - if options.docker_mode { - llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + if step_command::chain_step(&self.config, &ticket, &step_name) { + llm_cmd = + step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); + } + + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + &self.config, + &llm_cmd, + &working_dir_str, + &options.target, + options.provider.as_ref().map(|p| &p.env), + )?; + + // Strip Coder session tokens from the client-executed command's env + let token_envs = crate::config::coder_token_envs(&self.config); + if !token_envs.is_empty() { + llm_cmd = format!("unset {}; {llm_cmd}", token_envs.join(" ")); } // Determine tool name from options or default @@ -987,6 +1113,23 @@ impl Launcher { state.update_agent_step(&agent_id, &ticket.step)?; } + state.update_agent_target_name(&agent_id, &options.target.name)?; + + // Persist the launch context for multi-step exec-chain transitions + state.update_agent_step_launch_context( + &agent_id, + step_command::StepLaunchContext { + delegator: options.delegator_name.clone(), + tool: tool_name.clone(), + model, + yolo: options.yolo_mode, + session_id: Some(session_uuid.clone()), + opr8r, + operator_relay: options.operator_relay, + extra_flags: options.extra_flags.clone(), + }, + )?; + tracing::info!( terminal = %terminal_name, session_uuid = %session_uuid, @@ -1113,6 +1256,11 @@ impl Launcher { "OPERATOR_UI_PORT".to_string(), self.config.rest_api.port.to_string(), ); + // Explicit API URL so opr8r never depends on api-session.json discovery + env_vars.insert( + "OPERATOR_API_URL".to_string(), + format!("http://127.0.0.1:{}", self.config.rest_api.port), + ); // Store the session UUID in the ticket file let ticket_in_progress_path = self @@ -1212,9 +1360,27 @@ impl Launcher { ); } - // Wrap in docker command if docker mode is enabled - if options.launch_options.docker_mode { - llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + if step_command::chain_step(&self.config, &ticket, &step_name) { + llm_cmd = + step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); + } + + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + &self.config, + &llm_cmd, + &working_dir_str, + &options.launch_options.target, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; + + // Strip Coder session tokens from the client-executed command's env + let token_envs = crate::config::coder_token_envs(&self.config); + if !token_envs.is_empty() { + llm_cmd = format!("unset {}; {llm_cmd}", token_envs.join(" ")); } // Determine tool name from options or default @@ -1257,6 +1423,23 @@ impl Launcher { state.update_agent_step(&agent_id, &ticket.step)?; } + state.update_agent_target_name(&agent_id, &options.launch_options.target.name)?; + + // Persist the launch context for multi-step exec-chain transitions + state.update_agent_step_launch_context( + &agent_id, + step_command::StepLaunchContext { + delegator: options.launch_options.delegator_name.clone(), + tool: tool_name.clone(), + model, + yolo: options.launch_options.yolo_mode, + session_id: Some(session_uuid.clone()), + opr8r, + operator_relay: options.launch_options.operator_relay, + extra_flags: options.launch_options.extra_flags.clone(), + }, + )?; + tracing::info!( terminal = %terminal_name, session_uuid = %session_uuid, @@ -1292,6 +1475,7 @@ impl Launcher { /// Used when a tmux session died but the ticket is still in progress. /// Can optionally resume from an existing Claude session ID. pub async fn relaunch(&self, ticket: &Ticket, options: RelaunchOptions) -> Result { + let mut options = options; // Clone ticket so we can update worktree info if needed let mut ticket = ticket.clone(); @@ -1361,13 +1545,16 @@ impl Launcher { // Remote relaunches preflight too: the wrapper is regenerated and the // remote session reattached, so the host must still be reachable. - if let Some(ref host) = options.launch_options.remote_host { + self.provision_target(&ticket, &working_dir_str, &mut options.launch_options) + .await?; + + if let Some(host) = options.launch_options.remote_host() { let tool = options .launch_options .provider .as_ref() .map_or("claude", |p| p.tool.as_str()); - remote::run_preflight(host, tool)?; + remote::run_preflight(&host, tool)?; } // Dispatch based on session wrapper type @@ -1466,7 +1653,7 @@ impl Launcher { } // Store remote host so the dashboard can annotate the agent - if let Some(ref host) = options.launch_options.remote_host { + if let Some(host) = options.launch_options.remote_host() { state.update_agent_remote_host(&agent_id, &host.name)?; } @@ -1475,6 +1662,17 @@ impl Launcher { state.update_agent_step(&agent_id, &ticket.step)?; } + state.update_agent_target_name(&agent_id, &options.launch_options.target.name)?; + + // Persist the launch context for multi-step exec-chain transitions + state.update_agent_step_launch_context( + &agent_id, + self.step_launch_context_for( + &options.launch_options, + options.resume_session_id.as_deref(), + ), + )?; + // Send notification if self.config.notifications.enabled && self.config.notifications.on_agent_start { let mode_suffix = if options.resume_session_id.is_some() { diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index 471b8830..e29f35f0 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -1,6 +1,6 @@ //! Launch and relaunch options for agent sessions -use crate::config::LlmProvider; +use crate::config::{LlmProvider, TargetDef, TargetKind}; /// Launch options for starting an agent with specific provider and mode settings #[derive(Debug, Clone, Default)] @@ -11,8 +11,8 @@ pub struct LaunchOptions { pub delegator_name: Option, /// Additional CLI flags from delegator `launch_config` pub extra_flags: Vec, - /// Run in docker container - pub docker_mode: bool, + /// Resolved execution target for the agent process (default: local) + pub target: TargetDef, /// Run in YOLO (auto-accept) mode pub yolo_mode: bool, /// Override project path (if None, use ticket's project) @@ -31,18 +31,94 @@ pub struct LaunchOptions { pub session_suffix: Option, /// Enable relay MCP server injection for this launch (None = use global config) pub operator_relay: Option, - /// Resolved remote host to launch the agent CLI on over SSH (None = local). - pub remote_host: Option, + /// Provisioned remote host for coder targets (set by the launcher after + /// workspace provisioning; None until then and for non-coder targets) + pub provisioned_host: Option, + /// `OPERATOR_API_URL` override for remote launches (a coder target's + /// control-plane-reachable `callback_url`); None = reverse-tunnel default + pub api_url_override: Option, +} + +/// Persisted launch-mode kind, derived from the resolved execution target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LaunchModeKind { + #[default] + Local, + Docker, + Coder, + Ssh, +} + +impl LaunchModeKind { + fn base(self) -> &'static str { + match self { + Self::Local => "default", + Self::Docker => "docker", + Self::Coder => "coder", + Self::Ssh => "ssh", + } + } +} + +/// A `launch_mode` string parsed back into structured form. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ParsedLaunchMode { + pub kind: LaunchModeKind, + pub yolo: bool, +} + +/// Parse a persisted `launch_mode` string. Legacy values ("default", "yolo", +/// "docker", "docker-yolo") and unknown strings all parse — old state files +/// predate the coder/ssh vocabulary. +pub fn parse_launch_mode(s: &str) -> ParsedLaunchMode { + let (base, yolo) = match s.strip_suffix("-yolo") { + Some(base) => (base, true), + None if s == "yolo" => ("default", true), + None => (s, false), + }; + let kind = match base { + "docker" => LaunchModeKind::Docker, + "coder" => LaunchModeKind::Coder, + "ssh" => LaunchModeKind::Ssh, + _ => LaunchModeKind::Local, + }; + ParsedLaunchMode { kind, yolo } } impl LaunchOptions { - /// Get the launch mode string for state tracking + /// The launch-mode kind for this launch's resolved target. + pub fn launch_mode_kind(&self) -> LaunchModeKind { + match self.target.kind { + TargetKind::Local => LaunchModeKind::Local, + TargetKind::Docker(_) => LaunchModeKind::Docker, + TargetKind::Coder(_) => LaunchModeKind::Coder, + TargetKind::Ssh(_) => LaunchModeKind::Ssh, + } + } + + /// Whether this launch wraps the command in a docker container. + pub fn is_docker(&self) -> bool { + matches!(self.target.kind, TargetKind::Docker(_)) + } + + /// The remote host this launch runs on, if any: the provisioned coder + /// workspace when set, else an ssh target's declared host. + pub fn remote_host(&self) -> Option { + self.provisioned_host + .clone() + .or_else(|| self.target.as_remote_host()) + } + + /// Get the launch mode string for state tracking — the single derivation + /// point for the persisted vocabulary: + /// `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]`. pub fn launch_mode_string(&self) -> String { - match (self.docker_mode, self.yolo_mode) { - (true, true) => "docker-yolo".to_string(), - (true, false) => "docker".to_string(), - (false, true) => "yolo".to_string(), - (false, false) => "default".to_string(), + let kind = self.launch_mode_kind(); + match (kind, self.yolo_mode) { + (LaunchModeKind::Local, false) => "default".to_string(), + (LaunchModeKind::Local, true) => "yolo".to_string(), + (kind, false) => kind.base().to_string(), + (kind, true) => format!("{}-yolo", kind.base()), } } } @@ -50,6 +126,45 @@ impl LaunchOptions { #[cfg(test)] mod tests { use super::*; + use crate::config::{CoderConfig, DockerConfig, SshTarget}; + + fn target(kind: TargetKind) -> TargetDef { + TargetDef { + name: "t".to_string(), + display_name: None, + kind, + } + } + + fn options_with(kind: TargetKind, yolo: bool) -> LaunchOptions { + LaunchOptions { + target: target(kind), + yolo_mode: yolo, + ..Default::default() + } + } + + fn coder_config() -> CoderConfig { + CoderConfig { + template: "operator-agent".to_string(), + url_env: "CODER_URL".to_string(), + token_env: "CODER_SESSION_TOKEN".to_string(), + name_prefix: "op".to_string(), + workdir: None, + stop_on_complete: true, + create_timeout_secs: 300, + callback_url: None, + parameters: std::collections::HashMap::new(), + } + } + + fn ssh_target() -> SshTarget { + SshTarget { + ssh_alias: "gpu".to_string(), + workdir: "/proj".to_string(), + ssh_config_path: None, + } + } #[test] fn test_launch_options_carries_operator_relay() { @@ -57,7 +172,7 @@ mod tests { provider: None, delegator_name: None, extra_flags: vec![], - docker_mode: false, + target: TargetDef::local(), yolo_mode: false, project_override: None, use_worktrees_override: None, @@ -66,10 +181,88 @@ mod tests { prompt_suffix: None, session_suffix: None, operator_relay: Some(false), - remote_host: None, + provisioned_host: None, + api_url_override: None, }; assert_eq!(opts.operator_relay, Some(false)); } + + #[test] + fn test_launch_mode_string_all_kinds() { + let cases = [ + (TargetKind::Local, false, "default"), + (TargetKind::Local, true, "yolo"), + (TargetKind::Docker(DockerConfig::default()), false, "docker"), + ( + TargetKind::Docker(DockerConfig::default()), + true, + "docker-yolo", + ), + (TargetKind::Coder(coder_config()), false, "coder"), + (TargetKind::Coder(coder_config()), true, "coder-yolo"), + (TargetKind::Ssh(ssh_target()), false, "ssh"), + (TargetKind::Ssh(ssh_target()), true, "ssh-yolo"), + ]; + for (kind, yolo, expected) in cases { + assert_eq!(options_with(kind, yolo).launch_mode_string(), expected); + } + } + + #[test] + fn test_launch_mode_roundtrip() { + let kinds = [ + TargetKind::Local, + TargetKind::Docker(DockerConfig::default()), + TargetKind::Coder(coder_config()), + TargetKind::Ssh(ssh_target()), + ]; + for kind in kinds { + for yolo in [false, true] { + let opts = options_with(kind.clone(), yolo); + let parsed = parse_launch_mode(&opts.launch_mode_string()); + assert_eq!(parsed.kind, opts.launch_mode_kind()); + assert_eq!(parsed.yolo, yolo); + } + } + } + + #[test] + fn test_parse_launch_mode_legacy_strings() { + assert_eq!( + parse_launch_mode("default"), + ParsedLaunchMode { + kind: LaunchModeKind::Local, + yolo: false + } + ); + assert_eq!( + parse_launch_mode("yolo"), + ParsedLaunchMode { + kind: LaunchModeKind::Local, + yolo: true + } + ); + assert_eq!( + parse_launch_mode("docker"), + ParsedLaunchMode { + kind: LaunchModeKind::Docker, + yolo: false + } + ); + assert_eq!( + parse_launch_mode("docker-yolo"), + ParsedLaunchMode { + kind: LaunchModeKind::Docker, + yolo: true + } + ); + } + + #[test] + fn test_parse_launch_mode_unknown_is_local() { + assert_eq!(parse_launch_mode("garbage"), ParsedLaunchMode::default()); + assert_eq!(parse_launch_mode(""), ParsedLaunchMode::default()); + } } /// Options for relaunching an existing in-progress ticket diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index 5e4e3549..6d0722e8 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -24,15 +24,20 @@ pub struct OperatorEnvVars { impl OperatorEnvVars { /// Render shell `export` lines for all operator env vars. + /// + /// `OPERATOR_API_URL` is set explicitly (rather than relying on opr8r's + /// `api-session.json` fallback) so local step-completion reporting never + /// depends on disk-based discovery. pub fn to_export_block(&self) -> String { format!( - "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\n", + "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\nexport OPERATOR_API_URL=http://127.0.0.1:{}\n", shell_escape(&self.agent_id), shell_escape(&self.ticket_id), shell_escape(&self.project), shell_escape(&self.step), shell_escape(&self.ui_url), self.ui_port, + self.ui_port, ) } @@ -213,8 +218,19 @@ pub fn write_command_file( .map(OperatorEnvVars::to_pane_title_line) .unwrap_or_default(); + // Strip Coder session tokens from the agent's environment on every target + // kind — Operator needs them to drive the control plane; no agent CLI does. + let strip_block = { + let names = crate::config::coder_token_envs(config); + if names.is_empty() { + String::new() + } else { + format!("unset {}\n", names.join(" ")) + } + }; + let script_content = format!( - "#!/bin/bash\n{env_block}{provider_block}{pane_title}cd {}\nexec {}\n", + "#!/bin/bash\n{env_block}{provider_block}{strip_block}{pane_title}cd {}\nexec {}\n", shell_escape(project_path), llm_command ); @@ -270,6 +286,21 @@ fn is_shell_var_reference(value: &str) -> bool { .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) } +/// Shell-escape only when the value contains characters outside a +/// conservative safe set. Keeps common paths, mount specs, and KEY=VALUE +/// pairs readable in generated command files. +pub(crate) fn shell_escape_if_needed(s: &str) -> String { + let safe = !s.is_empty() + && s.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':' | '=' | ',' | '@') + }); + if safe { + s.to_string() + } else { + shell_escape(s) + } +} + /// Escape a string for safe use in shell command pub fn shell_escape(s: &str) -> String { // Use single quotes and escape any single quotes within @@ -281,6 +312,41 @@ pub fn shell_escape(s: &str) -> String { mod tests { use super::*; + #[test] + fn test_command_file_strips_coder_token_env_on_local_target() { + // The token variable is stripped from EVERY agent spawn environment, + // including Local launches — the likeliest exposure is a local agent + // inside the operator's own Coder workspace. + let temp = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.tickets = temp.path().to_string_lossy().to_string(); + config.targets.push(crate::config::TargetDef { + name: "cloud".to_string(), + display_name: None, + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: "t".to_string(), + url_env: "CODER_URL".to_string(), + token_env: "CODER_SESSION_TOKEN".to_string(), + name_prefix: "op".to_string(), + workdir: None, + stop_on_complete: true, + create_timeout_secs: 300, + callback_url: None, + parameters: std::collections::HashMap::new(), + }), + }); + + let path = write_command_file(&config, "uuid-1", "/proj", "claude", None, None).unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + assert!( + content.contains("unset CODER_SESSION_TOKEN\n"), + "token env must be unset before exec: {content}" + ); + let unset_pos = content.find("unset CODER_SESSION_TOKEN").unwrap(); + let exec_pos = content.find("exec ").unwrap(); + assert!(unset_pos < exec_pos, "unset must precede exec"); + } + #[test] fn test_shell_escape_simple() { assert_eq!(shell_escape("hello"), "'hello'"); diff --git a/src/agents/launcher/remote.rs b/src/agents/launcher/remote.rs index 12683a92..9830dca5 100644 --- a/src/agents/launcher/remote.rs +++ b/src/agents/launcher/remote.rs @@ -13,10 +13,19 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::config::{Config, RemoteHost}; -use crate::llm::tool_config::load_all_tool_configs; +use crate::llm::tool_config::{load_all_tool_configs, user_tools_dir}; use super::prompt::shell_escape; +/// `-F ` fragment for hosts carrying a provisioned ssh config +/// (coder aliases); empty for plain `~/.ssh/config` hosts. +fn ssh_config_flag(host: &RemoteHost) -> String { + host.ssh_config_path + .as_ref() + .map(|p| format!("-F {} ", shell_escape(p))) + .unwrap_or_default() +} + /// Remote path the prompt file is shipped to. pub(crate) fn remote_prompt_path(host: &RemoteHost, session_uuid: &str) -> String { format!( @@ -35,11 +44,11 @@ pub(crate) fn remote_payload_path(host: &RemoteHost, session_uuid: &str) -> Stri /// Build the agent CLI command executed on the remote host. /// -/// Uses the *embedded* tool config template (bare tool name, resolved via the -/// remote PATH) rather than the locally detected binary path, which would be +/// Uses the *loaded* tool config template — builtin or user-provided (see +/// `crate::llm::tool_config`) — with the bare tool name, resolved via the +/// remote PATH, rather than the locally detected binary path, which would be /// wrong on the remote machine. `{{config_flags}}` is dropped: permission -/// translation, MCP config, and statusline all write local files with local -/// paths — an accepted v1 degradation. +/// translation, MCP config, and statusline all write local files pub(crate) fn build_remote_llm_command( tool_name: &str, model: &str, @@ -52,11 +61,34 @@ pub(crate) fn build_remote_llm_command( .into_iter() .find(|t| t.tool_name == tool_name) .ok_or_else(|| { + let tools_dir = user_tools_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "~/.config/operator/tools".to_string()); anyhow::anyhow!( - "LLM tool '{tool_name}' has no embedded tool config; remote launch supports claude/codex/gemini" + "LLM tool '{tool_name}' has no tool config; builtins are claude/codex/gemini — \ + add a JSON under {tools_dir} to support others" ) })?; + Ok(build_remote_llm_command_from( + &tool, + model, + session_id, + remote_prompt, + yolo, + extra_flags, + )) +} + +fn build_remote_llm_command_from( + tool: &crate::llm::tool_config::ToolConfig, + model: &str, + session_id: &str, + remote_prompt: &str, + yolo: bool, + extra_flags: &[String], +) -> String { + let tool_name = tool.tool_name.as_str(); let model_flag = if tool.arg_mapping.model.is_empty() { String::new() } else { @@ -82,7 +114,7 @@ pub(crate) fn build_remote_llm_command( cmd = format!("{cmd} {}", extra_flags.join(" ")); } - Ok(cmd) + cmd } /// Build the local wrapper script content: ship prompt + payload over SSH, @@ -105,6 +137,7 @@ pub(crate) fn build_remote_wrapper_script( api_port: u16, ) -> String { let alias = shell_escape(&host.ssh_alias); + let f_flag = ssh_config_flag(host); let r_prompt = remote_prompt_path(host, session_uuid); let r_payload = remote_payload_path(host, session_uuid); @@ -120,7 +153,8 @@ pub(crate) fn build_remote_wrapper_script( ); format!( - "#!/bin/bash\nset -e\nssh {alias} {mkdir}\nssh {alias} {cat_prompt} < {local_prompt}\nssh {alias} {cat_payload} < {local_payload}\nexec ssh -t -R {port}:localhost:{port} -o ExitOnForwardFailure=yes {alias} {tmux}\n", + "#!/bin/bash\nset -e\nssh {f}{alias} {mkdir}\nssh {f}{alias} {cat_prompt} < {local_prompt}\nssh {f}{alias} {cat_payload} < {local_payload}\nexec ssh -t {f}-R {port}:localhost:{port} -o ExitOnForwardFailure=yes {alias} {tmux}\n", + f = f_flag, alias = alias, mkdir = shell_escape(&mkdir_cmd), cat_prompt = shell_escape(&format!("cat > {}", shell_escape(&r_prompt))), @@ -201,10 +235,13 @@ pub(crate) fn launch_remote_in_session( .as_ref() .map(|p| p.env.clone()) .unwrap_or_default(); - provider_env.insert( - "OPERATOR_API_URL".to_string(), - format!("http://localhost:{}", operator_env.ui_port), - ); + // Tunnel default; a coder target's control-plane-reachable callback_url + // overrides it so detached multi-step survives tunnel loss. + let api_url = options + .api_url_override + .clone() + .unwrap_or_else(|| format!("http://localhost:{}", operator_env.ui_port)); + provider_env.insert("OPERATOR_API_URL".to_string(), api_url); let payload_file = super::prompt::write_command_file( config, @@ -265,7 +302,11 @@ fn preflight_script(host: &RemoteHost, tool_name: &str) -> String { /// reachable over SSH (`BatchMode` so a password prompt can't wedge the TUI), /// tmux and the tool on the remote PATH, and the workdir present. pub(crate) fn run_preflight(host: &RemoteHost, tool_name: &str) -> Result<()> { - let status = std::process::Command::new("ssh") + let mut cmd = std::process::Command::new("ssh"); + if let Some(ref frag) = host.ssh_config_path { + cmd.args(["-F", frag]); + } + let status = cmd .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]) .arg(&host.ssh_alias) .arg(preflight_script(host, tool_name)) @@ -305,6 +346,7 @@ mod tests { ssh_alias: "gpu-alias".to_string(), workdir: "/srv/agents/proj".to_string(), display_name: None, + ssh_config_path: None, } } @@ -358,8 +400,40 @@ mod tests { #[test] fn test_build_remote_llm_command_unknown_tool_errors() { - let err = build_remote_llm_command("agy", "m", "s", "/p", false, &[]).unwrap_err(); - assert!(err.to_string().contains("no embedded tool config")); + let err = + build_remote_llm_command("op-test-tool-does-not-exist", "m", "s", "/p", false, &[]) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no tool config"), "got: {msg}"); + assert!(msg.contains("tools"), "points at the user tools dir: {msg}"); + } + + #[test] + fn test_build_remote_llm_command_runtime_tool() { + // A non-builtin tool config (as loaded from ~/.config/operator/tools) + let tool: crate::llm::tool_config::ToolConfig = serde_json::from_str( + r#"{ + "tool_name": "mux", + "version_command": "mux --version", + "capabilities": { "supports_sessions": true, "supports_headless": true }, + "model_aliases": ["default"], + "arg_mapping": { "prompt": "", "model": "--model" }, + "command_template": "mux {{model_flag}}--resume {{session_id}} \"$(cat {{prompt_file}})\"", + "yolo_flags": ["--yes"] + }"#, + ) + .unwrap(); + + let cmd = build_remote_llm_command_from(&tool, "m1", "uuid-1", "/remote/p.txt", true, &[]); + assert!(cmd.starts_with("mux "), "bare tool name, got: {cmd}"); + assert!(cmd.contains("--model m1")); + assert!(cmd.contains("--resume uuid-1")); + assert!(cmd.contains("/remote/p.txt")); + assert!(cmd.contains("--yes")); + assert!( + !cmd.contains("{{"), + "all template variables substituted: {cmd}" + ); } #[test] @@ -409,6 +483,28 @@ mod tests { assert!(script.contains("agent workdir")); } + #[test] + fn test_wrapper_passes_ssh_config_fragment_and_keeps_tunnel() { + let mut h = host(); + h.ssh_config_path = Some("/local/.tickets/operator/ssh/ws.config".to_string()); + let script = build_remote_wrapper_script( + &h, + "op-FEAT-1", + "u1", + Path::new("/l/p.txt"), + Path::new("/l/c.sh"), + 7008, + ); + assert!( + script.contains("-F '/local/.tickets/operator/ssh/ws.config'"), + "provisioned fragment must be passed with -F: {script}" + ); + assert!( + script.contains("exec ssh -t -F '/local/.tickets/operator/ssh/ws.config' -R 7008:localhost:7008"), + "-R reverse tunnel must be preserved alongside -F (ProxyCommand is transport only): {script}" + ); + } + #[test] fn test_preflight_script_distinct_exit_codes() { let s = preflight_script(&host(), "claude"); diff --git a/src/agents/launcher/step_command.rs b/src/agents/launcher/step_command.rs new file mode 100644 index 00000000..291efb92 --- /dev/null +++ b/src/agents/launcher/step_command.rs @@ -0,0 +1,539 @@ +//! Step-command construction for the multi-step exec chain. +//! +//! One builder produces the `opr8r --ticket-id … --step … -- ` wrapper +//! for both the first step (launcher) and subsequent steps (`complete_step` +//! route). The returned command is always the INNER command — target wrapping +//! (docker, remote) is applied once, to the outermost launch, by the launcher; +//! `exec()` transitions happen inside the already-wrapped environment. + +use anyhow::Result; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::config::Config; +use crate::queue::Ticket; +use crate::templates::schema::StepSchema; + +use super::interpolation::PromptInterpolator; +use super::llm_command::{ + apply_yolo_flags, build_llm_command_with_permissions_for_tool, locate_opr8r_binary, +}; +use super::prompt::{generate_session_uuid, shell_escape_if_needed, write_prompt_file}; + +/// Launch context fixed at launch time, persisted with the agent record, and +/// read back by `complete_step` to build subsequent step commands. +/// +/// The persisted context is the baseline for a ticket's whole chain; per-step +/// `agent` overrides from the step schema apply on top for that step only. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct StepLaunchContext { + /// Delegator name used at launch (None = ad-hoc provider/model) + #[serde(default)] + pub delegator: Option, + /// Resolved LLM tool (e.g. "claude") + pub tool: String, + /// Resolved model alias (e.g. "sonnet") + pub model: String, + /// YOLO (auto-accept) mode + pub yolo: bool, + /// Session UUID of the step launched with this context (informational; + /// each transition mints a fresh UUID for the next step) + #[serde(default)] + pub session_id: Option, + /// opr8r invocation for the launch environment ("opr8r" inside a + /// container where the image ships it on PATH, an absolute path locally). + /// Steps exec inside the same environment, so the value holds chain-wide. + pub opr8r: String, + /// Relay MCP injection override from the delegator launch config + #[serde(default)] + pub operator_relay: Option, + /// Extra CLI flags from the delegator launch config + #[serde(default)] + pub extra_flags: Vec, +} + +/// A step command plus the session UUID minted for it. The caller persists the +/// UUID (ticket `session_ids`, agent state) — building is side-effect-free +/// with respect to ticket and state files. +#[derive(Debug)] +pub struct BuiltStepCommand { + pub command: String, + pub session_id: String, +} + +/// Build the full `opr8r`-wrapped command for one step. +/// +/// Renders the step prompt (issuetype + step + ticket + previous-step +/// context), writes the prompt file, builds the tool command with permission +/// config flags, applies YOLO and delegator flags, and wraps in the opr8r +/// step wrapper. Never applies target wrapping (see module docs). +pub fn build_step_command( + config: &Config, + ticket: &Ticket, + step: &StepSchema, + ctx: &StepLaunchContext, + project_path: &str, + previous_summary: Option<&str>, + previous_recommendation: Option<&str>, +) -> Result { + // Per-step agent override: a step naming a delegator switches tool/model + // for that step only; the persisted context stays the chain baseline. + let override_pair = + crate::templates::step_type::effective_agent(step).map(|agent_name| { + config.delegators.iter().find(|d| d.name == agent_name).map_or_else( + || { + tracing::warn!( + step = %step.name, + agent = %agent_name, + "Step agent override names no configured delegator; using launch context" + ); + (ctx.tool.clone(), ctx.model.clone()) + }, + |d| (d.llm_tool.clone(), d.model.clone()), + ) + }); + let (tool, model) = override_pair.unwrap_or_else(|| (ctx.tool.clone(), ctx.model.clone())); + + let session_id = generate_session_uuid(); + + // Render the prompt against a ticket positioned on this step. + let mut step_ticket = ticket.clone(); + step_ticket.step = step.name.clone(); + let interpolator = PromptInterpolator::new(); + let prompt = interpolator.build_launch_prompt_with_context( + config, + &step_ticket, + project_path, + previous_summary, + previous_recommendation, + )?; + let prompt_file = write_prompt_file(config, &session_id, &prompt)?; + + let mut llm_cmd = build_llm_command_with_permissions_for_tool( + config, + &tool, + &model, + &session_id, + &prompt_file, + Some(&step_ticket), + Some(project_path), + ctx.operator_relay, + )?; + + if ctx.yolo { + llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool); + } + + if !ctx.extra_flags.is_empty() { + llm_cmd = format!("{} {}", llm_cmd, ctx.extra_flags.join(" ")); + } + + let command = wrap_step(&ctx.opr8r, &ticket.id, &step.name, &session_id, &llm_cmd); + + Ok(BuiltStepCommand { + command, + session_id, + }) +} + +/// Wrap an already-built inner LLM command in the opr8r step wrapper. +/// Used directly by the launcher for step one, where the inner command has +/// already been constructed by the session backend. +/// +/// Ticket ids, step names, and session UUIDs satisfy strict grammars and are +/// passed raw; only the opr8r path can contain shell-hostile characters. +pub fn wrap_step( + opr8r: &str, + ticket_id: &str, + step_name: &str, + session_id: &str, + inner_cmd: &str, +) -> String { + format!( + "{} --ticket-id={ticket_id} --step={step_name} --session-id={session_id} -- {inner_cmd}", + shell_escape_if_needed(opr8r), + ) +} + +/// Whether a launch should participate in the exec chain: true when the +/// workspace issuetype registry defines the step being launched. +/// +/// Resolves against the same catalog the `complete_step` route uses +/// (`startup::templates::load_registry`) rather than the embedded builtins, so +/// collection-installed and custom issue types chain too, and an override of a +/// builtin key follows one graph on both sides. Schema-less tickets (step +/// "initial") launch unwrapped, exactly as before. +/// +/// Called once per launch, alongside far heavier worktree/session setup. +pub fn chain_step(config: &Config, ticket: &Ticket, step_name: &str) -> bool { + let registry = crate::startup::templates::load_registry(&config.tickets_path()); + registry + .get(&ticket.ticket_type.to_uppercase()) + .is_some_and(|t| t.get_step(step_name).is_some()) +} + +/// Resolve the opr8r invocation for the launch environment. +/// +/// Inside a container the image ships `opr8r` on PATH; locally the binary +/// sits alongside `operator` (or on PATH as a fallback). +pub fn resolve_opr8r_invocation(containerized: bool) -> String { + if containerized { + return "opr8r".to_string(); + } + locate_opr8r_binary().map_or_else(|| "opr8r".to_string(), |p| p.display().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Delegator, DetectedTool}; + + fn make_detected_tool(name: &str) -> DetectedTool { + DetectedTool { + name: name.to_string(), + path: format!("/usr/bin/{name}"), + version: "1.0.0".to_string(), + min_version: Some("1.0.0".to_string()), + version_ok: true, + model_aliases: vec!["sonnet".to_string()], + command_template: format!( + "{name} {{{{config_flags}}}}{{{{model_flag}}}}--session-id {{{{session_id}}}} --print-prompt-path {{{{prompt_file}}}}" + ), + capabilities: crate::config::ToolCapabilities { + supports_sessions: true, + supports_headless: true, + }, + yolo_flags: vec!["--dangerously-skip-permissions".to_string()], + health_ok: true, + } + } + + fn make_config(temp_dir: &std::path::Path) -> Config { + let mut config = Config::default(); + config.paths.tickets = temp_dir.join(".tickets").to_string_lossy().to_string(); + config.llm_tools.detected = + vec![make_detected_tool("claude"), make_detected_tool("gemini")]; + config + } + + /// FEAT tickets use the embedded FEAT schema: plan -> build -> code, + /// where "code" carries agent: "claude-opus". + fn make_feat_ticket(temp_dir: &std::path::Path, step: &str) -> Ticket { + let content = format!( + "---\nid: FEAT-9001\nstatus: running\nstep: {step}\n---\n\n# Feature: step command test\n" + ); + let path = temp_dir.join("20260807-1200-FEAT-operator-stepcmd.md"); + std::fs::write(&path, content).unwrap(); + Ticket::from_file(&path).unwrap() + } + + fn make_ctx() -> StepLaunchContext { + StepLaunchContext { + delegator: Some("primary".to_string()), + tool: "claude".to_string(), + model: "sonnet".to_string(), + yolo: false, + session_id: None, + opr8r: "opr8r".to_string(), + operator_relay: Some(false), + extra_flags: vec![], + } + } + + fn build_step(step_name: &str) -> StepSchema { + // Pull the real step from the embedded FEAT schema so agent + // overrides and prompts match production data. + let temp = tempfile::tempdir().unwrap(); + let ticket = make_feat_ticket(temp.path(), step_name); + ticket + .template_schema() + .and_then(|t| t.get_step(step_name).cloned()) + .expect("embedded FEAT schema step") + } + + #[test] + fn test_build_step_command_includes_model_flag() { + let temp = tempfile::tempdir().unwrap(); + let config = make_config(temp.path()); + let ticket = make_feat_ticket(temp.path(), "plan"); + let step = build_step("build"); + let ctx = make_ctx(); + + let built = build_step_command( + &config, + &ticket, + &step, + &ctx, + temp.path().to_str().unwrap(), + None, + None, + ) + .unwrap(); + + assert!( + built.command.contains("--model sonnet"), + "model from the launch context must reach the command: {}", + built.command + ); + assert!( + built.command.contains("--ticket-id=FEAT-9001"), + "opr8r wrapper must carry the ticket id: {}", + built.command + ); + assert!( + built.command.contains("--step=build"), + "opr8r wrapper must carry the step name: {}", + built.command + ); + assert!( + built + .command + .contains(&format!("--session-id={}", built.session_id)), + "opr8r wrapper must carry the minted session id: {}", + built.command + ); + } + + #[test] + fn test_build_step_command_applies_yolo_flags() { + let temp = tempfile::tempdir().unwrap(); + let config = make_config(temp.path()); + let ticket = make_feat_ticket(temp.path(), "plan"); + let step = build_step("build"); + let mut ctx = make_ctx(); + ctx.yolo = true; + + let built = build_step_command( + &config, + &ticket, + &step, + &ctx, + temp.path().to_str().unwrap(), + None, + None, + ) + .unwrap(); + + assert!( + built.command.contains("--dangerously-skip-permissions"), + "YOLO flags must be present when the context sets them: {}", + built.command + ); + } + + #[test] + fn test_build_step_command_honors_step_agent_override() { + let temp = tempfile::tempdir().unwrap(); + let mut config = make_config(temp.path()); + // The FEAT "code" step names agent "claude-opus"; register a + // delegator by that name pointing at a different tool + model. + config.delegators = vec![Delegator { + name: "claude-opus".to_string(), + llm_tool: "gemini".to_string(), + model: "gemini-pro".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: None, + model_server: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }]; + let ticket = make_feat_ticket(temp.path(), "build"); + let step = build_step("code"); + let ctx = make_ctx(); + + let built = build_step_command( + &config, + &ticket, + &step, + &ctx, + temp.path().to_str().unwrap(), + None, + None, + ) + .unwrap(); + + assert!( + built.command.contains("gemini") && built.command.contains("--model gemini-pro"), + "SwitchAgent step must override tool and model for that step: {}", + built.command + ); + } + + #[test] + fn test_build_step_command_unknown_override_falls_back_to_context() { + let temp = tempfile::tempdir().unwrap(); + let config = make_config(temp.path()); // no delegators configured + let ticket = make_feat_ticket(temp.path(), "build"); + let step = build_step("code"); // names agent "claude-opus" + let ctx = make_ctx(); + + let built = build_step_command( + &config, + &ticket, + &step, + &ctx, + temp.path().to_str().unwrap(), + None, + None, + ) + .unwrap(); + + assert!( + built.command.contains("--model sonnet"), + "unknown step agent must fall back to the launch context: {}", + built.command + ); + } + + #[test] + fn test_next_command_has_no_docker_wrapper() { + // A docker-launched chain builds INNER commands only: exec() happens + // inside the container, so re-wrapping would nest containers. + let temp = tempfile::tempdir().unwrap(); + let mut config = make_config(temp.path()); + config.launch.docker.enabled = true; + config.launch.docker.image = "ghcr.io/untra/operator:test".to_string(); + let ticket = make_feat_ticket(temp.path(), "plan"); + let step = build_step("build"); + let ctx = make_ctx(); // opr8r = "opr8r", as a docker launch persists + + let built = build_step_command( + &config, + &ticket, + &step, + &ctx, + temp.path().to_str().unwrap(), + None, + None, + ) + .unwrap(); + + assert!( + !built.command.contains("docker run"), + "next_command must never include the target wrapper: {}", + built.command + ); + assert!( + built.command.starts_with("opr8r "), + "next_command must start with the opr8r wrapper: {}", + built.command + ); + } + + #[test] + fn test_next_command_uses_persisted_delegator_context() { + let temp = tempfile::tempdir().unwrap(); + let config = make_config(temp.path()); + let ticket = make_feat_ticket(temp.path(), "plan"); + let step = build_step("build"); // no agent override on "build" + let mut ctx = make_ctx(); + ctx.tool = "gemini".to_string(); + ctx.model = "gemini-flash".to_string(); + + let built = build_step_command( + &config, + &ticket, + &step, + &ctx, + temp.path().to_str().unwrap(), + None, + None, + ) + .unwrap(); + + assert!( + built.command.contains("gemini") && built.command.contains("--model gemini-flash"), + "second step must use the launching delegator's tool/model, not a default: {}", + built.command + ); + } + + #[test] + fn test_wrap_step_shape() { + let cmd = wrap_step( + "opr8r", + "FEAT-1", + "build", + "uuid-1", + "claude --model sonnet", + ); + assert_eq!( + cmd, + "opr8r --ticket-id=FEAT-1 --step=build --session-id=uuid-1 -- claude --model sonnet" + ); + } + + #[test] + fn test_chain_step_gates_on_schema() { + let temp = tempfile::tempdir().unwrap(); + let config = make_config(temp.path()); + let ticket = make_feat_ticket(temp.path(), "plan"); + assert!(chain_step(&config, &ticket, "plan")); + assert!(!chain_step(&config, &ticket, "initial")); + } + + /// A non-builtin issue type installed into the workspace registry must + /// wrap too — otherwise a collection issuetype's chain never engages. + #[test] + fn test_chain_step_wraps_registry_only_issuetype() { + const GAST_JSON: &str = r#"{ + "key": "GAST", + "name": "Gastown", + "description": "A registry-only type", + "mode": "autonomous", + "glyph": "g", + "fields": [], + "steps": [{"name": "execute", "outputs": ["report"], "prompt": "Do it."}] + }"#; + + let temp = tempfile::tempdir().unwrap(); + let config = make_config(temp.path()); + let legacy = config.tickets_path().join("operator").join("issuetypes"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("gast.json"), GAST_JSON).unwrap(); + + let content = + "---\nid: GAST-9001\nstatus: running\nstep: execute\n---\n\n# Registry-only type\n"; + let path = temp.path().join("20260807-1200-GAST-operator-gast.md"); + std::fs::write(&path, content).unwrap(); + let ticket = Ticket::from_file(&path).unwrap(); + + assert_eq!(ticket.ticket_type, "GAST"); + assert!( + ticket.template_schema().is_none(), + "GAST must not be an embedded builtin, or the test proves nothing" + ); + assert!( + chain_step(&config, &ticket, "execute"), + "registry-resolved issue type must chain" + ); + assert!(!chain_step(&config, &ticket, "nonexistent")); + } + + #[test] + fn test_resolve_opr8r_invocation_containerized_uses_path_name() { + assert_eq!(resolve_opr8r_invocation(true), "opr8r"); + } + + #[test] + fn test_step_launch_context_roundtrip() { + let ctx = make_ctx(); + let json = serde_json::to_string(&ctx).unwrap(); + let back: StepLaunchContext = serde_json::from_str(&json).unwrap(); + assert_eq!(ctx, back); + } + + #[test] + fn test_step_launch_context_parses_without_optional_fields() { + // Old state files predate optional fields; they must still parse. + let json = r#"{"tool":"claude","model":"sonnet","yolo":false,"opr8r":"opr8r"}"#; + let ctx: StepLaunchContext = serde_json::from_str(json).unwrap(); + assert_eq!(ctx.delegator, None); + assert!(ctx.extra_flags.is_empty()); + } +} diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 7cf0f4ef..7a2d7727 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -51,6 +51,7 @@ fn make_test_config(temp_dir: &TempDir) -> Config { supports_headless: true, }, yolo_flags: vec!["--dangerously-skip-permissions".to_string()], + health_ok: true, }; Config { @@ -660,11 +661,12 @@ fn test_launch_in_tmux_remote_host_sends_wrapper_and_skips_relay() { .to_string_lossy() .to_string(); let options = LaunchOptions { - remote_host: Some(crate::config::RemoteHost { + target: crate::config::TargetDef::from_host(&crate::config::RemoteHost { name: "gpu-vm".to_string(), ssh_alias: "gpu-alias".to_string(), workdir: "/srv/agents/proj".to_string(), display_name: None, + ssh_config_path: None, }), ..Default::default() }; @@ -855,7 +857,7 @@ fn test_launch_in_tmux_docker_mode_wraps() { .to_string_lossy() .to_string(); let options = LaunchOptions { - docker_mode: true, + target: crate::config::TargetDef::docker(config.launch.docker.clone()), ..Default::default() }; @@ -883,6 +885,227 @@ fn test_launch_in_tmux_docker_mode_wraps() { ); } +/// FEAT ticket positioned on "plan", the first step of the embedded FEAT +/// schema — satisfies `step_command::chain_step` so the launch wraps in opr8r. +fn make_chain_ticket(project: &str) -> Ticket { + Ticket { + ticket_type: "FEAT".to_string(), + step: "plan".to_string(), + ..make_test_ticket(project) + } +} + +#[test] +fn test_launch_in_tmux_wraps_command_in_opr8r() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_chain_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + let options = LaunchOptions::default(); + + let result = launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + &make_test_operator_env(), + ); + + assert!(result.is_ok(), "Launch failed: {:?}", result.err()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name); + let sent_cmd = &keys_sent.unwrap()[0]; + + let script_content = + read_command_file_content(sent_cmd).expect("Should be able to read command file content"); + assert!( + script_content.contains("--ticket-id="), + "Command file should carry the opr8r ticket id flag, got: {script_content}" + ); + assert!( + script_content.contains("--step="), + "Command file should carry the opr8r step flag, got: {script_content}" + ); + assert!( + script_content.contains("--session-id="), + "Command file should carry the opr8r session id flag, got: {script_content}" + ); + assert!( + script_content.contains("-- claude"), + "Command file should separate the opr8r wrapper from the LLM command, got: {script_content}" + ); +} + +#[test] +fn test_launch_in_tmux_docker_wrap_is_outermost() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config_with_docker(&temp_dir, "my-claude:latest"); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_chain_ticket("test-project"); + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + let options = LaunchOptions { + target: crate::config::TargetDef::docker(config.launch.docker.clone()), + ..Default::default() + }; + + let result = launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + &make_test_operator_env(), + ); + + assert!(result.is_ok(), "Launch failed: {:?}", result.err()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name); + let sent_cmd = &keys_sent.unwrap()[0]; + + let script_content = + read_command_file_content(sent_cmd).expect("Should be able to read command file content"); + let docker_idx = script_content + .find("docker run") + .expect("Command file should contain docker run"); + let opr8r_idx = script_content + .find("opr8r --ticket-id=") + .expect("Command file should contain the containerized opr8r invocation"); + assert!( + docker_idx < opr8r_idx, + "docker run must wrap OUTSIDE the opr8r invocation, got: {script_content}" + ); + + // The token immediately preceding "--ticket-id=" must be the bare literal + // "opr8r" (per resolve_opr8r_invocation(true)), not an absolute host path + // like "/Users/x/operator/opr8r" — `contains("opr8r --ticket-id=")` alone + // would match either, since a path only has safe characters and isn't + // quoted by escape_if_needed. + let before_ticket_id = script_content + .split(" --ticket-id=") + .next() + .expect("script should contain --ticket-id="); + let opr8r_token = before_ticket_id + .rsplit(char::is_whitespace) + .next() + .unwrap_or("") + .trim_matches('\''); + assert_eq!( + opr8r_token, "opr8r", + "containerized opr8r invocation must be the literal bare command, not an absolute host path: {script_content}" + ); +} + +/// The launch context persisted for a new agent must carry the session uuid +/// the backend actually minted, so `complete_step` can match this agent by +/// session id on the very first transition. +#[test] +fn test_launched_session_uuid_returns_backend_minted_uuid() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = make_chain_ticket("test-project"); + + // The backend records the uuid on the in-progress ticket file. + let in_progress = config + .tickets_path() + .join("in-progress") + .join(&ticket.filename); + std::fs::write( + &in_progress, + "---\nid: TASK-1234\nstatus: running\nstep: plan\n---\n\n# Chain ticket\n", + ) + .unwrap(); + assert!( + super::launched_session_uuid(&config, &ticket).is_none(), + "nothing minted before the launch" + ); + + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + let session_name = launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &LaunchOptions::default(), + &make_test_operator_env(), + ) + .expect("launch should succeed"); + + let keys_sent = mock.get_session_keys_sent(&session_name).unwrap(); + let script_content = read_command_file_content(&keys_sent[0]).expect("command file readable"); + + let uuid = super::launched_session_uuid(&config, &ticket) + .expect("the minted session uuid must be readable after launch"); + assert!( + script_content.contains(&format!("--session-id={uuid}")), + "readback uuid must be the one the backend wrapped the step with: {script_content}" + ); +} + +#[test] +fn test_launch_in_tmux_no_wrap_for_unknown_ticket_type() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let mock = Arc::new(MockTmuxClient::new()); + let tmux: Arc = mock.clone(); + let ticket = Ticket { + ticket_type: "UNKNOWNTYPE".to_string(), + ..make_test_ticket("test-project") + }; + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + let options = LaunchOptions::default(); + + let result = launch_in_tmux_with_options( + &config, + &tmux, + &ticket, + &project_path, + "Test prompt", + &options, + &make_test_operator_env(), + ); + + assert!(result.is_ok(), "Launch failed: {:?}", result.err()); + let session_name = result.unwrap(); + let keys_sent = mock.get_session_keys_sent(&session_name); + let sent_cmd = &keys_sent.unwrap()[0]; + + let script_content = + read_command_file_content(sent_cmd).expect("Should be able to read command file content"); + assert!( + !script_content.contains("--ticket-id="), + "Unknown ticket type must launch unwrapped (legacy path), got: {script_content}" + ); +} + #[test] fn test_launch_in_tmux_both_modes() { let temp_dir = TempDir::new().unwrap(); @@ -898,7 +1121,7 @@ fn test_launch_in_tmux_both_modes() { .to_string(); let options = LaunchOptions { yolo_mode: true, - docker_mode: true, + target: crate::config::TargetDef::docker(config.launch.docker.clone()), ..Default::default() }; @@ -947,6 +1170,7 @@ fn test_launch_in_tmux_uses_provider_from_options() { .to_string(), capabilities: crate::config::ToolCapabilities::default(), yolo_flags: vec![], + health_ok: true, }); let mock = Arc::new(MockTmuxClient::new()); let tmux: Arc = mock.clone(); @@ -1128,11 +1352,12 @@ fn test_relaunch_remote_resume_reuses_session_and_adds_resume_flag() { let options = RelaunchOptions { launch_options: LaunchOptions { - remote_host: Some(crate::config::RemoteHost { + target: crate::config::TargetDef::from_host(&crate::config::RemoteHost { name: "gpu-vm".to_string(), ssh_alias: "gpu-alias".to_string(), workdir: "/srv/agents/proj".to_string(), display_name: None, + ssh_config_path: None, }), ..Default::default() }, @@ -1237,7 +1462,7 @@ fn test_relaunch_inherits_docker_mode() { .to_string(); let options = RelaunchOptions { launch_options: LaunchOptions { - docker_mode: true, + target: crate::config::TargetDef::docker(config.launch.docker.clone()), ..Default::default() }, resume_session_id: None, @@ -1528,6 +1753,7 @@ fn test_launch_provider_from_delegator_determines_tool() { .to_string(), capabilities: crate::config::ToolCapabilities::default(), yolo_flags: vec!["--full-auto".to_string()], + health_ok: true, }); let mock = Arc::new(MockTmuxClient::new()); diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index 70f5fcc6..06abeb74 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -10,8 +10,8 @@ use crate::queue::Ticket; use super::interpolation::PromptInterpolator; use super::llm_command::{ - apply_yolo_flags, build_docker_command, build_llm_command_with_permissions_for_tool, - get_default_model, + apply_yolo_flags, build_llm_command_with_permissions_for_tool, get_default_model, + wrap_for_target, }; use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ @@ -19,6 +19,7 @@ use super::prompt::{ write_prompt_file, OperatorEnvVars, }; use super::remote::launch_remote_in_session; +use super::step_command; use super::SESSION_PREFIX; /// Launch Claude in a tmux session with specific options @@ -166,14 +167,14 @@ pub fn launch_in_tmux_with_options( // Remote launch: the local pane runs a wrapper that ships the prompt and // payload over SSH and execs into a remote tmux session through a reverse // tunnel. Relay injection is skipped (unix socket is local-only). - if let Some(ref host) = options.remote_host { + if let Some(host) = options.remote_host() { return launch_remote_in_session( config, ticket, &session_name, &session_uuid, &step_name, - host, + &host, &tool_name, &model, &prompt_file, @@ -204,16 +205,22 @@ pub fn launch_in_tmux_with_options( llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool_name); } - // Wrap in docker command if docker mode is enabled - if options.docker_mode { - llm_cmd = build_docker_command( - config, - &llm_cmd, - project_path, - options.provider.as_ref().map(|p| &p.env), - )?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + if step_command::chain_step(config, ticket, &step_name) { + let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + config, + &llm_cmd, + project_path, + &options.target, + options.provider.as_ref().map(|p| &p.env), + )?; + // Write the command to a shell script file to avoid issues with long commands // and special characters when using tmux send-keys let command_file = write_command_file( @@ -416,14 +423,14 @@ pub fn launch_in_tmux_with_relaunch_options( // Remote relaunch: regenerate the wrapper (idempotent, keyed by session // uuid); `tmux new -A` on the remote host reattaches a surviving session. - if let Some(ref host) = options.launch_options.remote_host { + if let Some(host) = options.launch_options.remote_host() { return launch_remote_in_session( config, ticket, &session_name, &session_uuid, &step_name, - host, + &host, &tool_name, &model, &prompt_file, @@ -464,16 +471,22 @@ pub fn launch_in_tmux_with_relaunch_options( llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool_name); } - // Wrap in docker command if docker mode is enabled - if options.launch_options.docker_mode { - llm_cmd = build_docker_command( - config, - &llm_cmd, - project_path, - options.launch_options.provider.as_ref().map(|p| &p.env), - )?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + if step_command::chain_step(config, ticket, &step_name) { + let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + config, + &llm_cmd, + project_path, + &options.launch_options.target, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; + // Write the command to a shell script file to avoid issues with long commands // and special characters when using tmux send-keys let command_file = write_command_file( diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 1eb46796..ed771529 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -14,14 +14,15 @@ use crate::queue::Ticket; use super::interpolation::PromptInterpolator; use super::llm_command::{ - apply_yolo_flags, build_docker_command, build_llm_command_with_permissions_for_tool, - get_default_model, + apply_yolo_flags, build_llm_command_with_permissions_for_tool, get_default_model, + wrap_for_target, }; use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, write_prompt_file, OperatorEnvVars, }; +use super::step_command; /// Result of launching in zellij — includes tab name for state tracking #[derive(Debug, Clone)] pub struct ZellijLaunchResult { @@ -132,15 +133,22 @@ pub fn launch_in_zellij_with_options( llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool_name); } - if options.docker_mode { - llm_cmd = build_docker_command( - config, - &llm_cmd, - project_path, - options.provider.as_ref().map(|p| &p.env), - )?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + if step_command::chain_step(config, ticket, &step_name) { + let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + config, + &llm_cmd, + project_path, + &options.target, + options.provider.as_ref().map(|p| &p.env), + )?; + // Write the command to a shell script file let command_file = write_command_file( config, @@ -305,15 +313,22 @@ pub fn launch_in_zellij_with_relaunch_options( llm_cmd = apply_yolo_flags(config, &llm_cmd, &tool_name); } - if options.launch_options.docker_mode { - llm_cmd = build_docker_command( - config, - &llm_cmd, - project_path, - options.launch_options.provider.as_ref().map(|p| &p.env), - )?; + // Wrap in the opr8r step wrapper when the issuetype defines this step, + // so completion reporting and exec-chain transitions engage. + if step_command::chain_step(config, ticket, &step_name) { + let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } + // Wrap for the resolved execution target (local = identity) + llm_cmd = wrap_for_target( + config, + &llm_cmd, + project_path, + &options.launch_options.target, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; + // Write and send command let command_file = write_command_file( config, diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 12f1238f..ff920978 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -10,8 +10,10 @@ mod generator; pub mod hooks; pub mod idle_detector; pub(crate) mod launcher; +pub use launcher::step_command::StepLaunchContext; mod monitor; mod pr_workflow; +mod proof_review; mod session; mod sync; pub mod terminal_wrapper; @@ -36,7 +38,10 @@ pub use generator::{ }; // Launcher -pub use launcher::{LaunchOptions, Launcher, PreparedLaunch, RelaunchOptions}; +pub use launcher::{ + parse_launch_mode, LaunchModeKind, LaunchOptions, Launcher, ParsedLaunchMode, PreparedLaunch, + RelaunchOptions, +}; // Artifact detection pub use artifact_detector::{ArtifactDetector, ArtifactStatus}; @@ -46,6 +51,7 @@ pub use monitor::{HealthCheckResult, ReconciliationResult, SessionMonitor}; // Workflows pub use pr_workflow::PrWorkflow; +pub use proof_review::{ProofResult, ProofRunner}; pub use session::Session; pub use sync::{SyncAction, SyncResult, TicketSessionSync}; pub use visual_review::{VisualReviewHandler, VisualReviewResult}; diff --git a/src/agents/pr_workflow.rs b/src/agents/pr_workflow.rs index a7c90f51..bdbe056c 100644 --- a/src/agents/pr_workflow.rs +++ b/src/agents/pr_workflow.rs @@ -1,24 +1,25 @@ -//! PR Workflow Handler - Manages PR creation and lifecycle. +//! PR/MR Workflow Handler - Manages PR/MR creation and lifecycle. //! //! Follows vibe-kanban patterns: //! - Push branch to remote -//! - Create PR via gh CLI -//! - Open PR in browser -//! - Track PR for merge detection +//! - Create PR/MR via the provider's `PrService` +//! - Open PR/MR in browser +//! - Track PR/MR for merge detection //! - Cleanup on merge use anyhow::{Context, Result}; use std::path::Path; +use std::sync::Arc; use tracing::{info, instrument, warn}; -use crate::api::GitHubService; +use crate::api::pr_service::{PrService, PrServiceRouter}; use crate::git::GitCli; use crate::services::PrMonitorService; -use crate::types::pr::{CreatePrError, CreatePrRequest, GitHubRepoInfo, PrState, PullRequestInfo}; +use crate::types::pr::{CreatePrError, CreatePrRequest, PrState, PullRequestInfo, RepoInfo}; -/// Handles the PR workflow for a step +/// Handles the PR/MR workflow for a step pub struct PrWorkflow { - github: GitHubService, + service: Arc, } impl Default for PrWorkflow { @@ -28,22 +29,22 @@ impl Default for PrWorkflow { } impl PrWorkflow { - /// Create a new PR workflow handler + /// Create a new PR workflow handler (routes per-call by provider) pub fn new() -> Self { Self { - github: GitHubService::new(), + service: Arc::new(PrServiceRouter::new()), } } /// Get repo info from a worktree path #[instrument(skip(self))] - pub async fn get_repo_info(&self, worktree_path: &Path) -> Result { + pub async fn get_repo_info(&self, worktree_path: &Path) -> Result { let remote_url = GitCli::get_remote_url(worktree_path) .await .context("Failed to get remote URL")?; - GitHubRepoInfo::from_remote_url(&remote_url) - .map_err(|e| anyhow::anyhow!("Failed to parse GitHub URL: {e}")) + RepoInfo::from_remote_url(&remote_url) + .map_err(|e| anyhow::anyhow!("Failed to parse repository URL: {e}")) } /// Push branch to remote @@ -68,15 +69,14 @@ impl PrWorkflow { base_branch: &str, draft: bool, ) -> Result { - let repo_info = - self.get_repo_info(worktree_path) - .await - .map_err(|e| CreatePrError::GithubApiError { - message: e.to_string(), - })?; + let repo_info = self.get_repo_info(worktree_path).await.map_err(|e| { + CreatePrError::ProviderApiError { + message: e.to_string(), + } + })?; let current_branch = GitCli::current_branch(worktree_path).await.map_err(|e| { - CreatePrError::GithubApiError { + CreatePrError::ProviderApiError { message: format!("Failed to get current branch: {e}"), } })?; @@ -97,14 +97,14 @@ impl PrWorkflow { }; let pr = self - .github + .service .create_pr(&repo_info, &request, worktree_path) .await?; info!("Created PR #{}: {}", pr.number, pr.url); // Open in browser - if let Err(e) = self.github.open_pr_in_browser(&repo_info, pr.number).await { + if let Err(e) = self.service.open_in_browser(&repo_info, pr.number).await { warn!("Failed to open PR in browser: {}", e); } @@ -117,7 +117,7 @@ impl PrWorkflow { let repo_info = self.get_repo_info(worktree_path).await?; let current_branch = GitCli::current_branch(worktree_path).await?; - self.github + self.service .find_pr_for_branch(&repo_info, ¤t_branch) .await } @@ -182,16 +182,14 @@ impl PrWorkflow { pr_number: i64, ) -> Result { let repo_info = self.get_repo_info(worktree_path).await?; - self.github.get_pr(&repo_info, pr_number).await + self.service.get_pr(&repo_info, pr_number).await } - /// Check if PR is ready to merge + /// Check if PR/MR is ready to merge #[instrument(skip(self))] pub async fn is_ready_to_merge(&self, worktree_path: &Path, pr_number: i64) -> Result { let repo_info = self.get_repo_info(worktree_path).await?; - self.github - .is_pr_ready_to_merge(&repo_info, pr_number) - .await + self.service.is_ready_to_merge(&repo_info, pr_number).await } /// Get new comments since last check @@ -203,7 +201,7 @@ impl PrWorkflow { since: chrono::DateTime, ) -> Result> { let repo_info = self.get_repo_info(worktree_path).await?; - self.github + self.service .get_comments_since(&repo_info, pr_number, since) .await } diff --git a/src/agents/proof_review.rs b/src/agents/proof_review.rs new file mode 100644 index 00000000..33f84ca0 --- /dev/null +++ b/src/agents/proof_review.rs @@ -0,0 +1,512 @@ +//! Proof Runner - Command-based proof-of-work review. +//! +//! For steps with `review_type: proof`, this handler runs an assertion +//! command (and optionally an artifact-capture command) in the worktree, +//! collects any declared artifacts, and persists the outcome as +//! `result.json` under `.proof/{ticket_id}/{step_name}/`. +//! +//! Unix-only: commands run via `sh -c`, mirroring the shell assumption +//! already made by the rest of the launch plumbing. + +use anyhow::{Context, Result}; +use handlebars::Handlebars; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tracing::{instrument, warn}; + +use crate::templates::schema::ProofReviewConfig; + +const DEFAULT_TIMEOUT_SECS: u64 = 120; + +/// Result of a proof review run, persisted as `result.json`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProofResult { + /// The rendered assertion command that was actually executed. + pub assertion_command: String, + /// Exit code of the assertion command; -1 if killed by signal/timeout. + pub exit_code: i32, + /// True iff `exit_code` == 0 && !`timed_out`. + pub passed: bool, + /// True if the assertion command was killed for exceeding its timeout. + pub timed_out: bool, + /// Wall-clock duration of the assertion command, in milliseconds. + pub duration_ms: u64, + /// Paths (relative to `worktree_root`) of everything written into the + /// proof dir: assertion.log, artifact.log (if run), and copied files. + pub artifacts: Vec, + /// Error from running/spawning `artifact_command`, if any. Artifact capture is best-effort. + pub artifact_command_error: Option, + /// RFC3339 timestamp of when this result was produced. + pub timestamp: String, +} + +/// Runs proof-of-work reviews: an assertion command plus optional artifact +/// capture, persisted under `.proof/{ticket_id}/{step_name}/`. +pub struct ProofRunner { + /// Context for handlebars rendering of `assertion_command` / + /// `artifact_command` (merged under `ticket_id`, `step`, `proof_dir`). + context: serde_json::Value, +} + +impl ProofRunner { + /// Create a new runner with an empty template context. + pub fn new() -> Self { + Self { + context: serde_json::Value::Object(serde_json::Map::new()), + } + } + + /// Create a runner with template context for command rendering. + pub fn with_context(context: serde_json::Value) -> Self { + Self { context } + } + + /// Directory a proof run for `(ticket_id, step_name)` is stored in: + /// `{worktree_root}/.proof/{ticket_id}/{step_name}/`. + fn proof_dir(worktree_root: &Path, ticket_id: &str, step_name: &str) -> PathBuf { + worktree_root.join(".proof").join(ticket_id).join(step_name) + } + + /// Path to the persisted `result.json` for `(ticket_id, step_name)`. + /// Used by callers (`complete_step` hook, sync arm) for idempotence + /// checks - a proof step has already run iff this path exists. + pub fn result_path(worktree_root: &Path, ticket_id: &str, step_name: &str) -> PathBuf { + Self::proof_dir(worktree_root, ticket_id, step_name).join("result.json") + } + + /// Render a command template with `ticket_id`, `step`, `proof_dir` + /// merged over any `with_context` values. + fn render_command( + &self, + template: &str, + ticket_id: &str, + step_name: &str, + proof_dir: &Path, + ) -> Result { + let mut ctx = self.context.clone(); + if let serde_json::Value::Object(ref mut map) = ctx { + map.insert("ticket_id".to_string(), ticket_id.into()); + map.insert("step".to_string(), step_name.into()); + map.insert( + "proof_dir".to_string(), + proof_dir.to_string_lossy().to_string().into(), + ); + } + let hbs = Handlebars::new(); + hbs.render_template(template, &ctx) + .context("Failed to render proof command template") + } + + /// Run `sh -c ` in `worktree_root`, capturing + /// stdout+stderr into `log_path` (sections, not interleaved) and + /// enforcing `timeout`. Returns `(exit_code, timed_out, duration_ms)`. + async fn run_command( + command: &str, + worktree_root: &Path, + proof_dir: &Path, + log_path: &Path, + timeout: Duration, + ) -> Result<(i32, bool, u64)> { + let start = Instant::now(); + + let mut child = Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(worktree_root) + .env("OPERATOR_PROOF_DIR", proof_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("Failed to spawn proof command")?; + + let mut stdout = child.stdout.take().context("Missing child stdout")?; + let mut stderr = child.stderr.take().context("Missing child stderr")?; + + let result = tokio::time::timeout(timeout, async { + let mut out = Vec::new(); + let mut err = Vec::new(); + let (out_res, err_res, status_res) = tokio::join!( + stdout.read_to_end(&mut out), + stderr.read_to_end(&mut err), + child.wait(), + ); + out_res.context("Failed to read stdout")?; + err_res.context("Failed to read stderr")?; + let status = status_res.context("Failed to wait for proof command")?; + Ok::<_, anyhow::Error>((out, err, status)) + }) + .await; + + let duration_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(Ok((out, err, status))) => { + let mut log = format!("$ {command}\n\n--- stdout ---\n"); + log.push_str(&String::from_utf8_lossy(&out)); + log.push_str("\n--- stderr ---\n"); + log.push_str(&String::from_utf8_lossy(&err)); + log.push('\n'); + tokio::fs::write(log_path, log) + .await + .context("Failed to write proof log")?; + let exit_code = status.code().unwrap_or(-1); + Ok((exit_code, false, duration_ms)) + } + Ok(Err(e)) => Err(e), + Err(_elapsed) => { + warn!("Proof command timed out after {:?}: {}", timeout, command); + child.kill().await.ok(); + child.wait().await.ok(); + let log = format!("$ {command}\n\n--- TIMED OUT after {timeout:?} ---\n"); + tokio::fs::write(log_path, log) + .await + .context("Failed to write proof log")?; + Ok((-1, true, duration_ms)) + } + } + } + + /// Run a proof review: assertion command, optional artifact command, + /// artifact glob collection, then persist `result.json`. + /// + /// Order: + /// 1. Create/clear the proof dir; ensure `.proof/.gitignore` exists. + /// 2. Run `assertion_command` (rendered) -> assertion.log. + /// 3. If set, run `artifact_command` (rendered) -> artifact.log; its + /// failure is recorded in `artifact_command_error`, never affects + /// `passed`. + /// 4. Copy files matching `artifact_patterns` (globs relative to + /// `worktree_root`) into the proof dir, flat, by file name. + /// 5. Persist result.json and return the struct. + #[instrument(skip(self, config))] + pub async fn run( + &self, + config: &ProofReviewConfig, + worktree_root: &Path, + ticket_id: &str, + step_name: &str, + ) -> Result { + let proof_dir = Self::proof_dir(worktree_root, ticket_id, step_name); + tokio::fs::create_dir_all(&proof_dir) + .await + .context("Failed to create proof dir")?; + + let gitignore_path = worktree_root.join(".proof").join(".gitignore"); + tokio::fs::write(&gitignore_path, "*\n") + .await + .context("Failed to write .proof/.gitignore")?; + + let timeout = Duration::from_secs( + config + .timeout_secs + .map(u64::from) + .unwrap_or(DEFAULT_TIMEOUT_SECS), + ); + + let assertion_command = + self.render_command(&config.assertion_command, ticket_id, step_name, &proof_dir)?; + let assertion_log = proof_dir.join("assertion.log"); + let (exit_code, timed_out, duration_ms) = Self::run_command( + &assertion_command, + worktree_root, + &proof_dir, + &assertion_log, + timeout, + ) + .await?; + + let mut artifact_command_error = None; + if let Some(ref artifact_command_template) = config.artifact_command { + let artifact_command = + self.render_command(artifact_command_template, ticket_id, step_name, &proof_dir)?; + let artifact_log = proof_dir.join("artifact.log"); + match Self::run_command( + &artifact_command, + worktree_root, + &proof_dir, + &artifact_log, + timeout, + ) + .await + { + Ok((code, artifact_timed_out, _)) if code != 0 || artifact_timed_out => { + artifact_command_error = Some(if artifact_timed_out { + format!("artifact_command timed out after {timeout:?}") + } else { + format!("artifact_command exited with code {code}") + }); + } + Ok(_) => {} + Err(e) => { + artifact_command_error = Some(format!("{e:#}")); + } + } + } + + Self::collect_artifacts(worktree_root, &proof_dir, &config.artifact_patterns)?; + + let artifacts = Self::list_artifacts(worktree_root, &proof_dir)?; + + let result = ProofResult { + assertion_command, + exit_code, + passed: exit_code == 0 && !timed_out, + timed_out, + duration_ms, + artifacts, + artifact_command_error, + timestamp: chrono::Utc::now().to_rfc3339(), + }; + + let result_json = + serde_json::to_string_pretty(&result).context("Failed to serialize proof result")?; + tokio::fs::write(proof_dir.join("result.json"), result_json) + .await + .context("Failed to write result.json")?; + + Ok(result) + } + + /// Copy files matching `patterns` (globs relative to `worktree_root`) + /// into `proof_dir`, flat by file name; last match wins on collision. + /// + /// Supported glob syntax is whatever the `glob` crate supports (already + /// a dependency, used elsewhere in this codebase for the same purpose - + /// see `artifact_detector.rs`), which includes `*`, `?`, `[...]`, and + /// `**` recursive matching. + fn collect_artifacts( + worktree_root: &Path, + proof_dir: &Path, + patterns: &[String], + ) -> Result<()> { + for pattern in patterns { + let full_pattern = worktree_root.join(pattern).to_string_lossy().to_string(); + let entries = glob::glob(&full_pattern) + .with_context(|| format!("Invalid artifact glob pattern: {pattern}"))?; + for entry in entries.flatten() { + if !entry.is_file() { + continue; + } + let Some(file_name) = entry.file_name() else { + continue; + }; + std::fs::copy(&entry, proof_dir.join(file_name)) + .with_context(|| format!("Failed to copy artifact {}", entry.display()))?; + } + } + Ok(()) + } + + /// List everything now in `proof_dir` except `result.json`, as paths + /// relative to `worktree_root`. + fn list_artifacts(worktree_root: &Path, proof_dir: &Path) -> Result> { + let mut artifacts = Vec::new(); + for entry in std::fs::read_dir(proof_dir).context("Failed to read proof dir")? { + let entry = entry?; + if entry.file_name() == "result.json" { + continue; + } + let path = entry.path(); + let relative = path.strip_prefix(worktree_root).unwrap_or(&path); + artifacts.push(relative.to_string_lossy().to_string()); + } + artifacts.sort(); + Ok(artifacts) + } +} + +impl Default for ProofRunner { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn config(assertion_command: &str) -> ProofReviewConfig { + ProofReviewConfig { + assertion_command: assertion_command.to_string(), + artifact_command: None, + artifact_patterns: Vec::new(), + timeout_secs: None, + } + } + + #[tokio::test] + async fn test_assertion_true_passes() { + let temp_dir = TempDir::new().unwrap(); + let runner = ProofRunner::new(); + let result = runner + .run(&config("true"), temp_dir.path(), "TICKET-1", "step-a") + .await + .unwrap(); + + assert!(result.passed); + assert_eq!(result.exit_code, 0); + assert!(!result.timed_out); + + let result_path = ProofRunner::result_path(temp_dir.path(), "TICKET-1", "step-a"); + assert!(result_path.exists()); + let assertion_log = result_path.with_file_name("assertion.log"); + assert!(assertion_log.exists()); + + let gitignore = temp_dir.path().join(".proof").join(".gitignore"); + let contents = std::fs::read_to_string(gitignore).unwrap(); + assert_eq!(contents, "*\n"); + } + + #[tokio::test] + async fn test_assertion_false_fails_but_artifacts_written() { + let temp_dir = TempDir::new().unwrap(); + let runner = ProofRunner::new(); + let result = runner + .run(&config("false"), temp_dir.path(), "TICKET-1", "step-a") + .await + .unwrap(); + + assert!(!result.passed); + assert_eq!(result.exit_code, 1); + assert!(!result.artifacts.is_empty()); + } + + #[tokio::test] + async fn test_assertion_timeout() { + let temp_dir = TempDir::new().unwrap(); + let runner = ProofRunner::new(); + let mut cfg = config("sleep 5"); + cfg.timeout_secs = Some(1); + + let start = Instant::now(); + let result = runner + .run(&cfg, temp_dir.path(), "TICKET-1", "step-a") + .await + .unwrap(); + let elapsed = start.elapsed(); + + assert!(result.timed_out); + assert!(!result.passed); + assert_eq!(result.exit_code, -1); + assert!(elapsed < Duration::from_secs(3)); + } + + #[tokio::test] + async fn test_assertion_log_contains_stdout_and_stderr() { + let temp_dir = TempDir::new().unwrap(); + let runner = ProofRunner::new(); + runner + .run( + &config("echo out; echo err 1>&2"), + temp_dir.path(), + "TICKET-1", + "step-a", + ) + .await + .unwrap(); + + let assertion_log = ProofRunner::result_path(temp_dir.path(), "TICKET-1", "step-a") + .with_file_name("assertion.log"); + let contents = std::fs::read_to_string(assertion_log).unwrap(); + assert!(contents.contains("out")); + assert!(contents.contains("err")); + } + + #[tokio::test] + async fn test_artifact_command_success() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("somefile"), b"data").unwrap(); + + let runner = ProofRunner::new(); + let mut cfg = config("true"); + cfg.artifact_command = Some("cp somefile $OPERATOR_PROOF_DIR/".to_string()); + + let result = runner + .run(&cfg, temp_dir.path(), "TICKET-1", "step-a") + .await + .unwrap(); + + assert!(result.artifact_command_error.is_none()); + let copied = ProofRunner::result_path(temp_dir.path(), "TICKET-1", "step-a") + .with_file_name("somefile"); + assert!(copied.exists()); + } + + #[tokio::test] + async fn test_artifact_command_failure_recorded_but_passed_tracks_assertion() { + let temp_dir = TempDir::new().unwrap(); + let runner = ProofRunner::new(); + let mut cfg = config("true"); + cfg.artifact_command = Some("false".to_string()); + + let result = runner + .run(&cfg, temp_dir.path(), "TICKET-1", "step-a") + .await + .unwrap(); + + assert!(result.artifact_command_error.is_some()); + assert!(result.passed); + } + + #[tokio::test] + async fn test_artifact_patterns_glob_copies_matching_file() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("report.txt"), b"contents").unwrap(); + std::fs::write(temp_dir.path().join("ignored.md"), b"nope").unwrap(); + + let runner = ProofRunner::new(); + let mut cfg = config("true"); + cfg.artifact_patterns = vec!["*.txt".to_string()]; + + let result = runner + .run(&cfg, temp_dir.path(), "TICKET-1", "step-a") + .await + .unwrap(); + + let copied = ProofRunner::result_path(temp_dir.path(), "TICKET-1", "step-a") + .with_file_name("report.txt"); + assert!(copied.exists()); + + let result_json = std::fs::read_to_string(ProofRunner::result_path( + temp_dir.path(), + "TICKET-1", + "step-a", + )) + .unwrap(); + let round_tripped: ProofResult = serde_json::from_str(&result_json).unwrap(); + assert_eq!(round_tripped.exit_code, result.exit_code); + assert!(round_tripped + .artifacts + .iter() + .any(|a| a.ends_with("report.txt"))); + } + + #[tokio::test] + async fn test_handlebars_proof_dir_and_ticket_id_render() { + let temp_dir = TempDir::new().unwrap(); + let runner = ProofRunner::new(); + let result = runner + .run( + &config("test -d {{proof_dir}}"), + temp_dir.path(), + "TICKET-42", + "step-a", + ) + .await + .unwrap(); + + assert!(result.passed); + + let mut cfg = config("echo {{ticket_id}} > $OPERATOR_PROOF_DIR/id.txt"); + cfg.assertion_command = "echo {{ticket_id}}".to_string(); + let result2 = runner + .run(&cfg, temp_dir.path(), "TICKET-42", "step-b") + .await + .unwrap(); + assert_eq!(result2.assertion_command, "echo TICKET-42"); + } +} diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 05316902..237d073f 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -20,11 +20,40 @@ use super::monitor::{HealthCheckResult, SessionMonitor}; use super::tmux::TmuxClient; use super::visual_review::VisualReviewHandler; use crate::agents::launcher::worktree_setup::cleanup_ticket_worktree; +use crate::agents::ProofResult; use crate::config::Config; use crate::queue::{Queue, StepAdvanceResult, Ticket}; use crate::state::{AgentState, State}; use crate::templates::schema::ReviewType; +/// Status message for a finished proof run - mirrors the strings the +/// `complete_step` proof hook (`rest/routes/launch.rs`) produces, so the +/// message reads the same regardless of which path ran the proof. +fn proof_result_message(result: &ProofResult, proof_ref: &str) -> String { + if result.timed_out { + format!( + "Proof FAILED (timeout, exit {}) — awaiting review ({proof_ref})", + result.exit_code + ) + } else if result.passed { + format!("Proof passed — awaiting review ({proof_ref})") + } else { + format!( + "Proof FAILED (exit {}) — awaiting review ({proof_ref})", + result.exit_code + ) + } +} + +/// Read and format the message for an already-persisted `result.json`. +/// Returns `None` on any read/parse failure so callers fall back to a +/// generic message. +fn read_proof_result_message(result_path: &std::path::Path, proof_ref: &str) -> Option { + let contents = std::fs::read_to_string(result_path).ok()?; + let result: ProofResult = serde_json::from_str(&contents).ok()?; + Some(proof_result_message(&result, proof_ref)) +} + /// Result of a sync cycle #[derive(Debug, Default)] pub struct SyncResult { @@ -129,6 +158,7 @@ impl TicketSessionSync { if let Some(agent) = state.agent_by_ticket(&ticket.id) { let agent_id = agent.id.clone(); let session_name = agent.session_name.clone().unwrap_or_default(); + let worktree_path = agent.worktree_path.clone(); // Determine the sync action based on health check results let action = self.determine_action(&ticket, &session_name, health_result); @@ -209,6 +239,15 @@ impl TicketSessionSync { "Step requires PR review - awaiting PR creation" ); } + ReviewType::Proof => { + self.handle_proof_awaiting( + &ticket, + &agent_id, + &step_display, + &worktree_path, + state, + )?; + } } // Add history entry to ticket @@ -303,6 +342,16 @@ impl TicketSessionSync { "completing", Some("All steps completed".to_string()), )?; + // Coder targets: stop the finished workspace + // (never delete). Best-effort by design. + if let Some(agent) = + state.agents.iter().find(|a| a.id == agent_id).cloned() + { + crate::agents::launcher::coder::stop_on_complete_for_agent( + &self.config, + &agent, + ); + } result.completed.push(ticket.id.clone()); tracing::info!( ticket_id = %ticket.id, @@ -345,6 +394,111 @@ impl TicketSessionSync { Ok(result) } + /// Gate a Proof step at `MovedToAwaiting` + fn handle_proof_awaiting( + &self, + ticket: &Ticket, + agent_id: &str, + step_display: &str, + worktree_path: &Option, + state: &mut State, + ) -> Result<()> { + let Some(proof_config) = ticket.current_step_schema().and_then(|s| s.proof_config) else { + state.update_agent_status( + agent_id, + "awaiting_input", + Some("Proof review (no config)".to_string()), + )?; + state.set_agent_review_state(agent_id, "pending_proof")?; + return Ok(()); + }; + + let worktree_root = worktree_path + .clone() + .map(PathBuf::from) + .unwrap_or_else(|| self.config.projects_path().join(&ticket.project)); + let proof_ref = format!(".proof/{}/{}", ticket.id, ticket.step); + let result_path = + crate::agents::ProofRunner::result_path(&worktree_root, &ticket.id, &ticket.step); + let already_ran = result_path.exists(); + + let message = if already_ran { + // Already ran (e.g. via complete_step) - never rerun. + read_proof_result_message(&result_path, &proof_ref) + .unwrap_or_else(|| "Proof review: awaiting approval".to_string()) + } else { + "Proof review: awaiting approval".to_string() + }; + + state.update_agent_status(agent_id, "awaiting_input", Some(message))?; + state.set_agent_review_state(agent_id, "pending_proof")?; + + if !already_ran { + self.spawn_proof_run(ticket, agent_id, proof_config, worktree_root, proof_ref); + } + + tracing::info!( + ticket_id = %ticket.id, + step = %step_display, + "Step awaiting proof review" + ); + Ok(()) + } + + /// Fire-and-forget the proof run. `sync_all` doesn't hold a live `State` + /// handle across await points, so the spawned task loads/saves its own + /// copy on completion - the same pattern `launch.rs`'s proof hook uses. + fn spawn_proof_run( + &self, + ticket: &Ticket, + agent_id: &str, + proof_config: crate::templates::schema::ProofReviewConfig, + worktree_root: PathBuf, + proof_ref: String, + ) { + let config = self.config.clone(); + let ticket_id = ticket.id.clone(); + let step_name = ticket.step.clone(); + let agent_id = agent_id.to_string(); + let context = serde_json::json!({"ticket_id": ticket_id, "step": step_name}); + + tokio::spawn(async move { + let runner = crate::agents::ProofRunner::with_context(context); + let message = match runner + .run(&proof_config, &worktree_root, &ticket_id, &step_name) + .await + { + Ok(result) => proof_result_message(&result, &proof_ref), + Err(e) => { + tracing::warn!( + ticket_id = %ticket_id, + step = %step_name, + error = %e, + "Proof runner error" + ); + "Proof runner error — awaiting review".to_string() + } + }; + match State::load(&config) { + Ok(mut app_state) => { + if let Err(e) = + app_state.update_agent_status(&agent_id, "awaiting_input", Some(message)) + { + tracing::warn!( + agent_id = %agent_id, + error = %e, + "Failed to persist proof result status" + ); + } + } + Err(e) => tracing::warn!( + error = %e, + "Failed to load state for proof result status" + ), + } + }); + } + /// Sync a multi-agent ticket: iterate each sub-agent in the group, /// collect their outputs, and when all `expected_total` sub-agents /// have reported, aggregate + write artifact + advance the step once. @@ -492,7 +646,15 @@ impl TicketSessionSync { } // Remove all sub-agent records now that the group is done. + // Coder targets: stop each finished workspace first. for aid in &agent_ids { + if let Some(agent) = state.agents.iter().find(|a| &a.id == aid).cloned() + { + crate::agents::launcher::coder::stop_on_complete_for_agent( + &self.config, + &agent, + ); + } state.remove_agent(aid)?; } state.cleanup_finished_groups()?; @@ -654,7 +816,7 @@ impl TicketSessionSync { // Handle based on review state match agent.review_state.as_deref() { - Some("pending_plan" | "pending_visual") => { + Some("pending_plan" | "pending_visual" | "pending_proof") => { // Review was approved via TUI (signal file written by approval handler) // Resume without needing content change state.update_agent_status(&agent.id, "running", None)?; diff --git a/src/agents/vscode_types.rs b/src/agents/vscode_types.rs index d6a31afb..b8eabb1f 100644 --- a/src/agents/vscode_types.rs +++ b/src/agents/vscode_types.rs @@ -202,6 +202,10 @@ pub struct VsCodeLaunchOptions { pub yolo_mode: bool, /// Resume from existing session (uses `session_id` from ticket) pub resume_session: bool, + /// Execution-target override by name (None = delegator/default resolution) + #[serde(default)] + #[ts(optional)] + pub target: Option, } /// Parsed ticket metadata from YAML frontmatter diff --git a/src/api/cli_detection.rs b/src/api/cli_detection.rs index 1b245d96..3afc2372 100644 --- a/src/api/cli_detection.rs +++ b/src/api/cli_detection.rs @@ -6,6 +6,8 @@ use std::process::Stdio; use tokio::process::Command; +use crate::types::pr::GitProvider; + /// CLI tool information #[derive(Debug, Clone)] pub struct CliInfo { @@ -19,75 +21,77 @@ pub struct CliInfo { pub version: Option, } -/// Check if the `git` CLI is installed -pub async fn detect_git() -> CliInfo { - let (installed, version) = check_cli_version("git", &["--version"]).await; - CliInfo { - name: "Git", - command: "git", - installed, - version, - } +/// Static description of a provider CLI: display name and command to probe. +/// `provider` is `None` for the provider-agnostic `git` binary. +struct CliSpec { + provider: Option, + name: &'static str, + command: &'static str, } -/// Check if the GitHub CLI (`gh`) is installed -pub async fn detect_github_cli() -> CliInfo { - let (installed, version) = check_cli_version("gh", &["--version"]).await; - CliInfo { +const CLI_SPECS: &[CliSpec] = &[ + CliSpec { + provider: None, + name: "Git", + command: "git", + }, + CliSpec { + provider: Some(GitProvider::GitHub), name: "GitHub CLI", command: "gh", - installed, - version, - } -} - -/// Check if the GitLab CLI (`glab`) is installed -pub async fn detect_gitlab_cli() -> CliInfo { - let (installed, version) = check_cli_version("glab", &["--version"]).await; - CliInfo { + }, + CliSpec { + provider: Some(GitProvider::GitLab), name: "GitLab CLI", command: "glab", - installed, - version, - } -} - -/// Check if the Bitbucket CLI (`bb`) is installed -pub async fn detect_bitbucket_cli() -> CliInfo { - let (installed, version) = check_cli_version("bb", &["--version"]).await; - CliInfo { + }, + CliSpec { + provider: Some(GitProvider::Bitbucket), name: "Bitbucket CLI", command: "bb", - installed, - version, - } + }, + CliSpec { + provider: Some(GitProvider::AzureDevOps), + name: "Azure CLI", + command: "az", + }, + CliSpec { + provider: Some(GitProvider::Forgejo), + name: "Forgejo CLI", + command: "fj", + }, + CliSpec { + provider: Some(GitProvider::Gitea), + name: "Gitea CLI", + command: "tea", + }, +]; + +/// Detect all provider CLIs (and `git` itself), in table order. +pub async fn detect_all_clis() -> Vec { + let checks = CLI_SPECS.iter().map(probe); + futures_util::future::join_all(checks).await } -/// Check if the Azure CLI (`az`) is installed with repos extension -pub async fn detect_azure_cli() -> CliInfo { - let (installed, version) = check_cli_version("az", &["--version"]).await; +/// Detect the CLI for a specific provider. +pub async fn detect_for(provider: GitProvider) -> CliInfo { + let spec = CLI_SPECS + .iter() + .find(|s| s.provider == Some(provider)) + .expect("CLI_SPECS covers every GitProvider variant"); + probe(spec).await +} + +async fn probe(spec: &CliSpec) -> CliInfo { + let (installed, version) = check_cli_version(spec.command, &["--version"]).await; CliInfo { - name: "Azure CLI", - command: "az", + name: spec.name, + command: spec.command, installed, version, } } -/// Detect all available provider CLIs -pub async fn detect_all_clis() -> Vec { - // Run all detections in parallel - let (git, github, gitlab, bitbucket, azure) = tokio::join!( - detect_git(), - detect_github_cli(), - detect_gitlab_cli(), - detect_bitbucket_cli(), - detect_azure_cli(), - ); - - vec![git, github, gitlab, bitbucket, azure] -} - /// Helper to check if a CLI is installed and get its version async fn check_cli_version(command: &str, args: &[&str]) -> (bool, Option) { let result = Command::new(command) @@ -114,27 +118,53 @@ mod tests { use super::*; #[tokio::test] - async fn test_detect_git() { - // git should be installed on most systems - let info = detect_git().await; - assert_eq!(info.name, "Git"); - assert_eq!(info.command, "git"); - // Don't assert installed=true as it depends on the system + async fn test_detect_all_clis() { + let clis = detect_all_clis().await; + assert_eq!(clis.len(), 7); + assert!(clis.iter().any(|c| c.command == "git")); + assert!(clis.iter().any(|c| c.command == "gh")); + assert!(clis.iter().any(|c| c.command == "glab")); + assert!(clis.iter().any(|c| c.command == "bb")); + assert!(clis.iter().any(|c| c.command == "az")); + assert!(clis.iter().any(|c| c.command == "fj")); + assert!(clis.iter().any(|c| c.command == "tea")); + } + + #[test] + fn test_cli_specs_covers_all_providers() { + for provider in GitProvider::ALL { + assert!( + CLI_SPECS.iter().any(|s| s.provider == Some(provider)), + "no CliSpec for provider {provider}" + ); + } } #[tokio::test] - async fn test_detect_github_cli() { - let info = detect_github_cli().await; - assert_eq!(info.name, "GitHub CLI"); + async fn test_detect_for_github() { + let info = detect_for(GitProvider::GitHub).await; assert_eq!(info.command, "gh"); + assert_eq!(info.name, "GitHub CLI"); } #[tokio::test] - async fn test_detect_all_clis() { - let clis = detect_all_clis().await; - assert_eq!(clis.len(), 5); - assert!(clis.iter().any(|c| c.command == "git")); - assert!(clis.iter().any(|c| c.command == "gh")); - assert!(clis.iter().any(|c| c.command == "glab")); + async fn test_detect_for_gitlab() { + let info = detect_for(GitProvider::GitLab).await; + assert_eq!(info.command, "glab"); + assert_eq!(info.name, "GitLab CLI"); + } + + #[tokio::test] + async fn test_detect_for_forgejo() { + let info = detect_for(GitProvider::Forgejo).await; + assert_eq!(info.command, "fj"); + assert_eq!(info.name, "Forgejo CLI"); + } + + #[tokio::test] + async fn test_detect_for_gitea() { + let info = detect_for(GitProvider::Gitea).await; + assert_eq!(info.command, "tea"); + assert_eq!(info.name, "Gitea CLI"); } } diff --git a/src/api/gh_cli.rs b/src/api/gh_cli.rs index 040ab1b3..a0904a26 100644 --- a/src/api/gh_cli.rs +++ b/src/api/gh_cli.rs @@ -84,12 +84,12 @@ impl GhCli { ) -> Result { // Check if gh is installed if !Self::is_installed().await { - return Err(CreatePrError::GithubCliNotInstalled); + return Err(CreatePrError::ProviderCliNotInstalled); } // Check if authenticated if !Self::check_auth().await.unwrap_or(false) { - return Err(CreatePrError::GithubCliNotLoggedIn); + return Err(CreatePrError::ProviderCliNotLoggedIn); } let repo_full_name = repo_info.full_name(); @@ -148,12 +148,12 @@ impl GhCli { }; } - CreatePrError::GithubApiError { message: err_str } + CreatePrError::ProviderApiError { message: err_str } })?; // Parse the JSON response let pr_response: GhPrCreateResponse = - serde_json::from_str(&output).map_err(|e| CreatePrError::GithubApiError { + serde_json::from_str(&output).map_err(|e| CreatePrError::ProviderApiError { message: format!("Failed to parse PR response: {e}"), })?; diff --git a/src/api/gitlab_service.rs b/src/api/gitlab_service.rs new file mode 100644 index 00000000..3f5769e3 --- /dev/null +++ b/src/api/gitlab_service.rs @@ -0,0 +1,329 @@ +//! GitLab service with retry logic for MR operations. +//! +//! Wraps `GlabCli` with exponential backoff retry for transient failures. +//! Mirrors `GitHubService`'s retry/backoff shape. + +use anyhow::Result; +use backon::{ExponentialBuilder, Retryable}; +use std::path::Path; +use std::time::Duration; +use tracing::{debug, info, instrument, warn}; + +use crate::api::GlabCli; +use crate::types::pr::{ + CreatePrError, CreatePrRequest, PrReviewState, PrState, PullRequestInfo, RepoInfo, + UnifiedPrComment, +}; + +/// GitLab service with retry logic +pub struct GitLabService { + /// Maximum retry attempts + max_retries: usize, + /// Base delay for exponential backoff + base_delay: Duration, + /// Maximum delay between retries + max_delay: Duration, +} + +impl Default for GitLabService { + fn default() -> Self { + Self::new() + } +} + +impl GitLabService { + /// Create a new GitLab service with default retry settings + pub fn new() -> Self { + Self { + max_retries: 3, + base_delay: Duration::from_millis(500), + max_delay: Duration::from_secs(10), + } + } + + /// Create with custom retry settings + pub fn with_retry_config( + max_retries: usize, + base_delay: Duration, + max_delay: Duration, + ) -> Self { + Self { + max_retries, + base_delay, + max_delay, + } + } + + /// Build the retry strategy + fn retry_strategy(&self) -> ExponentialBuilder { + ExponentialBuilder::default() + .with_min_delay(self.base_delay) + .with_max_delay(self.max_delay) + .with_max_times(self.max_retries) + } + + /// Check if an error is retryable + fn should_retry(err: &anyhow::Error) -> bool { + let err_str = err.to_string().to_lowercase(); + + // Network/transient errors are retryable + if err_str.contains("timeout") + || err_str.contains("connection") + || err_str.contains("temporary") + || err_str.contains("rate limit") + || err_str.contains("503") + || err_str.contains("502") + || err_str.contains("504") + { + return true; + } + + // Auth errors are not retryable + if err_str.contains("401") + || err_str.contains("403") + || err_str.contains("unauthorized") + || err_str.contains("not logged in") + { + return false; + } + + // Default: don't retry unknown errors + false + } + + /// Check if glab CLI is available and authenticated + pub async fn check_available(&self) -> Result { + if !GlabCli::is_installed().await { + return Ok(false); + } + GlabCli::check_auth().await + } + + /// Get the authenticated user + #[instrument(skip(self))] + pub async fn get_authenticated_user(&self) -> Result { + let op = || async { GlabCli::get_authenticated_user().await }; + + op.retry(self.retry_strategy()) + .when(Self::should_retry) + .notify(|err, dur| { + warn!("Retrying get_authenticated_user after {:?}: {}", dur, err); + }) + .await + } + + /// Create an MR with retry + #[instrument(skip(self, request))] + pub async fn create_pr( + &self, + repo_info: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result { + // MR creation errors are not typically retryable (validation errors, already exists, etc.) + // So we don't use retry here + GlabCli::create_pr(repo_info, request, cwd).await + } + + /// Get MR info with retry + #[instrument(skip(self))] + pub async fn get_pr(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { + let op = || async { GlabCli::get_pr(repo_info, pr_number).await }; + + op.retry(self.retry_strategy()) + .when(Self::should_retry) + .notify(|err, dur| { + warn!("Retrying get_pr after {:?}: {}", dur, err); + }) + .await + } + + /// List MRs for a branch with retry + #[instrument(skip(self))] + pub async fn list_prs_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + let op = || async { GlabCli::list_prs_for_branch(repo_info, branch).await }; + + op.retry(self.retry_strategy()) + .when(Self::should_retry) + .notify(|err, dur| { + warn!("Retrying list_prs_for_branch after {:?}: {}", dur, err); + }) + .await + } + + /// Get all MR comments with retry + #[instrument(skip(self))] + pub async fn get_all_pr_comments( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result> { + let op = || async { GlabCli::get_all_pr_comments(repo_info, pr_number).await }; + + op.retry(self.retry_strategy()) + .when(Self::should_retry) + .notify(|err, dur| { + warn!("Retrying get_all_pr_comments after {:?}: {}", dur, err); + }) + .await + } + + /// Get MR review state with retry + #[instrument(skip(self))] + pub async fn get_pr_review_state( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result { + let op = || async { GlabCli::get_pr_review_state(repo_info, pr_number).await }; + + op.retry(self.retry_strategy()) + .when(Self::should_retry) + .notify(|err, dur| { + warn!("Retrying get_pr_review_state after {:?}: {}", dur, err); + }) + .await + } + + /// Check if MR is ready to merge with retry + #[instrument(skip(self))] + pub async fn is_pr_ready_to_merge(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { + let op = || async { GlabCli::is_pr_ready_to_merge(repo_info, pr_number).await }; + + op.retry(self.retry_strategy()) + .when(Self::should_retry) + .notify(|err, dur| { + warn!("Retrying is_pr_ready_to_merge after {:?}: {}", dur, err); + }) + .await + } + + /// Open MR in browser (no retry needed) + pub async fn open_pr_in_browser(&self, repo_info: &RepoInfo, pr_number: i64) -> Result<()> { + GlabCli::open_pr_in_browser(repo_info, pr_number).await + } + + /// Poll MR status until a condition is met or timeout + #[instrument(skip(self, condition))] + pub async fn poll_pr_until( + &self, + repo_info: &RepoInfo, + pr_number: i64, + poll_interval: Duration, + timeout: Duration, + mut condition: impl FnMut(&PullRequestInfo) -> bool, + ) -> Result { + let start = std::time::Instant::now(); + + loop { + let pr = self.get_pr(repo_info, pr_number).await?; + + if condition(&pr) { + return Ok(pr); + } + + if start.elapsed() > timeout { + return Err(anyhow::anyhow!( + "Timeout waiting for MR #{pr_number} condition" + )); + } + + debug!( + "MR #{} state: {:?}, waiting {:?} before next poll", + pr_number, pr.state, poll_interval + ); + tokio::time::sleep(poll_interval).await; + } + } + + /// Wait for MR to be merged + #[instrument(skip(self))] + pub async fn wait_for_merge( + &self, + repo_info: &RepoInfo, + pr_number: i64, + poll_interval: Duration, + timeout: Duration, + ) -> Result { + info!("Waiting for MR #{} to be merged", pr_number); + + self.poll_pr_until(repo_info, pr_number, poll_interval, timeout, |pr| { + pr.state == PrState::Merged + }) + .await + } + + /// Get new comments since a given time + #[instrument(skip(self))] + pub async fn get_comments_since( + &self, + repo_info: &RepoInfo, + pr_number: i64, + since: chrono::DateTime, + ) -> Result> { + let all_comments = self.get_all_pr_comments(repo_info, pr_number).await?; + + Ok(all_comments + .into_iter() + .filter(|c| c.created_at() > since) + .collect()) + } + + /// Find an existing MR for a branch + #[instrument(skip(self))] + pub async fn find_pr_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + let prs = self.list_prs_for_branch(repo_info, branch).await?; + + // Find the first open MR, or the most recent if none are open + let open_pr = prs.iter().find(|p| p.state == PrState::Open); + if let Some(pr) = open_pr { + return Ok(Some(pr.clone())); + } + + // Return most recent (first in list) + Ok(prs.into_iter().next()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_retry_timeout() { + let err = anyhow::anyhow!("Connection timeout"); + assert!(GitLabService::should_retry(&err)); + } + + #[test] + fn test_should_retry_rate_limit() { + let err = anyhow::anyhow!("Rate limit exceeded"); + assert!(GitLabService::should_retry(&err)); + } + + #[test] + fn test_should_not_retry_auth() { + let err = anyhow::anyhow!("401 Unauthorized"); + assert!(!GitLabService::should_retry(&err)); + } + + #[test] + fn test_should_not_retry_forbidden() { + let err = anyhow::anyhow!("403 Forbidden"); + assert!(!GitLabService::should_retry(&err)); + } + + #[test] + fn test_default_config() { + let service = GitLabService::new(); + assert_eq!(service.max_retries, 3); + } +} diff --git a/src/api/glab_cli.rs b/src/api/glab_cli.rs new file mode 100644 index 00000000..ea8c1608 --- /dev/null +++ b/src/api/glab_cli.rs @@ -0,0 +1,642 @@ +//! GitLab CLI (`glab`) wrapper for MR operations. +//! +//! Uses the `glab` CLI () for GitLab operations, +//! mirroring `GhCli`'s shape so both providers plug into the same `PrService` trait. + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; +use tracing::{debug, instrument}; + +use crate::types::pr::{ + CreatePrError, CreatePrRequest, PrReviewState, PrState, PullRequestInfo, RepoInfo, + UnifiedPrComment, +}; + +/// GitLab CLI wrapper for MR operations +pub struct GlabCli; + +impl GlabCli { + /// Execute a glab command and return stdout + async fn run_glab(args: &[&str], cwd: Option<&Path>) -> Result { + debug!(?args, "Running glab command"); + + let mut cmd = Command::new("glab"); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + let output = cmd + .output() + .await + .context("Failed to execute glab command")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "glab {} failed: {}", + args.first().unwrap_or(&""), + stderr.trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Check if glab CLI is installed + pub async fn is_installed() -> bool { + Command::new("glab") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) + } + + /// Check if glab CLI is authenticated + #[instrument] + pub async fn check_auth() -> Result { + let result = Self::run_glab(&["auth", "status"], None).await; + Ok(result.is_ok()) + } + + /// Get the authenticated user + pub async fn get_authenticated_user() -> Result { + let output = Self::run_glab(&["api", "user"], None).await?; + let user: GlabUser = + serde_json::from_str(&output).context("Failed to parse authenticated user")?; + Ok(user.username) + } + + /// Create an MR using glab CLI + #[instrument(skip(request))] + pub async fn create_pr( + repo_info: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result { + if !Self::is_installed().await { + return Err(CreatePrError::ProviderCliNotInstalled); + } + + if !Self::check_auth().await.unwrap_or(false) { + return Err(CreatePrError::ProviderCliNotLoggedIn); + } + + let repo_full_name = repo_info.full_name(); + let mut args = vec![ + "mr", + "create", + "--repo", + &repo_full_name, + "--source-branch", + &request.head_branch, + "--target-branch", + &request.base_branch, + "--title", + &request.title, + ]; + + let body_arg: String; + if let Some(ref body) = request.body { + body_arg = body.clone(); + args.push("--description"); + args.push(&body_arg); + } + + if request.draft.unwrap_or(false) { + args.push("--draft"); + } + + args.push("--yes"); + + let output = Self::run_glab(&args, Some(cwd)).await.map_err(|e| { + CreatePrError::ProviderApiError { + message: e.to_string(), + } + })?; + + let mr_number = + extract_mr_number(&output).ok_or_else(|| CreatePrError::ProviderApiError { + message: format!("Failed to parse MR number from glab output: {output}"), + })?; + + Self::get_pr(repo_info, mr_number) + .await + .map_err(|e| CreatePrError::ProviderApiError { + message: e.to_string(), + }) + } + + /// Get MR info using glab CLI + #[instrument] + pub async fn get_pr(repo_info: &RepoInfo, pr_number: i64) -> Result { + let pr_num_str = pr_number.to_string(); + let output = Self::run_glab( + &[ + "mr", + "view", + &pr_num_str, + "--repo", + &repo_info.full_name(), + "--output", + "json", + ], + None, + ) + .await?; + + let response: GlabMr = + serde_json::from_str(&output).context("Failed to parse MR view response")?; + Ok(response.into()) + } + + /// List MRs for a branch + #[instrument] + pub async fn list_prs_for_branch( + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + let output = Self::run_glab( + &[ + "mr", + "list", + "--repo", + &repo_info.full_name(), + "--source-branch", + branch, + "--all", + "--output", + "json", + ], + None, + ) + .await?; + + let responses: Vec = + serde_json::from_str(&output).context("Failed to parse MR list response")?; + + Ok(responses.into_iter().map(Into::into).collect()) + } + + /// Get all MR comments (general + review), notes API covers both + #[instrument] + pub async fn get_all_pr_comments( + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result> { + let project_path = encode_project_path(repo_info); + let endpoint = format!("projects/{project_path}/merge_requests/{pr_number}/notes?sort=asc"); + + let output = Self::run_glab(&["api", &endpoint], None).await?; + let notes: Vec = + serde_json::from_str(&output).context("Failed to parse notes")?; + + Ok(notes + .into_iter() + .filter(|n| !n.system) + .map(note_to_comment) + .collect()) + } + + /// Get the review state of an MR + #[instrument] + pub async fn get_pr_review_state( + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result { + let project_path = encode_project_path(repo_info); + + let approvals_endpoint = + format!("projects/{project_path}/merge_requests/{pr_number}/approvals"); + let output = Self::run_glab(&["api", &approvals_endpoint], None).await?; + let approvals: GlabApprovals = + serde_json::from_str(&output).context("Failed to parse approvals")?; + + if approvals.approved { + return Ok(PrReviewState::Approved); + } + + // GitLab has no "dismissed" state; fall back to reviewer change-requests, + // and treat any missing/unparseable data as Pending (conservative default). + let reviewers_endpoint = + format!("projects/{project_path}/merge_requests/{pr_number}/reviewers"); + let reviewers_output = Self::run_glab(&["api", &reviewers_endpoint], None) + .await + .unwrap_or_default(); + let reviewers: Vec = + serde_json::from_str(&reviewers_output).unwrap_or_default(); + + Ok(reviewer_review_state(&reviewers)) + } + + /// Open an MR in the browser + pub async fn open_pr_in_browser(repo_info: &RepoInfo, pr_number: i64) -> Result<()> { + let pr_num_str = pr_number.to_string(); + Self::run_glab( + &[ + "mr", + "view", + &pr_num_str, + "--repo", + &repo_info.full_name(), + "--web", + ], + None, + ) + .await?; + Ok(()) + } + + /// Check if an MR is ready to merge (approved, no changes requested, checks pass) + #[instrument] + pub async fn is_pr_ready_to_merge(repo_info: &RepoInfo, pr_number: i64) -> Result { + let pr = Self::get_pr(repo_info, pr_number).await?; + + // Must be open and not a draft + if pr.state != PrState::Open || pr.is_draft { + return Ok(false); + } + + // Check review state + let review_state = Self::get_pr_review_state(repo_info, pr_number).await?; + + if review_state == PrReviewState::ChangesRequested { + return Ok(false); + } + + if review_state != PrReviewState::Approved { + return Ok(false); + } + + // TODO: Check status checks when needed + // For now, just require approval + + Ok(true) + } +} + +// Response types for glab CLI JSON output + +#[derive(Debug, Deserialize)] +struct GlabUser { + username: String, +} + +#[derive(Debug, Deserialize)] +struct GlabMr { + iid: i64, + web_url: String, + state: String, + title: String, + #[serde(default)] + draft: bool, + #[serde(default)] + merge_commit_sha: Option, +} + +impl From for PullRequestInfo { + fn from(mr: GlabMr) -> Self { + PullRequestInfo { + number: mr.iid, + url: mr.web_url, + state: map_state(&mr.state), + merge_commit_sha: mr.merge_commit_sha, + title: Some(mr.title), + is_draft: mr.draft, + } + } +} + +#[derive(Debug, Default, Deserialize)] +struct GlabApprovals { + #[serde(default)] + approved: bool, +} + +#[derive(Debug, Default, Deserialize)] +struct GlabReviewer { + #[serde(default)] + state: Option, +} + +#[derive(Debug, Deserialize)] +struct GlabNoteAuthor { + username: String, +} + +#[derive(Debug, Deserialize)] +struct GlabNotePosition { + new_path: String, + #[serde(default)] + new_line: Option, +} + +#[derive(Debug, Deserialize)] +struct GlabNote { + id: i64, + body: String, + author: GlabNoteAuthor, + created_at: DateTime, + #[serde(default)] + system: bool, + #[serde(default)] + position: Option, +} + +/// Map a GitLab MR `state` string to the provider-agnostic `PrState`. +fn map_state(state: &str) -> PrState { + match state { + "opened" => PrState::Open, + "merged" => PrState::Merged, + "closed" | "locked" => PrState::Closed, + _ => PrState::Closed, + } +} + +/// Reduce a reviewers list to a review state: any `requested_changes` wins, +/// otherwise conservative Pending (GitLab has no Dismissed equivalent). +fn reviewer_review_state(reviewers: &[GlabReviewer]) -> PrReviewState { + if reviewers + .iter() + .any(|r| r.state.as_deref() == Some("requested_changes")) + { + PrReviewState::ChangesRequested + } else { + PrReviewState::Pending + } +} + +/// Convert a GitLab note into a `UnifiedPrComment`. Notes with a `position` +/// are inline review comments; others are general conversation comments. +/// GitLab's notes API has no `author_association` or comment `url`, so both +/// are left empty (mirrors `gh_cli`'s handling of provider-missing fields). +fn note_to_comment(note: GlabNote) -> UnifiedPrComment { + match note.position { + Some(pos) => UnifiedPrComment::Review { + id: note.id, + author: note.author.username, + author_association: String::new(), + body: note.body, + created_at: note.created_at, + url: String::new(), + path: pos.new_path, + line: pos.new_line, + diff_hunk: String::new(), + }, + None => UnifiedPrComment::General { + id: note.id.to_string(), + author: note.author.username, + author_association: String::new(), + body: note.body, + created_at: note.created_at, + url: String::new(), + }, + } +} + +/// Percent-encode a repo's `owner/repo` path for GitLab's REST API, which +/// requires every `/` (including subgroup separators) encoded as `%2F`. +fn encode_project_path(repo_info: &RepoInfo) -> String { + repo_info.full_name().replace('/', "%2F") +} + +/// Extract the MR number from `glab mr create`'s stdout, which prints the MR +/// URL (`.../-/merge_requests/N`) rather than JSON. +fn extract_mr_number(output: &str) -> Option { + if let Ok(re) = regex::Regex::new(r"/-/merge_requests/(\d+)") { + if let Some(caps) = re.captures(output) { + return caps.get(1)?.as_str().parse().ok(); + } + } + + // Fall back to a trailing "!N" shorthand + if let Ok(re) = regex::Regex::new(r"!(\d+)\s*$") { + if let Some(caps) = re.captures(output.trim()) { + return caps.get(1)?.as_str().parse().ok(); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_is_installed() { + // This test just verifies the function doesn't panic + let _ = GlabCli::is_installed().await; + } + + // --- MR number extraction from create output --- + + #[test] + fn test_extract_mr_number_from_url() { + let output = "https://gitlab.com/group/project/-/merge_requests/42"; + assert_eq!(extract_mr_number(output), Some(42)); + } + + #[test] + fn test_extract_mr_number_from_bang_shorthand() { + let output = "Merge request created !17"; + assert_eq!(extract_mr_number(output), Some(17)); + } + + #[test] + fn test_extract_mr_number_no_match() { + assert_eq!(extract_mr_number("no number here"), None); + } + + // --- glab mr view JSON state mapping --- + + #[test] + fn test_map_state_opened() { + assert_eq!(map_state("opened"), PrState::Open); + } + + #[test] + fn test_map_state_merged() { + assert_eq!(map_state("merged"), PrState::Merged); + } + + #[test] + fn test_map_state_closed() { + assert_eq!(map_state("closed"), PrState::Closed); + } + + #[test] + fn test_map_state_locked() { + assert_eq!(map_state("locked"), PrState::Closed); + } + + #[test] + fn test_glab_mr_json_deserializes_and_maps_draft() { + let json = r#"{ + "iid": 5, + "web_url": "https://gitlab.com/group/project/-/merge_requests/5", + "state": "opened", + "title": "Add feature", + "draft": true, + "merge_commit_sha": null + }"#; + let mr: GlabMr = serde_json::from_str(json).unwrap(); + let info: PullRequestInfo = mr.into(); + assert_eq!(info.number, 5); + assert_eq!(info.state, PrState::Open); + assert!(info.is_draft); + assert_eq!(info.merge_commit_sha, None); + } + + #[test] + fn test_glab_mr_json_merged_with_commit_sha() { + let json = r#"{ + "iid": 9, + "web_url": "https://gitlab.com/group/project/-/merge_requests/9", + "state": "merged", + "title": "Fix bug", + "draft": false, + "merge_commit_sha": "abc123" + }"#; + let mr: GlabMr = serde_json::from_str(json).unwrap(); + let info: PullRequestInfo = mr.into(); + assert_eq!(info.state, PrState::Merged); + assert_eq!(info.merge_commit_sha, Some("abc123".to_string())); + } + + // --- approvals + reviewers -> review-state normalization --- + + #[test] + fn test_approvals_approved() { + let json = r#"{"approved": true}"#; + let approvals: GlabApprovals = serde_json::from_str(json).unwrap(); + assert!(approvals.approved); + } + + #[test] + fn test_reviewer_review_state_requested_changes() { + let reviewers = vec![ + GlabReviewer { + state: Some("reviewed".to_string()), + }, + GlabReviewer { + state: Some("requested_changes".to_string()), + }, + ]; + assert_eq!( + reviewer_review_state(&reviewers), + PrReviewState::ChangesRequested + ); + } + + #[test] + fn test_reviewer_review_state_pending_when_none_requested_changes() { + let reviewers = vec![GlabReviewer { + state: Some("reviewed".to_string()), + }]; + assert_eq!(reviewer_review_state(&reviewers), PrReviewState::Pending); + } + + #[test] + fn test_reviewer_review_state_pending_on_missing_fields() { + let reviewers: Vec = vec![GlabReviewer { state: None }]; + assert_eq!(reviewer_review_state(&reviewers), PrReviewState::Pending); + } + + #[test] + fn test_reviewer_review_state_pending_on_empty_list() { + assert_eq!(reviewer_review_state(&[]), PrReviewState::Pending); + } + + // --- notes JSON -> comment conversion --- + + #[test] + fn test_note_to_comment_general() { + let json = r#"{ + "id": 1, + "body": "LGTM", + "author": {"username": "alice"}, + "created_at": "2024-01-01T00:00:00Z", + "system": false + }"#; + let note: GlabNote = serde_json::from_str(json).unwrap(); + let comment = note_to_comment(note); + match comment { + UnifiedPrComment::General { + id, author, body, .. + } => { + assert_eq!(id, "1"); + assert_eq!(author, "alice"); + assert_eq!(body, "LGTM"); + } + UnifiedPrComment::Review { .. } => panic!("expected General comment"), + } + } + + #[test] + fn test_note_to_comment_review_with_position() { + let json = r#"{ + "id": 2, + "body": "fix this", + "author": {"username": "bob"}, + "created_at": "2024-01-02T00:00:00Z", + "system": false, + "position": {"new_path": "src/main.rs", "new_line": 10} + }"#; + let note: GlabNote = serde_json::from_str(json).unwrap(); + let comment = note_to_comment(note); + match comment { + UnifiedPrComment::Review { + id, + path, + line, + diff_hunk, + .. + } => { + assert_eq!(id, 2); + assert_eq!(path, "src/main.rs"); + assert_eq!(line, Some(10)); + assert_eq!(diff_hunk, ""); + } + UnifiedPrComment::General { .. } => panic!("expected Review comment"), + } + } + + #[test] + fn test_notes_filter_system_comments() { + let json = r#"[ + {"id": 1, "body": "hello", "author": {"username": "alice"}, "created_at": "2024-01-01T00:00:00Z", "system": false}, + {"id": 2, "body": "changed target branch", "author": {"username": "bob"}, "created_at": "2024-01-01T00:00:01Z", "system": true} + ]"#; + let notes: Vec = serde_json::from_str(json).unwrap(); + let comments: Vec<_> = notes + .into_iter() + .filter(|n| !n.system) + .map(note_to_comment) + .collect(); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].author(), "alice"); + } + + // --- project path %2F encoding, including subgroups --- + + #[test] + fn test_encode_project_path_simple() { + let repo = RepoInfo::new(crate::types::pr::GitProvider::GitLab, "owner", "repo"); + assert_eq!(encode_project_path(&repo), "owner%2Frepo"); + } + + #[test] + fn test_encode_project_path_subgroup() { + let repo = RepoInfo::new(crate::types::pr::GitProvider::GitLab, "group/sub", "repo"); + assert_eq!(encode_project_path(&repo), "group%2Fsub%2Frepo"); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 05370f9b..fc6b8edc 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,6 +12,8 @@ pub mod cli_detection; pub mod error; pub mod gh_cli; pub mod github_service; +pub mod gitlab_service; +pub mod glab_cli; pub mod kanban_sync; pub mod pr_service; pub mod providers; @@ -30,6 +32,8 @@ pub use anthropic::AnthropicClient; pub use gh_cli::GhCli; pub use github::GitHubClient; pub use github_service::GitHubService; +pub use gitlab_service::GitLabService; +pub use glab_cli::GlabCli; pub use pr_service::PrService; use std::collections::HashMap; diff --git a/src/api/pr_service.rs b/src/api/pr_service.rs index bd33524b..bc88f194 100644 --- a/src/api/pr_service.rs +++ b/src/api/pr_service.rs @@ -6,9 +6,11 @@ use anyhow::Result; use async_trait::async_trait; use std::path::Path; +use std::sync::Arc; use crate::types::pr::{ - CreatePrError, CreatePrRequest, PrReviewState, PullRequestInfo, RepoInfo, UnifiedPrComment, + CreatePrError, CreatePrRequest, GitProvider, PrReviewState, PullRequestInfo, RepoInfo, + UnifiedPrComment, }; /// Provider-agnostic trait for PR/MR operations. @@ -161,13 +163,455 @@ impl PrService for GitHubService { } } +/// Implement `PrService` for `GitLabService` +use crate::api::GitLabService; + +#[async_trait] +impl PrService for GitLabService { + fn provider_name(&self) -> &'static str { + "gitlab" + } + + async fn check_available(&self) -> Result { + GitLabService::check_available(self).await + } + + async fn get_authenticated_user(&self) -> Result { + GitLabService::get_authenticated_user(self).await + } + + async fn get_pr(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { + GitLabService::get_pr(self, repo_info, pr_number).await + } + + async fn is_ready_to_merge(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { + GitLabService::is_pr_ready_to_merge(self, repo_info, pr_number).await + } + + async fn get_review_state( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result { + GitLabService::get_pr_review_state(self, repo_info, pr_number).await + } + + async fn create_pr( + &self, + repo_info: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result { + GitLabService::create_pr(self, repo_info, request, cwd).await + } + + async fn list_prs_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + GitLabService::list_prs_for_branch(self, repo_info, branch).await + } + + async fn get_all_comments( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result> { + GitLabService::get_all_pr_comments(self, repo_info, pr_number).await + } + + async fn open_in_browser(&self, repo_info: &RepoInfo, pr_number: i64) -> Result<()> { + GitLabService::open_pr_in_browser(self, repo_info, pr_number).await + } + + async fn get_comments_since( + &self, + repo_info: &RepoInfo, + pr_number: i64, + since: chrono::DateTime, + ) -> Result> { + GitLabService::get_comments_since(self, repo_info, pr_number, since).await + } + + async fn find_pr_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + GitLabService::find_pr_for_branch(self, repo_info, branch).await + } +} + +/// Error returned when a provider has no operational `PrService` yet. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{provider} is detect-only; operations not yet supported")] +pub struct UnsupportedProviderError { + provider: GitProvider, +} + +/// Build the `PrService` for a given provider. +/// +/// GitHub and GitLab are operational; Bitbucket, Azure DevOps, Forgejo, and +/// Gitea are detect-only today (see `GitProvider::ALL`) and return +/// `UnsupportedProviderError` until their CLI stacks are implemented. +pub fn pr_service_for( + provider: GitProvider, +) -> Result, UnsupportedProviderError> { + match provider { + GitProvider::GitHub => Ok(Arc::new(GitHubService::new())), + GitProvider::GitLab => Ok(Arc::new(GitLabService::new())), + GitProvider::Bitbucket + | GitProvider::AzureDevOps + | GitProvider::Forgejo + | GitProvider::Gitea => Err(UnsupportedProviderError { provider }), + } +} + +/// A provider -> `PrService` resolver, matching `pr_service_for`'s signature. +/// Boxed so tests can inject a resolver backed by mocks instead of real CLIs. +type Resolver = + Box Result, UnsupportedProviderError> + Send + Sync>; + +/// Provider-routing `PrService`: dispatches each call to the operational +/// service for `repo_info.provider`. +/// +/// `provider_name()`, `check_available()`, and `get_authenticated_user()` +/// take no `RepoInfo`, so there's no per-call provider to route on. They +/// fall back to GitHub (the pre-router default) — `provider_name()` reports +/// `"auto"` so callers can tell it's the router rather than a concrete +/// provider. +pub struct PrServiceRouter { + resolve: Resolver, +} + +impl Default for PrServiceRouter { + fn default() -> Self { + Self::new() + } +} + +impl PrServiceRouter { + /// Create a new provider-routing PR service, backed by `pr_service_for` + pub fn new() -> Self { + Self { + resolve: Box::new(pr_service_for), + } + } + + /// Create a router backed by a custom resolver (tests inject mocks here) + #[cfg(test)] + fn with_resolver( + resolve: impl Fn(GitProvider) -> Result, UnsupportedProviderError> + + Send + + Sync + + 'static, + ) -> Self { + Self { + resolve: Box::new(resolve), + } + } +} + +#[async_trait] +impl PrService for PrServiceRouter { + fn provider_name(&self) -> &'static str { + "auto" + } + + async fn check_available(&self) -> Result { + GitHubService::new().check_available().await + } + + async fn get_authenticated_user(&self) -> Result { + GitHubService::new().get_authenticated_user().await + } + + async fn get_pr(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { + (self.resolve)(repo_info.provider)? + .get_pr(repo_info, pr_number) + .await + } + + async fn is_ready_to_merge(&self, repo_info: &RepoInfo, pr_number: i64) -> Result { + (self.resolve)(repo_info.provider)? + .is_ready_to_merge(repo_info, pr_number) + .await + } + + async fn get_review_state( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result { + (self.resolve)(repo_info.provider)? + .get_review_state(repo_info, pr_number) + .await + } + + async fn create_pr( + &self, + repo_info: &RepoInfo, + request: &CreatePrRequest, + cwd: &Path, + ) -> Result { + let service = + (self.resolve)(repo_info.provider).map_err(|e| CreatePrError::ProviderApiError { + message: e.to_string(), + })?; + service.create_pr(repo_info, request, cwd).await + } + + async fn list_prs_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + (self.resolve)(repo_info.provider)? + .list_prs_for_branch(repo_info, branch) + .await + } + + async fn get_all_comments( + &self, + repo_info: &RepoInfo, + pr_number: i64, + ) -> Result> { + (self.resolve)(repo_info.provider)? + .get_all_comments(repo_info, pr_number) + .await + } + + async fn open_in_browser(&self, repo_info: &RepoInfo, pr_number: i64) -> Result<()> { + (self.resolve)(repo_info.provider)? + .open_in_browser(repo_info, pr_number) + .await + } + + async fn get_comments_since( + &self, + repo_info: &RepoInfo, + pr_number: i64, + since: chrono::DateTime, + ) -> Result> { + (self.resolve)(repo_info.provider)? + .get_comments_since(repo_info, pr_number, since) + .await + } + + async fn find_pr_for_branch( + &self, + repo_info: &RepoInfo, + branch: &str, + ) -> Result> { + (self.resolve)(repo_info.provider)? + .find_pr_for_branch(repo_info, branch) + .await + } +} + #[cfg(test)] mod tests { use super::*; + use crate::types::pr::PrState; + use std::sync::atomic::{AtomicUsize, Ordering}; #[test] fn test_github_service_provider_name() { let service = GitHubService::new(); assert_eq!(service.provider_name(), "github"); } + + #[test] + fn test_gitlab_service_provider_name() { + let service = GitLabService::new(); + assert_eq!(service.provider_name(), "gitlab"); + } + + #[test] + fn test_pr_service_for_github() { + let service = pr_service_for(GitProvider::GitHub).unwrap(); + assert_eq!(service.provider_name(), "github"); + } + + #[test] + fn test_pr_service_for_gitlab() { + let service = pr_service_for(GitProvider::GitLab).unwrap(); + assert_eq!(service.provider_name(), "gitlab"); + } + + #[test] + fn test_pr_service_for_unsupported_provider_errors() { + let result = pr_service_for(GitProvider::Bitbucket); + let message = match result { + Ok(_) => panic!("expected UnsupportedProviderError for Bitbucket"), + Err(e) => e.to_string(), + }; + assert!(message.contains("bitbucket")); + assert!(message.contains("detect-only")); + } + + #[test] + fn test_pr_service_for_all_unsupported_providers() { + for provider in [ + GitProvider::Bitbucket, + GitProvider::AzureDevOps, + GitProvider::Forgejo, + GitProvider::Gitea, + ] { + assert!(pr_service_for(provider).is_err()); + } + } + + #[test] + fn test_router_provider_name_is_auto() { + let router = PrServiceRouter::new(); + assert_eq!(router.provider_name(), "auto"); + } + + /// Mock `PrService` that records which provider it was dispatched to. + struct MockPrService { + provider: &'static str, + calls: Arc, + } + + #[async_trait] + impl PrService for MockPrService { + fn provider_name(&self) -> &str { + self.provider + } + + async fn check_available(&self) -> Result { + Ok(true) + } + + async fn get_authenticated_user(&self) -> Result { + Ok("mock-user".to_string()) + } + + async fn get_pr(&self, _repo_info: &RepoInfo, pr_number: i64) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(PullRequestInfo { + number: pr_number, + url: format!("https://example.com/{}/pr/{pr_number}", self.provider), + state: PrState::Open, + merge_commit_sha: None, + title: None, + is_draft: false, + }) + } + + async fn is_ready_to_merge(&self, _repo_info: &RepoInfo, _pr_number: i64) -> Result { + Ok(false) + } + + async fn get_review_state( + &self, + _repo_info: &RepoInfo, + _pr_number: i64, + ) -> Result { + Ok(PrReviewState::Pending) + } + + async fn create_pr( + &self, + _repo_info: &RepoInfo, + _request: &CreatePrRequest, + _cwd: &Path, + ) -> Result { + Err(CreatePrError::ProviderApiError { + message: "mock".to_string(), + }) + } + + async fn list_prs_for_branch( + &self, + _repo_info: &RepoInfo, + _branch: &str, + ) -> Result> { + Ok(vec![]) + } + + async fn get_all_comments( + &self, + _repo_info: &RepoInfo, + _pr_number: i64, + ) -> Result> { + Ok(vec![]) + } + + async fn open_in_browser(&self, _repo_info: &RepoInfo, _pr_number: i64) -> Result<()> { + Ok(()) + } + + async fn get_comments_since( + &self, + _repo_info: &RepoInfo, + _pr_number: i64, + _since: chrono::DateTime, + ) -> Result> { + Ok(vec![]) + } + + async fn find_pr_for_branch( + &self, + _repo_info: &RepoInfo, + _branch: &str, + ) -> Result> { + Ok(None) + } + } + + /// Router backed by a resolver over two mocks, one per provider. + fn router_with_mocks(calls: Arc) -> PrServiceRouter { + let github: Arc = Arc::new(MockPrService { + provider: "github", + calls: calls.clone(), + }); + let gitlab: Arc = Arc::new(MockPrService { + provider: "gitlab", + calls, + }); + + PrServiceRouter::with_resolver(move |provider| match provider { + GitProvider::GitHub => Ok(github.clone()), + GitProvider::GitLab => Ok(gitlab.clone()), + other => Err(UnsupportedProviderError { provider: other }), + }) + } + + #[tokio::test] + async fn test_router_dispatches_to_github_mock() { + let calls = Arc::new(AtomicUsize::new(0)); + let router = router_with_mocks(calls.clone()); + let repo = RepoInfo::new(GitProvider::GitHub, "owner", "repo"); + + let pr = router.get_pr(&repo, 1).await.unwrap(); + + assert_eq!(pr.url, "https://example.com/github/pr/1"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_router_dispatches_to_gitlab_mock() { + let calls = Arc::new(AtomicUsize::new(0)); + let router = router_with_mocks(calls.clone()); + let repo = RepoInfo::new(GitProvider::GitLab, "owner", "repo"); + + let pr = router.get_pr(&repo, 2).await.unwrap(); + + assert_eq!(pr.url, "https://example.com/gitlab/pr/2"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_router_errors_for_unresolved_provider() { + let calls = Arc::new(AtomicUsize::new(0)); + let router = router_with_mocks(calls); + let repo = RepoInfo::new(GitProvider::Bitbucket, "owner", "repo"); + + let result = router.get_pr(&repo, 1).await; + + assert!(result.is_err()); + } } diff --git a/src/api/providers/repo/mod.rs b/src/api/providers/repo/mod.rs index c0907569..38f46fb2 100644 --- a/src/api/providers/repo/mod.rs +++ b/src/api/providers/repo/mod.rs @@ -3,6 +3,10 @@ //! Repository Provider trait and implementations //! //! Supports GitHub, GitLab, and Azure Repos for PR/issue status tracking. +//! +//! Legacy/experimental: this is a separate REST-polling stack, not part of +//! the `PrService` provider contract (see `crate::api::pr_service`). Slated +//! for consolidation into `PrService`. mod github; diff --git a/src/app/agents.rs b/src/app/agents.rs index 8be1b3e3..f618b57c 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -174,7 +174,7 @@ impl App { self.confirm_dialog.configure( self.config.llm_tools.providers.clone(), self.config.projects.clone(), - self.config.launch.docker.enabled, + crate::config::launchable_target_names(&self.config), self.config.launch.yolo.enabled, ); @@ -289,11 +289,19 @@ impl App { None }; + // Dialog target picker resolves by name; "local" (index 0) shields + // the launch.docker.enabled fallback so picker-off = local. + let target = crate::agents::delegator_resolution::resolve_named_target( + &self.config, + self.confirm_dialog.selected_target_name(), + ) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let options = LaunchOptions { provider: self.confirm_dialog.selected_provider().cloned(), delegator_name: None, extra_flags: Vec::new(), - docker_mode: self.confirm_dialog.docker_selected, + target, yolo_mode: self.confirm_dialog.yolo_selected, project_override, ..Default::default() diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index f77b034a..99cca30f 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -168,7 +168,7 @@ impl App { self.confirm_dialog.cycle_project(); } KeyCode::Char('d' | 'D') => { - self.confirm_dialog.toggle_docker(); + self.confirm_dialog.cycle_target(); } KeyCode::Char('a' | 'A') => { self.confirm_dialog.toggle_yolo(); @@ -202,7 +202,7 @@ impl App { self.confirm_dialog.cycle_project(); } KeyCode::Char('d' | 'D') => { - self.confirm_dialog.toggle_docker(); + self.confirm_dialog.cycle_target(); } KeyCode::Char('a' | 'A') => { self.confirm_dialog.toggle_yolo(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 28792293..44be2041 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -116,31 +116,35 @@ pub struct App { impl App { pub async fn new(mut config: Config, start_web: bool, open_ui: bool) -> Result { - // Run LLM tool detection on first startup - if !config.llm_tools.detection_complete { - tracing::info!("Detecting LLM CLI tools..."); - config.llm_tools = crate::llm::detect_all_tools(); - - // Log detected tools - for tool in &config.llm_tools.detected { - tracing::info!( - tool = %tool.name, - version = %tool.version, - path = %tool.path, - "LLM tool detected" - ); - } + // Refresh LLM tool detection every startup so config edits and + // runtime-loaded tool JSONs take effect (cached tools skip re-probe) + let refreshed = crate::llm::refresh_tool_detection(&config.llm_tools); + let detection_changed = + serde_json::to_value(&refreshed).ok() != serde_json::to_value(&config.llm_tools).ok(); + config.llm_tools = refreshed; + + // Log detected tools + for tool in &config.llm_tools.detected { + tracing::info!( + tool = %tool.name, + version = %tool.version, + path = %tool.path, + "LLM tool detected" + ); + } - // Log available providers - for provider in &config.llm_tools.providers { - tracing::debug!( - tool = %provider.tool, - model = %provider.model, - "LLM provider available" - ); - } + // Log available providers + for provider in &config.llm_tools.providers { + tracing::debug!( + tool = %provider.tool, + model = %provider.model, + "LLM provider available" + ); + } - // Save the detection results to config + // Save only when detection results changed, to avoid rewriting + // config.toml on every boot + if detection_changed { if let Err(e) = config.save() { tracing::warn!("Failed to save LLM detection results: {}", e); } diff --git a/src/app/tests.rs b/src/app/tests.rs index fb91f3f5..2c02da90 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -38,6 +38,7 @@ fn make_test_config(temp_dir: &TempDir) -> Config { supports_headless: true, }, yolo_flags: vec!["--dangerously-skip-permissions".to_string()], + health_ok: true, }; Config { diff --git a/src/config.rs b/src/config.rs index 5d04537f..8b40c0c1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub mod llm_tools; pub mod notifications_config; #[path = "config/sessions.rs"] pub mod sessions; +#[path = "config/targets.rs"] +pub mod targets; pub use agent_profile::*; pub use git_config::*; @@ -17,6 +19,7 @@ pub use kanban::*; pub use llm_tools::*; pub use notifications_config::*; pub use sessions::*; +pub use targets::*; use anyhow::{Context, Result}; use schemars::JsonSchema; @@ -70,6 +73,9 @@ pub struct Config { /// from `DelegatorLaunchConfig.host`. #[serde(default)] pub hosts: Vec, + /// Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. + #[serde(default)] + pub targets: Vec, /// Relay MCP injection configuration #[serde(default)] pub relay: RelayConfig, @@ -216,7 +222,7 @@ pub struct LaunchConfig { } /// Docker execution configuration for running agents in containers -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct DockerConfig { /// Whether docker mode option is available in launch dialog @@ -733,6 +739,8 @@ impl Config { ); } + validate_targets(&cfg)?; + Ok(cfg) } @@ -902,6 +910,7 @@ impl Default for Config { delegators: Vec::new(), model_servers: Vec::new(), hosts: Vec::new(), + targets: Vec::new(), relay: RelayConfig::default(), mcp: McpConfig::default(), acp: AcpConfig::default(), diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs index a8942bbf..917c0693 100644 --- a/src/config/agent_profile.rs +++ b/src/config/agent_profile.rs @@ -232,6 +232,7 @@ mod tests { prompt_suffix: Some("SUFFIX".to_string()), operator_relay: Some(true), host: Some("gpu-vm".to_string()), + target: None, }), model_server: Some("anthropic-api".to_string()), remote_agent: None, diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index a40f52ba..c98fc930 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -69,6 +69,7 @@ fn test_config_hosts_roundtrip() { ssh_alias: "gpu-vm-alias".to_string(), workdir: "/srv/agents".to_string(), display_name: None, + ssh_config_path: None, }); let json = serde_json::to_string(&config).unwrap(); let parsed: Config = serde_json::from_str(&json).unwrap(); diff --git a/src/config/git_config.rs b/src/config/git_config.rs index 525fdaaa..06558ad0 100644 --- a/src/config/git_config.rs +++ b/src/config/git_config.rs @@ -2,6 +2,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use crate::types::pr::GitProvider; + // ─── Git Provider Configuration ──────────────────────────────────────────── /// Git provider configuration for PR/MR operations @@ -14,7 +16,7 @@ pub struct GitConfig { /// GitHub-specific configuration #[serde(default)] pub github: GitHubConfig, - /// GitLab-specific configuration (planned) + /// GitLab-specific configuration #[serde(default)] pub gitlab: GitLabConfig, /// Branch naming format (e.g., "{type}/{ticket_id}-{slug}") @@ -55,6 +57,23 @@ pub enum GitProviderConfig { Bitbucket, /// Azure DevOps (dev.azure.com) AzureDevOps, + /// Forgejo (e.g. codeberg.org or self-hosted) + Forgejo, + /// Gitea (gitea.com or self-hosted) + Gitea, +} + +impl From for GitProvider { + fn from(config: GitProviderConfig) -> Self { + match config { + GitProviderConfig::GitHub => GitProvider::GitHub, + GitProviderConfig::GitLab => GitProvider::GitLab, + GitProviderConfig::Bitbucket => GitProvider::Bitbucket, + GitProviderConfig::AzureDevOps => GitProvider::AzureDevOps, + GitProviderConfig::Forgejo => GitProvider::Forgejo, + GitProviderConfig::Gitea => GitProvider::Gitea, + } + } } /// GitHub-specific configuration @@ -77,7 +96,7 @@ fn default_github_token_env() -> String { "GITHUB_TOKEN".to_string() } -/// GitLab-specific configuration (planned) +/// GitLab-specific configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] #[ts(export)] pub struct GitLabConfig { @@ -95,3 +114,36 @@ pub struct GitLabConfig { fn default_gitlab_token_env() -> String { "GITLAB_TOKEN".to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_git_provider_config_into_git_provider() { + assert_eq!( + GitProvider::from(GitProviderConfig::GitHub), + GitProvider::GitHub + ); + assert_eq!( + GitProvider::from(GitProviderConfig::GitLab), + GitProvider::GitLab + ); + assert_eq!( + GitProvider::from(GitProviderConfig::Bitbucket), + GitProvider::Bitbucket + ); + assert_eq!( + GitProvider::from(GitProviderConfig::AzureDevOps), + GitProvider::AzureDevOps + ); + assert_eq!( + GitProvider::from(GitProviderConfig::Forgejo), + GitProvider::Forgejo + ); + assert_eq!( + GitProvider::from(GitProviderConfig::Gitea), + GitProvider::Gitea + ); + } +} diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index f7724841..b099974c 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -60,6 +60,9 @@ pub struct DetectedTool { /// CLI flags for YOLO (auto-accept) mode #[serde(default)] pub yolo_flags: Vec, + /// Whether the tool passed its health check at detection on startup + #[serde(default)] + pub health_ok: bool, } /// Tool capabilities @@ -249,6 +252,9 @@ pub struct RemoteHost { /// Optional display name for UI #[serde(default)] pub display_name: Option, + /// SSH config fragment passed with `-F` (used by provisioned coder aliases) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_config_path: Option, } /// Returns the implicit builtin `ModelServer` associated with a given `llm_tool`. @@ -292,6 +298,19 @@ mod tests { assert!(d.unmapped_core.is_none()); } + #[test] + fn detected_tool_defaults_unhealthy_without_health_ok() { + // Health is earned at detection, never assumed: an entry that was never + // health-checked reads unhealthy until the startup refresh verifies it. + let json = r#"{ + "name": "claude", + "path": "/usr/local/bin/claude", + "version": "1.0.0" + }"#; + let t: DetectedTool = serde_json::from_str(json).expect("legacy tool deserializes"); + assert!(!t.health_ok); + } + #[test] fn remote_host_deserializes_from_toml() { let toml = r#" @@ -373,7 +392,8 @@ pub struct DelegatorLaunchConfig { /// Whether to create a git branch for the ticket (None = default behavior) #[serde(default)] pub create_branch: Option, - /// Run in docker container (None = use global `launch.docker.enabled`) + /// DEPRECATED: prefer `target`. Run in docker container + /// (None = fall back to `launch.docker.enabled`, then local). #[serde(default)] pub docker: Option, /// Prompt text to prepend before the generated step prompt @@ -385,8 +405,13 @@ pub struct DelegatorLaunchConfig { /// Override global relay auto-inject MCP setting per-delegator (None = use global setting) #[serde(default)] pub operator_relay: Option, - /// Name of a declared `RemoteHost` (from `Config.hosts`) to launch the agent - /// CLI on over SSH. `None` = launch locally. + /// DEPRECATED: prefer `target`. Name of a declared `RemoteHost` (from + /// `Config.hosts`) to launch the agent CLI on over SSH. `None` = local. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: Option, + /// Name of an execution target: an explicit `[[targets]]` entry, the + /// synthesized `local`/`docker` targets, or a `[[hosts]]` name. + /// Supersedes `docker` and `host`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, } diff --git a/src/config/targets.rs b/src/config/targets.rs new file mode 100644 index 00000000..9f68f203 --- /dev/null +++ b/src/config/targets.rs @@ -0,0 +1,504 @@ +//! Named execution targets — where a launched agent process runs. +//! +//! `[[targets]]` entries collapse the legacy trio of environment knobs +//! (`launch.docker` + `DelegatorLaunchConfig.docker`, `[[hosts]]` + +//! `DelegatorLaunchConfig.host`) into one registry referenced by name from +//! `DelegatorLaunchConfig.target`. Legacy inputs are synthesized into +//! `TargetDef`s at resolution time rather than branching, so the resolver has +//! exactly one output type. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::llm_tools::RemoteHost; +use super::DockerConfig; + +/// Reserved target name for the local (no-wrapper) environment. +pub const TARGET_LOCAL: &str = "local"; +/// Reserved target name synthesized from `[launch.docker]`. +pub const TARGET_DOCKER: &str = "docker"; + +/// A named execution target agents can be launched on. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct TargetDef { + /// Unique name, referenced by `DelegatorLaunchConfig.target`. + /// `local` and `docker` are reserved for synthesized targets. + pub name: String, + /// Human-readable name for UI surfaces + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Target kind and its kind-specific configuration + #[serde(flatten)] + pub kind: TargetKind, +} + +impl Default for TargetDef { + fn default() -> Self { + Self::local() + } +} + +impl TargetDef { + /// The synthesized local target: run the agent process directly. + pub fn local() -> Self { + Self { + name: TARGET_LOCAL.to_string(), + display_name: None, + kind: TargetKind::Local, + } + } + + /// The synthesized docker target from `[launch.docker]`. + pub fn docker(config: DockerConfig) -> Self { + Self { + name: TARGET_DOCKER.to_string(), + display_name: None, + kind: TargetKind::Docker(config), + } + } + + /// A synthesized ssh target from a `[[hosts]]` entry. + pub fn from_host(host: &RemoteHost) -> Self { + Self { + name: host.name.clone(), + display_name: host.display_name.clone(), + kind: TargetKind::Ssh(SshTarget { + ssh_alias: host.ssh_alias.clone(), + workdir: host.workdir.clone(), + ssh_config_path: host.ssh_config_path.clone(), + }), + } + } + + /// For ssh targets, the full `RemoteHost` the remote launch path consumes + /// (`name`/`display_name` from the def, connection details from the payload). + pub fn as_remote_host(&self) -> Option { + match &self.kind { + TargetKind::Ssh(ssh) => Some(RemoteHost { + name: self.name.clone(), + ssh_alias: ssh.ssh_alias.clone(), + workdir: ssh.workdir.clone(), + display_name: self.display_name.clone(), + ssh_config_path: ssh.ssh_config_path.clone(), + }), + _ => None, + } + } +} + +/// Execution-target kind, tagged by `kind` in config. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "lowercase")] +#[ts(export)] +pub enum TargetKind { + /// Run the agent process directly on this machine + Local, + /// Wrap the agent command in a `docker run` container. + Docker(DockerConfig), + /// Run inside a Coder workspace over a provisioned SSH alias + Coder(CoderConfig), + /// Run on a remote machine over SSH + Ssh(SshTarget), +} + +/// SSH target payload. Name and display name live on `TargetDef`; this is the +/// connection shape (`RemoteHost` minus identity). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SshTarget { + /// Host alias resolved via the user's `~/.ssh/config` (or `ssh_config_path`) + pub ssh_alias: String, + /// Absolute project root on the remote machine + pub workdir: String, + /// SSH config fragment passed with `-F` (used by provisioned coder aliases) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_config_path: Option, +} + +/// Coder workspace target: lifecycle + alias provisioning around the shared +/// SSH remote-launch path. There is no `enabled` field — presence in +/// `[[targets]]` is the enablement. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct CoderConfig { + /// Coder template child workspaces are created from (an allowlist — + /// never per-ticket input) + pub template: String, + /// Env var NAME holding the Coder deployment URL + #[serde(default = "default_coder_url_env")] + pub url_env: String, + /// Env var NAME holding the Coder session token. The variable is stripped + /// from every agent's spawn environment on all target kinds. + #[serde(default = "default_coder_token_env")] + pub token_env: String, + /// Workspace name prefix for deterministic per-ticket naming + #[serde(default = "default_coder_name_prefix")] + pub name_prefix: String, + /// Project root inside the workspace (None = workspace $HOME) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workdir: Option, + /// Stop the workspace when the ticket completes (never delete) + #[serde(default = "default_true")] + pub stop_on_complete: bool, + /// Bound on workspace create + agent-ready wait + #[serde(default = "default_coder_create_timeout_secs")] + pub create_timeout_secs: u64, + /// Control-plane-reachable `OPERATOR_API_URL` override for detached + /// multi-step (empty/None = reverse tunnel default) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback_url: Option, + /// Passthrough `-p` template parameters for `coder create` + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub parameters: std::collections::HashMap, +} + +fn default_coder_url_env() -> String { + "CODER_URL".to_string() +} + +fn default_coder_token_env() -> String { + "CODER_SESSION_TOKEN".to_string() +} + +fn default_coder_name_prefix() -> String { + "op".to_string() +} + +fn default_coder_create_timeout_secs() -> u64 { + 300 +} + +fn default_true() -> bool { + true +} + +/// Validate the target registry and every reference into it. Hard errors keep +/// startup honest (an unknown target must never silently fall back to local); +/// deprecated-combination cases warn instead so legacy configs keep working. +pub fn validate_targets(config: &super::Config) -> anyhow::Result<()> { + let mut seen = std::collections::HashSet::new(); + for target in &config.targets { + if !seen.insert(target.name.as_str()) { + anyhow::bail!("Duplicate [[targets]] name '{}'", target.name); + } + if target.name == TARGET_LOCAL || target.name == TARGET_DOCKER { + anyhow::bail!( + "[[targets]] name '{}' is reserved for the synthesized {} target", + target.name, + target.name + ); + } + if config.hosts.iter().any(|h| h.name == target.name) { + anyhow::bail!( + "[[targets]] name '{}' collides with a [[hosts]] entry of the same name", + target.name + ); + } + if let TargetKind::Docker(d) = &target.kind { + if d.enabled { + tracing::warn!( + target = %target.name, + "`enabled` is ignored inside a [[targets]] entry — presence is enablement" + ); + } + } + } + + for delegator in &config.delegators { + let Some(lc) = &delegator.launch_config else { + continue; + }; + if let Some(name) = &lc.target { + if !known_target_name(config, name) { + anyhow::bail!( + "Delegator '{}' references unknown target '{}' (known: {})", + delegator.name, + name, + known_target_names(config).join(", ") + ); + } + if lc.docker.is_some() || lc.host.is_some() { + tracing::warn!( + delegator = %delegator.name, + "launch_config sets `target` together with deprecated `docker`/`host`; \ + `target` wins" + ); + } + } else if lc.docker == Some(true) && lc.host.is_some() { + tracing::warn!( + delegator = %delegator.name, + "launch_config sets both `docker` and `host` (deprecated); host wins — \ + migrate to `target`" + ); + } + } + + Ok(()) +} + +/// Target names offered by launch pickers: `local` first, the synthesized +/// `docker` target when `launch.docker.enabled` gates it on, then explicit +/// `[[targets]]` entries and `[[hosts]]` synths. +pub fn launchable_target_names(config: &super::Config) -> Vec { + let mut names = vec![TARGET_LOCAL.to_string()]; + if config.launch.docker.enabled { + names.push(TARGET_DOCKER.to_string()); + } + names.extend(config.targets.iter().map(|t| t.name.clone())); + names.extend(config.hosts.iter().map(|h| h.name.clone())); + names +} + +/// Env-var NAMES holding Coder session tokens across all configured coder +/// targets. These are stripped from every agent's spawn environment on ALL +/// target kinds — an agent launched with a Local target inside the operator's +/// own Coder workspace would otherwise read the token straight out of `env`. +pub fn coder_token_envs(config: &super::Config) -> Vec { + let mut names: Vec = config + .targets + .iter() + .filter_map(|t| match &t.kind { + TargetKind::Coder(c) => Some(c.token_env.clone()), + _ => None, + }) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// Whether `name` resolves to any explicit or synthesized target. +pub fn known_target_name(config: &super::Config, name: &str) -> bool { + name == TARGET_LOCAL + || name == TARGET_DOCKER + || config.targets.iter().any(|t| t.name == name) + || config.hosts.iter().any(|h| h.name == name) +} + +/// All resolvable target names: explicit entries, builtins, then host synths. +pub fn known_target_names(config: &super::Config) -> Vec { + let mut names: Vec = config.targets.iter().map(|t| t.name.clone()).collect(); + names.push(TARGET_LOCAL.to_string()); + names.push(TARGET_DOCKER.to_string()); + names.extend(config.hosts.iter().map(|h| h.name.clone())); + names +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_def_docker_toml_roundtrip() { + let toml_src = r#" +name = "sandbox" +kind = "docker" +image = "ghcr.io/untra/operator:0.2.6" +"#; + let def: TargetDef = toml::from_str(toml_src).unwrap(); + assert_eq!(def.name, "sandbox"); + match &def.kind { + TargetKind::Docker(d) => assert_eq!(d.image, "ghcr.io/untra/operator:0.2.6"), + other => panic!("expected docker kind, got {other:?}"), + } + let back = toml::to_string(&def).unwrap(); + let re: TargetDef = toml::from_str(&back).unwrap(); + assert_eq!(def, re); + } + + #[test] + fn test_target_def_ssh_toml() { + let toml_src = r#" +name = "gpu-vm" +kind = "ssh" +ssh_alias = "gpu-vm" +workdir = "/home/me/proj" +"#; + let def: TargetDef = toml::from_str(toml_src).unwrap(); + match &def.kind { + TargetKind::Ssh(s) => { + assert_eq!(s.ssh_alias, "gpu-vm"); + assert_eq!(s.workdir, "/home/me/proj"); + assert_eq!(s.ssh_config_path, None); + } + other => panic!("expected ssh kind, got {other:?}"), + } + } + + #[test] + fn test_target_def_coder_toml_defaults() { + let toml_src = r#" +name = "cloud" +kind = "coder" +template = "operator-agent" +"#; + let def: TargetDef = toml::from_str(toml_src).unwrap(); + match &def.kind { + TargetKind::Coder(c) => { + assert_eq!(c.template, "operator-agent"); + assert_eq!(c.url_env, "CODER_URL"); + assert_eq!(c.token_env, "CODER_SESSION_TOKEN"); + assert_eq!(c.name_prefix, "op"); + assert!(c.stop_on_complete); + assert_eq!(c.create_timeout_secs, 300); + assert!(c.callback_url.is_none()); + assert!(c.parameters.is_empty()); + } + other => panic!("expected coder kind, got {other:?}"), + } + } + + #[test] + fn test_target_def_local_toml() { + let def: TargetDef = toml::from_str("name = \"here\"\nkind = \"local\"\n").unwrap(); + assert_eq!(def.kind, TargetKind::Local); + } + + #[test] + fn test_target_def_unknown_kind_errors() { + let result: Result = toml::from_str("name = \"x\"\nkind = \"lambda\"\n"); + assert!(result.is_err(), "unknown kind must be a hard parse error"); + } + + #[test] + fn test_target_def_missing_kind_errors() { + let result: Result = toml::from_str("name = \"x\"\n"); + assert!(result.is_err(), "missing kind must be a hard parse error"); + } + + #[test] + fn test_from_host_and_as_remote_host_roundtrip() { + let host = RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu-alias".to_string(), + workdir: "/home/me/proj".to_string(), + display_name: Some("GPU VM".to_string()), + ssh_config_path: None, + }; + let def = TargetDef::from_host(&host); + assert_eq!(def.name, "gpu-vm"); + assert_eq!(def.as_remote_host(), Some(host)); + } + + #[test] + fn test_as_remote_host_none_for_non_ssh() { + assert_eq!(TargetDef::local().as_remote_host(), None); + } + + #[test] + fn test_default_target_is_local() { + assert_eq!(TargetDef::default(), TargetDef::local()); + } + + fn config_with_targets(targets: Vec) -> crate::config::Config { + crate::config::Config { + targets, + ..Default::default() + } + } + + fn ssh_def(name: &str) -> TargetDef { + TargetDef { + name: name.to_string(), + display_name: None, + kind: TargetKind::Ssh(SshTarget { + ssh_alias: name.to_string(), + workdir: "/proj".to_string(), + ssh_config_path: None, + }), + } + } + + #[test] + fn test_validate_targets_duplicate_names_error() { + let config = config_with_targets(vec![ssh_def("a"), ssh_def("a")]); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("Duplicate"), "{err}"); + } + + #[test] + fn test_validate_targets_reserved_names_error() { + for reserved in [TARGET_LOCAL, TARGET_DOCKER] { + let config = config_with_targets(vec![ssh_def(reserved)]); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("reserved"), "{err}"); + } + } + + #[test] + fn test_validate_targets_host_collision_error() { + let mut config = config_with_targets(vec![ssh_def("gpu-vm")]); + config.hosts.push(RemoteHost { + name: "gpu-vm".to_string(), + ssh_alias: "gpu".to_string(), + workdir: "/p".to_string(), + display_name: None, + ssh_config_path: None, + }); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("collides"), "{err}"); + } + + #[test] + fn test_validate_targets_unknown_delegator_reference_error() { + let mut config = config_with_targets(vec![]); + config.delegators.push(crate::config::Delegator { + name: "heavy".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(crate::config::DelegatorLaunchConfig { + target: Some("nope".to_string()), + ..Default::default() + }), + model_server: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("unknown target 'nope'"), "{err}"); + assert!( + err.contains("local"), + "error should list known names: {err}" + ); + } + + #[test] + fn test_validate_targets_builtin_and_host_references_ok() { + let mut config = config_with_targets(vec![ssh_def("gpu-vm")]); + config.hosts.push(RemoteHost { + name: "legacy-host".to_string(), + ssh_alias: "l".to_string(), + workdir: "/p".to_string(), + display_name: None, + ssh_config_path: None, + }); + for name in ["local", "docker", "gpu-vm", "legacy-host"] { + config.delegators = vec![crate::config::Delegator { + name: "d".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: Some(crate::config::DelegatorLaunchConfig { + target: Some(name.to_string()), + ..Default::default() + }), + model_server: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }]; + assert!( + validate_targets(&config).is_ok(), + "'{name}' should be a known target name" + ); + } + } +} diff --git a/src/docs_gen/cli.rs b/src/docs_gen/cli.rs index aac93cc8..52cb83df 100644 --- a/src/docs_gen/cli.rs +++ b/src/docs_gen/cli.rs @@ -29,7 +29,6 @@ impl DocGenerator for CliDocGenerator { let mut output = format_header("CLI Reference", self.source()); // Introduction - output.push_str(&heading(1, "CLI Reference")); output.push_str( "Operator provides both a TUI dashboard and CLI commands for queue management.\n\n", ); @@ -222,7 +221,8 @@ mod tests { assert!(result.contains("main.rs")); // Should have the main heading - assert!(result.contains("# CLI Reference")); + assert!(result.contains("title: \"CLI Reference\"")); + assert!(!result.contains("\n# CLI Reference")); // Should have global options section assert!(result.contains("## Global Options")); diff --git a/src/docs_gen/config.rs b/src/docs_gen/config.rs index fa0c2648..f6f0058d 100644 --- a/src/docs_gen/config.rs +++ b/src/docs_gen/config.rs @@ -100,7 +100,6 @@ impl DocGenerator for ConfigDocGenerator { let mut output = format_header("Configuration", self.source()); // Introduction - output.push_str(&heading(1, "Configuration")); output.push_str("Operator configuration is stored in `.tickets/operator/config.toml`.\n\n"); // Quick reference table of all sections @@ -388,7 +387,8 @@ mod tests { assert!(result.contains("config.rs")); // Should have the main heading - assert!(result.contains("# Configuration")); + assert!(result.contains("title: \"Configuration\"")); + assert!(!result.contains("\n# Configuration\n")); // Should have sections overview assert!(result.contains("## Configuration Sections")); diff --git a/src/docs_gen/config_schema.rs b/src/docs_gen/config_schema.rs index 70b04802..2abfcadd 100644 --- a/src/docs_gen/config_schema.rs +++ b/src/docs_gen/config_schema.rs @@ -31,7 +31,6 @@ impl DocGenerator for ConfigSchemaDocGenerator { let mut output = format_header("Configuration Schema", self.source()); // Title and description - output.push_str(&heading(1, "Configuration Schema")); output.push_str("JSON Schema for the Operator configuration file (`config.toml`).\n\n"); // Schema metadata @@ -220,7 +219,8 @@ mod tests { assert!(result.contains("config.json")); // Should have the main heading - assert!(result.contains("# Configuration Schema")); + assert!(result.contains("title: \"Configuration Schema\"")); + assert!(!result.contains("\n# Configuration Schema")); // Should have required fields section assert!(result.contains("## Required Fields")); diff --git a/src/docs_gen/integrations.rs b/src/docs_gen/integrations.rs index 37636392..94e4ae04 100644 --- a/src/docs_gen/integrations.rs +++ b/src/docs_gen/integrations.rs @@ -41,8 +41,7 @@ impl DocGenerator for MaturityDocGenerator { let mut content = format_header("Feature Maturity", self.source()); content.push_str( - "# Feature Maturity\n\n\ - Operator integrates with many providers and tools across several **verticals**. \ + "Operator integrates with many providers and tools across several **verticals**. \ Each integration carries an official **support status** so you know what to expect \ before you depend on it. This page is generated from the same source of truth that \ drives the README badges and the `/api/v1/integrations` API, so it always reflects \ @@ -105,7 +104,8 @@ mod tests { #[test] fn test_maturity_content_has_legend_and_tables() { let content = MaturityDocGenerator.generate().unwrap(); - assert!(content.contains("# Feature Maturity")); + assert!(content.contains("title: \"Feature Maturity\"")); + assert!(!content.contains("\n# Feature Maturity")); assert!(content.contains("## Support levels")); // Legend badges for all four levels. for status in SupportStatus::ALL { diff --git a/src/docs_gen/issuetype.rs b/src/docs_gen/issuetype.rs index 99a8092c..32d63f31 100644 --- a/src/docs_gen/issuetype.rs +++ b/src/docs_gen/issuetype.rs @@ -32,7 +32,6 @@ impl DocGenerator for IssuetypeSchemaDocGenerator { let mut output = format_header("Issue Type Schema", self.source()); // Title and description - output.push_str(&heading(1, "Issue Type Schema")); if let Some(desc) = schema.get("description").and_then(|d| d.as_str()) { output.push_str(&format!("{desc}\n\n")); @@ -279,7 +278,8 @@ mod tests { assert!(result.contains("TemplateSchema")); // Should have the main heading - assert!(result.contains("# Issue Type Schema")); + assert!(result.contains("title: \"Issue Type Schema\"")); + assert!(!result.contains("\n# Issue Type Schema")); // Should have required fields section assert!(result.contains("## Required Fields")); diff --git a/src/docs_gen/jira_api.rs b/src/docs_gen/jira_api.rs index dd520328..63333672 100644 --- a/src/docs_gen/jira_api.rs +++ b/src/docs_gen/jira_api.rs @@ -33,7 +33,6 @@ impl DocGenerator for JiraApiDocGenerator { let mut output = format_header("Jira API Reference", self.source()); // Title and description - output.push_str(&heading(1, "Jira API Reference")); output.push_str("Auto-generated documentation of Jira Cloud REST API response types used by Operator.\n\n"); // Overview diff --git a/src/docs_gen/llm_tools.rs b/src/docs_gen/llm_tools.rs index ea393e8a..ced253fa 100644 --- a/src/docs_gen/llm_tools.rs +++ b/src/docs_gen/llm_tools.rs @@ -41,11 +41,11 @@ Operator supports multiple LLM CLI tools through a plugin-like configuration sys ## Adding a New Tool -To add support for a new LLM CLI tool, create a JSON configuration file in `src/llm/tools/`: +To add support for a new LLM CLI tool, drop a JSON configuration file into your +user tool-config directory — no rebuild required: -### 1. Create the Configuration File - -Create `src/llm/tools/.json`: +- Linux: `~/.config/operator/tools/.json` +- macOS: `~/Library/Application Support/operator/tools/.json` ```json { @@ -67,17 +67,48 @@ Create `src/llm/tools/.json`: } ``` -### 2. Register the Tool +Configs are loaded fresh on every startup. A user config whose `tool_name` matches a builtin (claude, gemini, codex) **fully replaces** that builtin — it +is not merged field-by-field. Malformed files are skipped with a logged warning. Runtime-loaded tools work everywhere the builtins do, including +remote (SSH) launches, where the tool's presence on the remote host is verified by a `command -v` preflight. + +> **Security note:** `command_template` is arbitrary shell executed at launch. +> Operator only ever loads tool configs from the user-global config directory — +> never from repository-local paths — so a cloned repo cannot inject a tool +> config. + +New *builtin* tools (shipped with Operator) are instead added as embedded JSONs +in `src/llm/tools/` and registered in the `BUILTIN_TOOL_CONFIGS` list in +`src/llm/tool_config.rs`. -Add the tool to `src/llm/tool_config.rs` in the `load_all_tool_configs()` function: +## Detection Modes -```rust -// Load YourTool config -if let Ok(config) = serde_json::from_str::(include_str!("tools/your-tool.json")) { - configs.push(config); +By default a tool is detected only when `which ` succeeds. The +optional `detection` object overrides this: + +```json +{ + "detection": { "mode": "always", "health_command": "your-tool ping" } } ``` +| Field | Values | Description | +|-------|--------|-------------| +| `mode` | `which` (default), `always` | `always` skips the PATH lookup and uses `tool_name` verbatim as the invocation path — for tools not installed locally (e.g. run over SSH) | +| `health_command` | any command | Health check run at every startup; failure marks the tool unhealthy (`health_ok: false`) | + +Health is **earned, never assumed**, and re-verified on every startup: + +| Mode | No `health_command` | With `health_command` | +|------|---------------------|-----------------------| +| `which` | Healthy — the PATH lookup proves the binary is present | Healthy if still on PATH **and** the command passes | +| `always` | **Unhealthy** — nothing is locally verifiable | Healthy if the command passes | + +An unhealthy tool stays listed in the detected tools (so you can see it and why), +but launching a local agent with it fails until it is healthy again. Remote (SSH) +launches are unaffected — they are gated by their own `command -v` preflight on +the remote host. An `always`-mode tool should therefore define a `health_command` +that proves reachability, e.g. `ssh gpu-vm command -v agy`. + ## Configuration Schema ### Required Fields @@ -97,6 +128,8 @@ if let Ok(config) = serde_json::from_str::(include_str!("tools/your- |-------|------|---------|-------------| | `display_name` | string | tool_name | Human-readable name for UI | | `yolo_flags` | array | [] | Flags for auto-accept/YOLO mode | +| `detection` | object | which-gated | Detection mode + soft health check (see Detection Modes) | +| `idle_detection` | object | - | Idle/activity regex patterns + completion hook config | | `permission_modes` | array | - | Supported permission modes (Claude-specific) | ### Capabilities Object @@ -182,13 +215,21 @@ In the TUI, running agents show a tool indicator: ## Detection Process -On startup, Operator: - -1. Loads all tool configurations from `src/llm/tools/*.json` -2. For each tool, runs `which ` to check if installed -3. If found, runs the `version_command` to verify and get version -4. Builds a list of available providers (tool + model combinations) -5. The first detected tool becomes the default provider +On every startup, Operator: + +1. Loads the embedded builtin tool configurations, then user configurations from `/operator/tools/*.json` +2. For each tool, runs `which ` to check if installed (skipped when `detection.mode` is `always`) +3. If found, runs the `version_command` to get the version (failure degrades to `"unknown"`; a `min_version` mismatch warns but does not block) +4. Computes health from the verified presence plus the `health_command`, if configured (see Detection Modes); an unhealthy tool stays listed but cannot launch locally +5. Builds a list of available providers (tool + model combinations) +6. The first detected tool becomes the default provider + +Already-detected tools keep their cached `path`/`version` across restarts (no +version re-probing); config-sourced fields like the command template and model +aliases are re-derived from the loaded configs each startup. Health is never +carried over from a previous run — presence and the `health_command` are +re-checked every startup, so an uninstalled binary or a newly failing health +command demotes the tool on the next launch of Operator. ## Troubleshooting diff --git a/src/docs_gen/llms.rs b/src/docs_gen/llms.rs index 7914c804..e70ef4b7 100644 --- a/src/docs_gen/llms.rs +++ b/src/docs_gen/llms.rs @@ -83,6 +83,11 @@ const SECTIONS: &[Section] = &[ slug: "delegators", fallback_desc: "", }, + Link { + slug: "getting-started/sessions/remote-hosts", + fallback_desc: + "Run launched agents on remote machines over SSH (execution targets).", + }, ], extra: &[], }, diff --git a/src/docs_gen/metadata.rs b/src/docs_gen/metadata.rs index bd546500..f7a6743f 100644 --- a/src/docs_gen/metadata.rs +++ b/src/docs_gen/metadata.rs @@ -29,7 +29,6 @@ impl DocGenerator for MetadataSchemaDocGenerator { let mut output = format_header("Ticket Metadata Schema", self.source()); // Title and description - output.push_str(&heading(1, "Ticket Metadata Schema")); if let Some(desc) = schema.get("description").and_then(|d| d.as_str()) { output.push_str(&format!("{desc}\n\n")); @@ -315,7 +314,8 @@ mod tests { assert!(result.contains("ticket_metadata.schema.json")); // Should have the main heading - assert!(result.contains("# Ticket Metadata Schema")); + assert!(result.contains("title: \"Ticket Metadata Schema\"")); + assert!(!result.contains("\n# Ticket Metadata Schema")); // Should have required fields section assert!(result.contains("## Required Fields")); diff --git a/src/docs_gen/schema_index.rs b/src/docs_gen/schema_index.rs index 655efe32..289fb7d9 100644 --- a/src/docs_gen/schema_index.rs +++ b/src/docs_gen/schema_index.rs @@ -25,7 +25,6 @@ impl DocGenerator for SchemaIndexDocGenerator { fn generate(&self) -> Result { let mut output = format_header("Schema Reference", self.source()); - output.push_str(&heading(1, "Schema Reference")); output.push_str( "This section documents all JSON schemas and type definitions used by Operator.\n\n", ); @@ -100,8 +99,8 @@ impl DocGenerator for SchemaIndexDocGenerator { output.push_str(&heading(2, "TypeScript Types")); output.push_str( "TypeScript type definitions are available for frontend integration:\n\n\ - - [TypeScript API Documentation](/typescript/) - Generated via TypeDoc\n\ - - Source: `shared/types.ts` (generated via ts-rs)\n\n", + - Source: `shared/types.ts` (generated via ts-rs)\n\ + - API docs can be generated locally with `npm run docs:typescript`\n\n", ); // Regeneration instructions @@ -134,8 +133,9 @@ mod tests { // Should have the auto-generated header assert!(result.contains("AUTO-GENERATED FROM")); - // Should have the main heading - assert!(result.contains("# Schema Reference")); + // Title lives in front matter only; the doc layout renders it + assert!(result.contains("title: \"Schema Reference\"")); + assert!(!result.contains("\n# Schema Reference")); // Should list all schema pages assert!(result.contains("[Configuration](config/)")); diff --git a/src/docs_gen/shortcuts.rs b/src/docs_gen/shortcuts.rs index b84cbd19..4597cba4 100644 --- a/src/docs_gen/shortcuts.rs +++ b/src/docs_gen/shortcuts.rs @@ -25,7 +25,6 @@ impl DocGenerator for ShortcutsDocGenerator { let mut output = format_header("Keyboard Shortcuts", self.source()); // Introduction - output.push_str(&heading(1, "Keyboard Shortcuts")); output.push_str("Operator uses vim-style keybindings for navigation and actions. "); output.push_str("This reference documents all available keyboard shortcuts.\n\n"); @@ -103,7 +102,8 @@ mod tests { assert!(result.contains("keybindings.rs")); // Should have the main heading - assert!(result.contains("# Keyboard Shortcuts")); + assert!(result.contains("title: \"Keyboard Shortcuts\"")); + assert!(!result.contains("\n# Keyboard Shortcuts")); // Should have quick reference section assert!(result.contains("## Quick Reference")); diff --git a/src/docs_gen/startup.rs b/src/docs_gen/startup.rs index 0990a94d..04524689 100644 --- a/src/docs_gen/startup.rs +++ b/src/docs_gen/startup.rs @@ -25,7 +25,6 @@ impl DocGenerator for StartupDocGenerator { let mut output = format_header("Setup Wizard", self.source()); // Introduction - output.push_str(&heading(1, "Setup Wizard")); output.push_str("When Operator starts and no `.tickets/` directory exists, "); output.push_str("the setup wizard guides you through first-time initialization. "); output.push_str("This reference documents each step of the wizard.\n\n"); @@ -99,7 +98,8 @@ mod tests { assert!(result.contains("startup/mod.rs")); // Should have the main heading - assert!(result.contains("# Setup Wizard")); + assert!(result.contains("title: \"Setup Wizard\"")); + assert!(!result.contains("\n# Setup Wizard")); // Should have overview section assert!(result.contains("## Steps Overview")); diff --git a/src/docs_gen/state_schema.rs b/src/docs_gen/state_schema.rs index aa7bb83b..00e832c5 100644 --- a/src/docs_gen/state_schema.rs +++ b/src/docs_gen/state_schema.rs @@ -31,7 +31,6 @@ impl DocGenerator for StateSchemaDocGenerator { let mut output = format_header("Application State Schema", self.source()); // Title and description - output.push_str(&heading(1, "Application State Schema")); output.push_str( "JSON Schema for the Operator runtime state file (`state.json`).\n\n\ This file tracks the current state of agents, completed tickets, and system status.\n\n", @@ -215,7 +214,8 @@ mod tests { assert!(result.contains("state.json")); // Should have the main heading - assert!(result.contains("# Application State Schema")); + assert!(result.contains("title: \"Application State Schema\"")); + assert!(!result.contains("\n# Application State Schema")); // Should have required fields section assert!(result.contains("## Required Fields")); diff --git a/src/docs_gen/taxonomy.rs b/src/docs_gen/taxonomy.rs index 995d1ea6..714a04b5 100644 --- a/src/docs_gen/taxonomy.rs +++ b/src/docs_gen/taxonomy.rs @@ -26,7 +26,6 @@ impl DocGenerator for TaxonomyDocGenerator { let mut output = format_header("Project Taxonomy", self.source()); // Introduction - output.push_str(&heading(1, "Project Taxonomy")); output.push_str(&format!( "This document defines the **{} project Kinds** organized into **{} tiers**.\n\n", taxonomy.kinds.len(), @@ -232,7 +231,8 @@ mod tests { assert!(result.contains("taxonomy.toml")); // Should have the main heading - assert!(result.contains("# Project Taxonomy")); + assert!(result.contains("title: \"Project Taxonomy\"")); + assert!(!result.contains("\n# Project Taxonomy")); // Should mention project Kinds (flexible - any count) assert!(result.contains("project Kinds")); diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index a31e043d..810029be 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -32,11 +32,12 @@ pub enum Vertical { Platform, Integration, Workflows, + Notification, } impl Vertical { /// All verticals, in README display order. - pub const ALL: [Vertical; 9] = [ + pub const ALL: [Vertical; 10] = [ Vertical::Kanban, Vertical::Model, Vertical::Git, @@ -46,6 +47,7 @@ impl Vertical { Vertical::Platform, Vertical::Integration, Vertical::Workflows, + Vertical::Notification, ]; /// Stable lowercase slug (wire id for the REST DTO). @@ -60,6 +62,7 @@ impl Vertical { Vertical::Platform => "platform", Vertical::Integration => "integration", Vertical::Workflows => "workflows", + Vertical::Notification => "notification", } } @@ -75,6 +78,24 @@ impl Vertical { Vertical::Platform => "Platform", Vertical::Integration => "Integration", Vertical::Workflows => "Workflow Export Format", + Vertical::Notification => "Notification Channel", + } + } + + /// Docs section directory (site-root-relative) that hosts this vertical's + /// entry pages — the sidebar nav item URL and the section `index.md`. + /// `Session` and `Editor` deliberately share one section. + pub fn docs_section(&self) -> &'static str { + match self { + Vertical::Kanban => "getting-started/kanban", + Vertical::Model => "getting-started/model-servers", + Vertical::Git => "getting-started/git", + Vertical::Session | Vertical::Editor => "getting-started/sessions", + Vertical::LlmTool => "getting-started/agents", + Vertical::Platform => "getting-started/platforms", + Vertical::Integration => "getting-started/integrations", + Vertical::Workflows => "getting-started/workflows", + Vertical::Notification => "getting-started/notifications", } } } @@ -93,6 +114,10 @@ pub struct CatalogEntry { /// `getting-started/kanban/jira`), or `None` if undocumented. Drives both /// the docs link and the expected README badge URL. pub docs_path: Option<&'static str>, + /// Brand icon filename stem in `docs/assets/icons/{icon}.svg`, or `None` + /// when no brand icon ships. Drives the sidebar nav `icon:` values + /// (enforced by `tests/docs_structure.rs`). + pub icon: Option<&'static str>, /// Whether this entry carries a curated README badge. pub readme_badge: bool, /// Official support / maturity status. @@ -115,7 +140,8 @@ impl CatalogEntry { pub fn all_integrations() -> Vec { use SupportStatus::{Alpha, Beta, Ga, Proto}; use Vertical::{ - Editor, Git, Integration, Kanban, LlmTool, Model, Platform, Session, Workflows, + Editor, Git, Integration, Kanban, LlmTool, Model, Notification, Platform, Session, + Workflows, }; vec![ // --- Kanban providers (mirror KanbanProviderType::ALL) --- @@ -124,6 +150,7 @@ pub fn all_integrations() -> Vec { "jira", "Jira", Some("getting-started/kanban/jira"), + Some("jira"), true, Beta, ), @@ -132,6 +159,7 @@ pub fn all_integrations() -> Vec { "linear", "Linear", Some("getting-started/kanban/linear"), + Some("linear"), true, Beta, ), @@ -140,6 +168,7 @@ pub fn all_integrations() -> Vec { "github", "GitHub Projects", Some("getting-started/kanban/github"), + Some("github"), true, Beta, ), @@ -148,6 +177,7 @@ pub fn all_integrations() -> Vec { "openspec", "OpenSpec", Some("getting-started/kanban/openspec"), + Some("openspec"), false, Alpha, ), @@ -157,6 +187,7 @@ pub fn all_integrations() -> Vec { "anthropic-api", "Anthropic", Some("getting-started/model-servers/anthropic"), + Some("anthropic"), true, Beta, ), @@ -165,6 +196,7 @@ pub fn all_integrations() -> Vec { "openai-api", "OpenAI", Some("getting-started/model-servers/openai"), + Some("openai"), true, Beta, ), @@ -173,6 +205,7 @@ pub fn all_integrations() -> Vec { "google-api", "Google", Some("getting-started/model-servers/google"), + Some("google"), true, Alpha, ), @@ -181,6 +214,7 @@ pub fn all_integrations() -> Vec { "ollama", "Ollama", Some("getting-started/model-servers/ollama"), + Some("ollama"), true, Beta, ), @@ -189,6 +223,7 @@ pub fn all_integrations() -> Vec { "openrouter", "OpenRouter", Some("getting-started/model-servers/openrouter"), + Some("openrouter"), true, Beta, ), @@ -197,16 +232,18 @@ pub fn all_integrations() -> Vec { "openai-compat", "OpenAI-compatible", None, + None, false, Proto, ), - entry(Model, "lmstudio", "LM Studio", None, false, Proto), + entry(Model, "lmstudio", "LM Studio", None, None, false, Proto), // --- Git providers (mirror GitProvider::ALL) --- entry( Git, "github", "GitHub", Some("getting-started/git/github"), + Some("github"), true, Beta, ), @@ -215,17 +252,21 @@ pub fn all_integrations() -> Vec { "gitlab", "GitLab", Some("getting-started/git/gitlab"), + Some("gitlab"), true, Alpha, ), - entry(Git, "bitbucket", "Bitbucket", None, false, Proto), - entry(Git, "azure", "Azure DevOps", None, false, Proto), + entry(Git, "bitbucket", "Bitbucket", None, None, false, Proto), + entry(Git, "azure", "Azure DevOps", None, None, false, Proto), + entry(Git, "forgejo", "Forgejo", None, None, false, Proto), + entry(Git, "gitea", "Gitea", None, None, false, Proto), // --- Session wrappers (mirror SessionWrapperType::ALL; vscode lives under Editor) --- entry( Session, "tmux", "tmux", Some("getting-started/sessions/tmux"), + Some("tmux"), true, Beta, ), @@ -234,6 +275,7 @@ pub fn all_integrations() -> Vec { "cmux", "cmux", Some("getting-started/sessions/cmux"), + Some("cmux"), true, Beta, ), @@ -242,6 +284,7 @@ pub fn all_integrations() -> Vec { "zellij", "Zellij", Some("getting-started/sessions/zellij"), + Some("zellij"), true, Beta, ), @@ -251,6 +294,7 @@ pub fn all_integrations() -> Vec { "vscode", "VS Code", Some("getting-started/sessions/vscode"), + Some("vscode"), true, Beta, ), @@ -259,6 +303,7 @@ pub fn all_integrations() -> Vec { "zed", "Zed", Some("getting-started/sessions/zed"), + Some("zed"), true, Alpha, ), @@ -267,6 +312,7 @@ pub fn all_integrations() -> Vec { "cursor", "Cursor", Some("getting-started/sessions/cursor"), + Some("cursor"), false, Proto, ), @@ -276,6 +322,7 @@ pub fn all_integrations() -> Vec { "claude", "Claude", Some("getting-started/agents/claude"), + Some("claude"), true, Ga, ), @@ -284,6 +331,7 @@ pub fn all_integrations() -> Vec { "codex", "Codex", Some("getting-started/agents/codex"), + Some("codex"), true, Beta, ), @@ -292,6 +340,7 @@ pub fn all_integrations() -> Vec { "gemini-cli", "Gemini CLI", Some("getting-started/agents/gemini-cli"), + Some("gemini"), true, Alpha, ), @@ -301,6 +350,7 @@ pub fn all_integrations() -> Vec { "docker", "Docker", Some("getting-started/platforms/docker"), + Some("docker"), true, Beta, ), @@ -309,6 +359,7 @@ pub fn all_integrations() -> Vec { "coder", "Coder", Some("getting-started/platforms/coder"), + Some("coder"), true, Alpha, ), @@ -318,6 +369,7 @@ pub fn all_integrations() -> Vec { "agnt", "AGNT", Some("getting-started/integrations/agnt"), + Some("agnt"), false, Alpha, ), @@ -327,6 +379,7 @@ pub fn all_integrations() -> Vec { "claude", "Claude Workflow", Some("getting-started/workflows/claude"), + Some("claude"), true, Ga, ), @@ -335,9 +388,29 @@ pub fn all_integrations() -> Vec { "agnt", "AGNT Workflow", Some("getting-started/workflows/agnt"), + Some("agnt"), true, Alpha, ), + // --- Notification channels --- + entry( + Notification, + "os", + "Operating System", + Some("getting-started/notifications/os"), + Some("notification"), + true, + Beta, + ), + entry( + Notification, + "webhooks", + "Webhooks", + Some("getting-started/notifications/webhooks"), + Some("webhook"), + true, + Beta, + ), ] } @@ -354,6 +427,7 @@ fn entry( slug: &'static str, label: &'static str, docs_path: Option<&'static str>, + icon: Option<&'static str>, readme_badge: bool, status: SupportStatus, ) -> CatalogEntry { @@ -362,6 +436,7 @@ fn entry( slug, label, docs_path, + icon, readme_badge, status, } diff --git a/src/issuetypes/schema.rs b/src/issuetypes/schema.rs index e3a6459f..40fdaca9 100644 --- a/src/issuetypes/schema.rs +++ b/src/issuetypes/schema.rs @@ -166,6 +166,7 @@ impl IssueType { allowed_tools: vec!["*".to_string()], review_type: ReviewType::None, visual_config: None, + proof_config: None, on_reject: None, next_step: None, permissions: None, @@ -351,6 +352,7 @@ mod tests { allowed_tools: vec!["*".to_string()], review_type: ReviewType::None, visual_config: None, + proof_config: None, on_reject: None, next_step: None, permissions: None, diff --git a/src/llm/detection.rs b/src/llm/detection.rs index 42579c71..8ea0bb49 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -8,17 +8,38 @@ use std::process::Command; use crate::config::{DetectedTool, LlmProvider, LlmToolsConfig, ToolCapabilities}; -use super::tool_config::{load_all_tool_configs, ToolConfig}; +use super::tool_config::{load_all_tool_configs, DetectionMode, ToolConfig}; -/// Detect all available LLM CLI tools and build the config +/// Detect all available LLM CLI tools and build the config from scratch #[allow(dead_code)] // Used via binary, not reachable from lib.rs pub fn detect_all_tools() -> LlmToolsConfig { - let tool_configs = load_all_tool_configs(); + refresh_tool_detection(&LlmToolsConfig::default()) +} + +/// Rebuild detection state from the currently loaded tool configs, preserving +/// user prefs. Cached entries keep their probed fields (`path`, `version` — no +/// process spawns); config-sourced fields are re-derived so config edits and +/// runtime-loaded tools take effect every startup. Tools whose config no longer +/// exists are dropped; new configs are probed fresh. +#[allow(dead_code)] // Used via binary, not reachable from lib.rs +pub fn refresh_tool_detection(existing: &LlmToolsConfig) -> LlmToolsConfig { + refresh_with_configs(existing, &load_all_tool_configs()) +} + +fn refresh_with_configs(existing: &LlmToolsConfig, configs: &[ToolConfig]) -> LlmToolsConfig { let mut detected = Vec::new(); let mut providers = Vec::new(); - for config in tool_configs { - if let Some(tool) = detect_tool(&config) { + for config in configs { + let tool = match existing + .detected + .iter() + .find(|t| t.name == config.tool_name) + { + Some(cached) => Some(refresh_cached_tool(cached, config)), + None => detect_tool(config), + }; + if let Some(tool) = tool { // Build provider pairs from tool + each model alias for model in &config.model_aliases { providers.push(LlmProvider { @@ -36,16 +57,51 @@ pub fn detect_all_tools() -> LlmToolsConfig { detected, providers, detection_complete: true, - skill_directory_overrides: std::collections::HashMap::new(), - default_tool: None, - default_model: None, + default_tool: existing.default_tool.clone(), + default_model: existing.default_model.clone(), + skill_directory_overrides: existing.skill_directory_overrides.clone(), + } +} + +/// Re-derive config-sourced fields on a cached tool, keeping its probed +/// `path`/`version` (no version re-spawn). Health is always recomputed — a +/// cached `health_ok` is never trusted — so an uninstalled binary or a newly +/// failing health command demotes the tool on the next startup. Also repairs +/// partial entries written by external detectors (e.g. the VS Code extension +/// caches only name/path/version). +fn refresh_cached_tool(cached: &DetectedTool, config: &ToolConfig) -> DetectedTool { + let version_ok = match &config.min_version { + Some(min_ver) => check_version_meets_minimum(&cached.version, min_ver), + None => true, + }; + let presence_verified = config.detection_mode() == DetectionMode::Which + && get_binary_path(&config.tool_name).is_some(); + DetectedTool { + name: config.tool_name.clone(), + path: cached.path.clone(), + version: cached.version.clone(), + min_version: config.min_version.clone(), + version_ok, + model_aliases: config.model_aliases.clone(), + command_template: config.command_template.clone(), + capabilities: ToolCapabilities { + supports_sessions: config.capabilities.supports_sessions, + supports_headless: config.capabilities.supports_headless, + }, + yolo_flags: config.yolo_flags.clone(), + health_ok: check_health(config, presence_verified), } } /// Detect a single tool from its config fn detect_tool(config: &ToolConfig) -> Option { - // Check if binary exists - let path = get_binary_path(&config.tool_name)?; + let mode = config.detection_mode(); + let path = match mode { + DetectionMode::Which => get_binary_path(&config.tool_name)?, + DetectionMode::Always => config.tool_name.clone(), + }; + // The which gate above succeeding *is* the presence proof. + let presence_verified = mode == DetectionMode::Which; let version = get_version(&config.version_command).unwrap_or_else(|| "unknown".to_string()); // Check if installed version meets minimum requirement @@ -76,9 +132,63 @@ fn detect_tool(config: &ToolConfig) -> Option { supports_headless: config.capabilities.supports_headless, }, yolo_flags: config.yolo_flags.clone(), + health_ok: check_health(config, presence_verified), }) } +/// Verify one tool's health right now, against its current config. Used at launch time by surfaces +/// that never ran startup detection (`launch`, `api`, `mcp`, `acp`), so a stale or absent `health_ok` +/// in config.toml cannot permanently block a tool that is actually installed and working. +pub fn verify_tool_health(tool_name: &str) -> bool { + load_all_tool_configs() + .iter() + .find(|c| c.tool_name == tool_name) + .is_some_and(|config| { + let presence_verified = config.detection_mode() == DetectionMode::Which + && get_binary_path(&config.tool_name).is_some(); + check_health(config, presence_verified) + }) +} + +/// Decide whether a tool is healthy. Health is earned, never assumed: which-mode +/// tools bank the verified presence of their binary, always-mode tools have +/// nothing locally verifiable and need a passing `health_command`. An unhealthy +/// tool stays detected (and visible) but cannot be launched locally. +fn check_health(config: &ToolConfig, presence_verified: bool) -> bool { + let cmd = config + .detection + .as_ref() + .and_then(|d| d.health_command.as_deref()); + + let healthy = match (config.detection_mode(), cmd) { + (DetectionMode::Which, None) => presence_verified, + (DetectionMode::Which, Some(c)) => presence_verified && run_health_command(c), + (DetectionMode::Always, None) => false, + (DetectionMode::Always, Some(c)) => run_health_command(c), + }; + + if !healthy { + tracing::warn!( + tool = %config.tool_name, + command = cmd.unwrap_or("none"), + presence_verified, + "Tool failed its health check; detected but not launchable locally" + ); + } + healthy +} + +fn run_health_command(health_command: &str) -> bool { + let parts: Vec<&str> = health_command.split_whitespace().collect(); + let Some((program, args)) = parts.split_first() else { + return true; + }; + Command::new(program) + .args(args) + .output() + .is_ok_and(|o| o.status.success()) +} + /// Get binary path using `which` fn get_binary_path(tool_name: &str) -> Option { Command::new("which") @@ -182,6 +292,229 @@ fn compare_version_parts(a: &[u32], b: &[u32]) -> Ordering { #[cfg(test)] mod tests { use super::*; + use crate::llm::tool_config::{ArgMapping, DetectionConfig, DetectionMode}; + + fn make_tool_config( + tool_name: &str, + mode: DetectionMode, + health_command: Option<&str>, + ) -> ToolConfig { + ToolConfig { + tool_name: tool_name.to_string(), + display_name: None, + version_command: format!("{tool_name} --version"), + min_version: None, + capabilities: crate::llm::tool_config::ToolCapabilities::default(), + model_aliases: vec!["default".to_string()], + arg_mapping: ArgMapping::default(), + command_template: format!("{tool_name} \"$(cat {{{{prompt_file}}}})\""), + yolo_flags: vec![], + idle_detection: None, + skill_directories: None, + detection: Some(DetectionConfig { + mode, + health_command: health_command.map(String::from), + }), + } + } + + const MISSING_BINARY: &str = "op-test-tool-does-not-exist"; + + fn make_cached_tool(name: &str) -> DetectedTool { + DetectedTool { + name: name.to_string(), + path: format!("/usr/bin/{name}"), + version: "1.0.0".to_string(), + min_version: None, + version_ok: true, + model_aliases: vec!["default".to_string()], + command_template: format!("{name} \"$(cat {{{{prompt_file}}}})\""), + capabilities: ToolCapabilities::default(), + yolo_flags: vec![], + health_ok: true, + } + } + + #[test] + fn test_refresh_adds_newly_configured_tool() { + let existing = LlmToolsConfig { + detected: vec![make_cached_tool("toolx")], + detection_complete: true, + ..Default::default() + }; + let configs = vec![ + make_tool_config("toolx", DetectionMode::Always, None), + make_tool_config("tooly", DetectionMode::Always, None), + ]; + + let refreshed = refresh_with_configs(&existing, &configs); + let names: Vec<_> = refreshed.detected.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["toolx", "tooly"]); + assert!(refreshed.detection_complete); + let expected: usize = refreshed + .detected + .iter() + .map(|t| t.model_aliases.len()) + .sum(); + assert_eq!(refreshed.providers.len(), expected); + } + + #[test] + fn test_refresh_preserves_user_prefs() { + let mut overrides = std::collections::HashMap::new(); + overrides.insert( + "toolx".to_string(), + crate::config::SkillDirectoriesOverride::default(), + ); + let existing = LlmToolsConfig { + detected: vec![make_cached_tool("toolx")], + detection_complete: true, + default_tool: Some("toolx".to_string()), + default_model: Some("default".to_string()), + skill_directory_overrides: overrides, + ..Default::default() + }; + let configs = vec![make_tool_config("toolx", DetectionMode::Always, None)]; + + let refreshed = refresh_with_configs(&existing, &configs); + assert_eq!(refreshed.default_tool.as_deref(), Some("toolx")); + assert_eq!(refreshed.default_model.as_deref(), Some("default")); + assert!(refreshed.skill_directory_overrides.contains_key("toolx")); + } + + #[test] + fn test_refresh_rederives_config_sourced_fields() { + // A VS Code-written subset entry: probed fields only, config-sourced empty + let mut cached = make_cached_tool("toolx"); + cached.command_template = String::new(); + cached.model_aliases = vec![]; + let existing = LlmToolsConfig { + detected: vec![cached], + detection_complete: true, + ..Default::default() + }; + let mut config = make_tool_config("toolx", DetectionMode::Always, None); + config.min_version = Some("0.5.0".to_string()); + config.model_aliases = vec!["a".to_string(), "b".to_string()]; + + let refreshed = refresh_with_configs(&existing, &[config]); + let tool = &refreshed.detected[0]; + // Probed fields kept, config-sourced fields re-derived + assert_eq!(tool.path, "/usr/bin/toolx"); + assert_eq!(tool.version, "1.0.0"); + assert_eq!(tool.model_aliases, vec!["a", "b"]); + assert!(!tool.command_template.is_empty()); + assert_eq!(tool.min_version.as_deref(), Some("0.5.0")); + assert!(tool.version_ok); + } + + #[test] + fn test_refresh_drops_tools_without_config() { + let existing = LlmToolsConfig { + detected: vec![make_cached_tool("toolz")], + detection_complete: true, + ..Default::default() + }; + let refreshed = refresh_with_configs(&existing, &[]); + assert!(refreshed.detected.is_empty()); + assert!(refreshed.providers.is_empty()); + } + + #[test] + fn test_refresh_reruns_health_command_on_cached_tool() { + let existing = LlmToolsConfig { + detected: vec![make_cached_tool("toolx")], + detection_complete: true, + ..Default::default() + }; + let configs = vec![make_tool_config( + "toolx", + DetectionMode::Always, + Some("false"), + )]; + + let refreshed = refresh_with_configs(&existing, &configs); + assert!(!refreshed.detected[0].health_ok); + } + + #[test] + fn test_refresh_which_mode_missing_binary_marks_unhealthy() { + // A cached tool whose binary has since been uninstalled stays listed + // but loses its health, so it can no longer be launched. + let existing = LlmToolsConfig { + detected: vec![make_cached_tool(MISSING_BINARY)], + detection_complete: true, + ..Default::default() + }; + let configs = vec![make_tool_config(MISSING_BINARY, DetectionMode::Which, None)]; + + let refreshed = refresh_with_configs(&existing, &configs); + assert_eq!(refreshed.detected.len(), 1); + assert!(!refreshed.detected[0].health_ok); + } + + #[test] + fn test_refresh_which_mode_present_binary_is_healthy() { + let existing = LlmToolsConfig { + detected: vec![make_cached_tool("true")], + detection_complete: true, + ..Default::default() + }; + let configs = vec![make_tool_config("true", DetectionMode::Which, None)]; + + let refreshed = refresh_with_configs(&existing, &configs); + assert!(refreshed.detected[0].health_ok); + } + + #[test] + fn test_detect_tool_mode_always_bypasses_which() { + let config = make_tool_config(MISSING_BINARY, DetectionMode::Always, None); + let tool = detect_tool(&config).expect("always mode detects without a local binary"); + assert_eq!(tool.path, MISSING_BINARY); + assert_eq!(tool.version, "unknown"); + } + + #[test] + fn test_which_mode_no_health_command_is_healthy() { + // The which gate itself verifies presence, so no health command is needed. + let config = make_tool_config("true", DetectionMode::Which, None); + let tool = detect_tool(&config).expect("`true` is on PATH"); + assert!(tool.health_ok); + } + + #[test] + fn test_always_mode_no_health_command_is_unhealthy() { + // Nothing is verifiable in always mode without a health command. + let config = make_tool_config(MISSING_BINARY, DetectionMode::Always, None); + let tool = detect_tool(&config).unwrap(); + assert!(!tool.health_ok); + } + + #[test] + fn test_detect_tool_mode_which_missing_binary_is_none() { + let config = make_tool_config(MISSING_BINARY, DetectionMode::Which, None); + assert!(detect_tool(&config).is_none()); + } + + #[test] + fn test_health_command_failure_is_soft() { + let config = make_tool_config(MISSING_BINARY, DetectionMode::Always, Some("false")); + let tool = detect_tool(&config).expect("health failure never blocks detection"); + assert!(!tool.health_ok); + } + + #[test] + fn test_health_command_success() { + let config = make_tool_config(MISSING_BINARY, DetectionMode::Always, Some("true")); + let tool = detect_tool(&config).unwrap(); + assert!(tool.health_ok); + } + + #[test] + fn test_verify_tool_health_unknown_tool_is_unhealthy() { + // No tool config means nothing to verify against. + assert!(!verify_tool_health("definitely-not-a-tool-config")); + } #[test] fn test_capitalize() { diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 4d15c7c1..fa11691a 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -1,15 +1,19 @@ //! LLM CLI tool detection and configuration //! -//! This module handles detection of LLM CLI tools (Claude Code, Gemini, Codex) -//! and provides configuration for which tool/model pairs are available. +//! This module handles detection of LLM CLI tools (Claude Code, Gemini, Codex, +//! plus user-defined tools) and provides configuration for which tool/model +//! pairs are available. //! -//! Tool configurations are defined in JSON files under `tools/` and loaded -//! at compile time. Detection checks if binaries exist on the system PATH. +//! Builtin tool configurations are embedded from JSON files under `tools/`; +//! user tool configurations are loaded at runtime from +//! `/operator/tools/*.json`. Detection checks if binaries exist on +//! the system PATH unless a tool opts out via `detection.mode: "always"`. mod detection; pub mod skill_deployer; pub mod tool_config; +pub use detection::verify_tool_health; #[allow(unused_imports)] // Used by main.rs binary -pub use detection::detect_all_tools; +pub use detection::{detect_all_tools, refresh_tool_detection}; pub use skill_deployer::deploy_skills; diff --git a/src/llm/tool_config.rs b/src/llm/tool_config.rs index d3129da8..febed0b2 100644 --- a/src/llm/tool_config.rs +++ b/src/llm/tool_config.rs @@ -1,7 +1,9 @@ //! Tool configuration loading and templating //! -//! This module loads LLM CLI tool configurations from embedded JSON files -//! and provides template-based command building. +//! This module loads LLM CLI tool configurations — embedded builtin JSONs plus +//! user JSONs from `/operator/tools/` — and provides template-based +//! command building. User configs are only ever read from the user-global +//! config dir, never from repo-local paths (see [`load_user_tool_configs`]). use serde::{Deserialize, Serialize}; @@ -66,6 +68,28 @@ pub struct IdleDetectionConfig { pub hook_config: Option, } +/// How a tool's presence is determined +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DetectionMode { + /// Gate on a successful `which ` lookup (default) + #[default] + Which, + /// Skip the PATH lookup; `tool_name` is used as the invocation path. + Always, +} + +/// Detection behavior overrides for a tool +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DetectionConfig { + #[serde(default)] + pub mode: DetectionMode, + /// Health check run every startup. Which-mode tools store their verified presence and only need this if set; + /// always-mode tools have nothing locally verifiable and stay unhealthy until this passes. + #[serde(default)] + pub health_command: Option, +} + /// Tool configuration loaded from JSON #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolConfig { @@ -97,6 +121,9 @@ pub struct ToolConfig { /// Well-known directories for skill/command files #[serde(default)] pub skill_directories: Option, + /// Detection behavior (None = which-gated, no health check) + #[serde(default)] + pub detection: Option, } impl ToolConfig { @@ -105,6 +132,11 @@ impl ToolConfig { self.display_name.as_deref().unwrap_or(&self.tool_name) } + /// Effective detection mode (default: `Which`) + pub fn detection_mode(&self) -> DetectionMode { + self.detection.as_ref().map(|d| d.mode).unwrap_or_default() + } + /// Build a command string by substituting template variables #[allow(dead_code)] // Used in tests pub fn build_command(&self, model: &str, session_id: &str, prompt_file: &str) -> String { @@ -122,31 +154,104 @@ impl ToolConfig { } } -/// Load all embedded tool configurations +/// Operator's directory under the platform config dir (matches `src/config.rs`) +const OPERATOR_CONFIG_DIR_NAME: &str = "operator"; +/// Subdirectory scanned for user tool configs +const USER_TOOLS_DIR_NAME: &str = "tools"; +const TOOL_CONFIG_EXT: &str = "json"; + +const BUILTIN_TOOL_CONFIGS: &[(&str, &str)] = &[ + ("claude", include_str!("tools/claude.json")), + ("gemini", include_str!("tools/gemini.json")), + ("codex", include_str!("tools/codex.json")), +]; + +/// The user tool-config directory: `/operator/tools` +pub fn user_tools_dir() -> Option { + dirs::config_dir().map(|d| d.join(OPERATOR_CONFIG_DIR_NAME).join(USER_TOOLS_DIR_NAME)) +} + +/// Load all tool configurations: embedded builtins plus user JSONs from +/// [`user_tools_dir`]. A user config whose `tool_name` matches a builtin fully +/// replaces it; new names append. pub fn load_all_tool_configs() -> Vec { - let mut configs = Vec::new(); + load_all_tool_configs_with(user_tools_dir().as_deref()) +} - // Load Claude config - if let Ok(config) = serde_json::from_str::(include_str!("tools/claude.json")) { - configs.push(config); - } else { - tracing::warn!("Failed to parse claude.json tool config"); +/// Injectable-dir variant for tests +pub(crate) fn load_all_tool_configs_with(user_dir: Option<&std::path::Path>) -> Vec { + let mut configs = load_builtin_tool_configs(); + if let Some(dir) = user_dir { + for user_config in load_user_tool_configs(dir) { + match configs + .iter() + .position(|c| c.tool_name == user_config.tool_name) + { + Some(pos) => configs[pos] = user_config, + None => configs.push(user_config), + } + } } + configs +} - // Load Gemini config - if let Ok(config) = serde_json::from_str::(include_str!("tools/gemini.json")) { - configs.push(config); - } else { - tracing::warn!("Failed to parse gemini.json tool config"); - } +fn load_builtin_tool_configs() -> Vec { + BUILTIN_TOOL_CONFIGS + .iter() + .filter_map(|(name, json)| match serde_json::from_str(json) { + Ok(config) => Some(config), + Err(e) => { + tracing::warn!(tool = name, error = %e, "Failed to parse builtin tool config"); + None + } + }) + .collect() +} - // Load Codex config - if let Ok(config) = serde_json::from_str::(include_str!("tools/codex.json")) { - configs.push(config); - } else { - tracing::warn!("Failed to parse codex.json tool config"); - } +/// Read `*.json` tool configs from a user directory, sorted by filename so +/// duplicate `tool_name`s resolve deterministically (last wins). Malformed or +/// unreadable files are skipped with a warning. +/// +/// Only the user-global config dir is ever scanned — never repo-local paths: +/// `command_template` is arbitrary shell executed at launch, so loading tool +/// configs from a checked-out repository would be a supply-chain hazard. +fn load_user_tool_configs(dir: &std::path::Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut paths: Vec<_> = entries + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == TOOL_CONFIG_EXT)) + .collect(); + paths.sort(); + let mut configs: Vec = Vec::new(); + for path in paths { + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "Failed to read user tool config"); + continue; + } + }; + match serde_json::from_str::(&contents) { + Ok(config) => { + if let Some(pos) = configs.iter().position(|c| c.tool_name == config.tool_name) { + tracing::warn!( + path = %path.display(), + tool = %config.tool_name, + "Duplicate tool_name in user tool configs; later file wins" + ); + configs[pos] = config; + } else { + configs.push(config); + } + } + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "Failed to parse user tool config; skipping"); + } + } + } configs } @@ -154,9 +259,23 @@ pub fn load_all_tool_configs() -> Vec { mod tests { use super::*; + fn tool_json(tool_name: &str, display_name: &str) -> String { + format!( + r#"{{ + "tool_name": "{tool_name}", + "display_name": "{display_name}", + "version_command": "{tool_name} --version", + "capabilities": {{ "supports_sessions": false, "supports_headless": true }}, + "model_aliases": ["default"], + "arg_mapping": {{ "prompt": "", "model": "" }}, + "command_template": "{tool_name} \"$(cat {{{{prompt_file}}}})\"" + }}"# + ) + } + #[test] fn test_load_all_tool_configs() { - let configs = load_all_tool_configs(); + let configs = load_all_tool_configs_with(None); assert_eq!(configs.len(), 3); let names: Vec<_> = configs.iter().map(|c| c.tool_name.as_str()).collect(); @@ -165,6 +284,100 @@ mod tests { assert!(names.contains(&"codex")); } + #[test] + fn test_user_dir_adds_new_tool() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("agy.json"), tool_json("agy", "Agy")).unwrap(); + + let configs = load_all_tool_configs_with(Some(dir.path())); + assert_eq!(configs.len(), 4); + let agy = configs.iter().find(|c| c.tool_name == "agy").unwrap(); + assert_eq!(agy.display_name(), "Agy"); + } + + #[test] + fn test_user_config_replaces_builtin_by_tool_name() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("claude.json"), + tool_json("claude", "My Claude"), + ) + .unwrap(); + + let configs = load_all_tool_configs_with(Some(dir.path())); + assert_eq!(configs.len(), 3); + let claude = configs.iter().find(|c| c.tool_name == "claude").unwrap(); + // Full replacement: user's file wins entirely, not a field merge + assert_eq!(claude.display_name(), "My Claude"); + assert!(claude.min_version.is_none()); + } + + #[test] + fn test_malformed_user_config_skipped() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("broken.json"), "{ not json").unwrap(); + std::fs::write(dir.path().join("agy.json"), tool_json("agy", "Agy")).unwrap(); + + let configs = load_all_tool_configs_with(Some(dir.path())); + assert_eq!(configs.len(), 4); + assert!(configs.iter().any(|c| c.tool_name == "agy")); + } + + #[test] + fn test_missing_user_dir_ok() { + let dir = tempfile::TempDir::new().unwrap(); + let missing = dir.path().join("does-not-exist"); + let configs = load_all_tool_configs_with(Some(&missing)); + assert_eq!(configs.len(), 3); + } + + #[test] + fn test_non_json_files_ignored() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("README.md"), "# tools").unwrap(); + + let configs = load_all_tool_configs_with(Some(dir.path())); + assert_eq!(configs.len(), 3); + } + + #[test] + fn test_detection_defaults_to_which() { + let configs = load_all_tool_configs(); + let claude = configs.iter().find(|c| c.tool_name == "claude").unwrap(); + assert!(claude.detection.is_none()); + assert_eq!(claude.detection_mode(), DetectionMode::Which); + } + + #[test] + fn test_detection_mode_always_parses() { + let json = r#"{ + "tool_name": "agy", + "version_command": "agy --version", + "capabilities": { "supports_sessions": false, "supports_headless": true }, + "model_aliases": ["default"], + "arg_mapping": { "prompt": "", "model": "" }, + "command_template": "agy \"$(cat {{prompt_file}})\"", + "detection": { "mode": "always", "health_command": "agy ping" } + }"#; + let config: ToolConfig = serde_json::from_str(json).expect("detection config parses"); + assert_eq!(config.detection_mode(), DetectionMode::Always); + let detection = config.detection.unwrap(); + assert_eq!(detection.health_command.as_deref(), Some("agy ping")); + + let json_no_health = r#"{ + "tool_name": "agy", + "version_command": "agy --version", + "capabilities": { "supports_sessions": false, "supports_headless": true }, + "model_aliases": ["default"], + "arg_mapping": { "prompt": "", "model": "" }, + "command_template": "agy \"$(cat {{prompt_file}})\"", + "detection": { "mode": "always" } + }"#; + let config: ToolConfig = serde_json::from_str(json_no_health).unwrap(); + assert_eq!(config.detection_mode(), DetectionMode::Always); + assert!(config.detection.unwrap().health_command.is_none()); + } + #[test] fn test_build_command_claude() { let configs = load_all_tool_configs(); diff --git a/src/llm/tools/tool_config.schema.json b/src/llm/tools/tool_config.schema.json index c3acc2d0..3cf9d4c7 100644 --- a/src/llm/tools/tool_config.schema.json +++ b/src/llm/tools/tool_config.schema.json @@ -20,7 +20,7 @@ }, "tool_name": { "type": "string", - "description": "The binary/command name used to invoke this tool. Must match the executable name in PATH.", + "description": "The binary/command name used to invoke this tool. Must match the executable name in PATH (unless detection.mode is \"always\", where it is used verbatim as the invocation path).", "examples": ["claude", "gemini", "codex"] }, "display_name": { @@ -152,6 +152,66 @@ ["--full-auto"] ] }, + "detection": { + "type": "object", + "description": "Detection behavior overrides. Omit for the default: gate detection on a successful `which ` lookup, which also serves as the tool's health proof.", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": ["which", "always"], + "default": "which", + "description": "\"which\" gates detection on the binary being found in PATH, and that verified presence earns the tool its health. \"always\" skips the PATH lookup and uses tool_name verbatim as the invocation path — for tools not installed locally (e.g. invoked over SSH) or binaries without a stable PATH entry; because nothing is locally verifiable, an always-mode tool is unhealthy until a health_command passes." + }, + "health_command": { + "type": "string", + "description": "Health check run at every startup. Health is earned, never assumed: which-mode tools must still be on PATH and pass this command if set; always-mode tools require this command to be set and pass. An unhealthy tool stays listed but cannot be launched locally.", + "examples": ["agy ping", "ssh gpu-vm command -v agy"] + } + } + }, + "idle_detection": { + "type": "object", + "description": "Configuration for idle/awaiting-input state detection in the tool's terminal output.", + "additionalProperties": false, + "properties": { + "idle_patterns": { + "type": "array", + "description": "Regex patterns that indicate the tool is idle/waiting for input (e.g. prompt characters).", + "items": { "type": "string" }, + "default": [] + }, + "activity_patterns": { + "type": "array", + "description": "Regex patterns that indicate the tool is actively working (spinners, status messages).", + "items": { "type": "string" }, + "default": [] + }, + "hook_config": { + "type": "object", + "description": "Hook configuration for tools that support completion hooks.", + "required": ["event_name", "script_path", "settings_path"], + "additionalProperties": false, + "properties": { + "event_name": { + "type": "string", + "description": "Hook event name.", + "examples": ["Stop", "AfterAgent"] + }, + "script_path": { + "type": "string", + "description": "Path to the hook script.", + "examples": ["~/.claude/hooks/operator-stop.sh"] + }, + "settings_path": { + "type": "string", + "description": "Settings file path for this tool.", + "examples": ["~/.claude/settings.json"] + } + } + } + } + }, "skill_directories": { "type": "object", "description": "Well-known directories where this tool stores skill/command files. Used for skill discovery across tools.", diff --git a/src/main.rs b/src/main.rs index 6bbf56c3..0ead4d29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,13 +46,15 @@ use app::App; use config::Config; use templates::glyph_for_key; -/// Detect installed LLM tools in PATH +/// Detect installed LLM tools from the loaded tool configs fn detect_llm_tools() -> Vec { - let tools = ["claude", "codex", "gemini"]; - tools - .iter() - .filter(|tool| which::which(tool).is_ok()) - .map(|s| (*s).to_string()) + use llm::tool_config::DetectionMode; + llm::tool_config::load_all_tool_configs() + .into_iter() + .filter(|t| { + t.detection_mode() == DetectionMode::Always || which::which(&t.tool_name).is_ok() + }) + .map(|t| t.tool_name) .collect() } @@ -161,7 +163,7 @@ enum Commands { #[arg(long)] delegator: Option, - /// LLM tool override: claude, codex, gemini + /// LLM tool override (e.g., claude, codex, gemini, or configured tool) #[arg(long = "llm-tool")] llm_tool: Option, @@ -289,7 +291,7 @@ enum Commands { #[arg(short = 'k', long)] kanban_provider: Option, - /// Preferred LLM tool: claude, codex, gemini + /// Preferred LLM tool (e.g., claude, codex, gemini, or any configured tool) #[arg(short = 'l', long)] llm_tool: Option, @@ -1023,13 +1025,17 @@ fn cmd_setup( } } - // Validate LLM tool if specified + // Validate LLM tool against the loaded tool configs (builtin + user) if let Some(ref tool) = llm_tool { - match tool.to_lowercase().as_str() { - "claude" | "codex" | "gemini" => {} - other => { - anyhow::bail!("Unknown LLM tool: {other}. Use 'claude', 'codex', or 'gemini'."); - } + let known: Vec = llm::tool_config::load_all_tool_configs() + .into_iter() + .map(|t| t.tool_name) + .collect(); + if !known.contains(&tool.to_lowercase()) { + anyhow::bail!( + "Unknown LLM tool: {tool}. Known tools: {}.", + known.join(", ") + ); } } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 1e44ed31..b459bdd2 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -361,6 +361,7 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result, + /// Execution-target override by name (explicit `[[targets]]` entry, + /// synthesized `local`/`docker`, or a `[[hosts]]` name). Overrides the + /// delegator's launch config for this launch only. + #[serde(default)] + pub target: Option, /// Run in YOLO mode (auto-accept all prompts) #[serde(default)] pub yolo_mode: bool, @@ -254,6 +263,11 @@ pub struct LaunchTicketRequest { #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct LaunchTicketResponse { + /// True when the server executed the launch itself (non-local targets: + /// docker/coder/ssh orchestration is server-side); `command` is then + /// empty and the client must NOT run anything. + #[serde(default)] + pub executed_server_side: bool, /// Agent ID assigned to this launch pub agent_id: String, /// Ticket ID that was launched @@ -417,7 +431,7 @@ pub struct NextStepInfo { pub name: String, /// Display name for the step pub display_name: String, - /// Review type: "none", "plan", "visual", "pr" + /// Review type: "none", "plan", "visual", "pr", "proof" pub review_type: String, /// Prompt template for the step #[serde(skip_serializing_if = "Option::is_none")] @@ -575,6 +589,7 @@ mod tests { #[test] fn test_kanban_ticket_card_step_display_name_absent_when_none() { let card = KanbanTicketCard { + filename: String::new(), id: "FEAT-1".to_string(), summary: "Add thing".to_string(), ticket_type: "FEAT".to_string(), @@ -592,6 +607,7 @@ mod tests { #[test] fn test_kanban_ticket_card_step_display_name_present_when_set() { let card = KanbanTicketCard { + filename: String::new(), id: "FEAT-1".to_string(), summary: "Add thing".to_string(), ticket_type: "FEAT".to_string(), diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index 7161448a..2160ea54 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -185,9 +185,14 @@ pub struct DelegatorLaunchConfigDto { /// Override global relay auto-inject MCP setting per-delegator (None = use global setting) #[serde(default, skip_serializing_if = "Option::is_none")] pub operator_relay: Option, - /// Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local) + /// Name of a declared `RemoteHost` to launch the agent CLI on over SSH (None = local). + /// DEPRECATED: prefer `target`. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: Option, + /// Name of an execution target (explicit `[[targets]]` entry, synthesized + /// `local`/`docker`, or a `[[hosts]]` name). Supersedes `docker`/`host`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, } /// Response listing all delegators diff --git a/src/rest/dto/issue_types.rs b/src/rest/dto/issue_types.rs index 712da7ce..cf7f6d31 100644 --- a/src/rest/dto/issue_types.rs +++ b/src/rest/dto/issue_types.rs @@ -311,7 +311,7 @@ pub struct StepResponse { pub prompt: String, pub outputs: Vec, pub allowed_tools: Vec, - /// Type of review required: "none", "plan", "visual", "pr" + /// Type of review required: "none", "plan", "visual", "pr", "proof" pub review_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub next_step: Option, @@ -344,6 +344,7 @@ impl From<&StepSchema> for StepResponse { crate::templates::schema::ReviewType::Plan => "plan".to_string(), crate::templates::schema::ReviewType::Visual => "visual".to_string(), crate::templates::schema::ReviewType::Pr => "pr".to_string(), + crate::templates::schema::ReviewType::Proof => "proof".to_string(), }, next_step: s.next_step.clone(), permission_mode: match s.permission_mode { @@ -368,7 +369,7 @@ pub struct CreateStepRequest { pub outputs: Vec, #[serde(default = "default_all_tools")] pub allowed_tools: Vec, - /// Type of review required: "none", "plan", "visual", "pr" + /// Type of review required: "none", "plan", "visual", "pr", "proof" #[serde(default = "default_review_type")] pub review_type: String, #[serde(default)] @@ -416,9 +417,11 @@ impl From for StepSchema { "plan" => crate::templates::schema::ReviewType::Plan, "visual" => crate::templates::schema::ReviewType::Visual, "pr" => crate::templates::schema::ReviewType::Pr, + "proof" => crate::templates::schema::ReviewType::Proof, _ => crate::templates::schema::ReviewType::None, }, visual_config: None, + proof_config: None, on_reject: None, next_step: s.next_step, permissions: None, @@ -457,7 +460,7 @@ pub struct UpdateStepRequest { pub outputs: Option>, #[serde(default)] pub allowed_tools: Option>, - /// Type of review required: "none", "plan", "visual", "pr" + /// Type of review required: "none", "plan", "visual", "pr", "proof" #[serde(default)] pub review_type: Option, #[serde(default)] diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index efd0a6fb..8ff2fa87 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -161,6 +161,7 @@ mod tests { #[test] fn test_launch_response_cmux_fields_present_when_set() { let resp = LaunchTicketResponse { + executed_server_side: false, agent_id: "a1".to_string(), ticket_id: "FEAT-001".to_string(), working_directory: "/tmp".to_string(), @@ -184,6 +185,7 @@ mod tests { #[test] fn test_launch_response_cmux_fields_absent_when_none() { let resp = LaunchTicketResponse { + executed_server_side: false, agent_id: "a1".to_string(), ticket_id: "FEAT-001".to_string(), working_directory: "/tmp".to_string(), diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 43625e24..03552143 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -166,6 +166,7 @@ fn dto_to_launch_config(lc: DelegatorLaunchConfigDto) -> DelegatorLaunchConfig { prompt_suffix: lc.prompt_suffix, operator_relay: lc.operator_relay, host: lc.host, + target: lc.target, } } @@ -182,6 +183,7 @@ fn launch_config_to_dto(lc: &DelegatorLaunchConfig) -> DelegatorLaunchConfigDto prompt_suffix: lc.prompt_suffix.clone(), operator_relay: lc.operator_relay, host: lc.host.clone(), + target: lc.target.clone(), } } @@ -500,6 +502,7 @@ mod tests { prompt_suffix: Some("Run tests before finishing.".to_string()), operator_relay: None, host: None, + target: None, }), remote_agent: None, x_agnt: None, @@ -630,6 +633,7 @@ mod tests { prompt_suffix: None, operator_relay: Some(true), host: None, + target: None, }; let dto = launch_config_to_dto(&config); assert_eq!(dto.operator_relay, Some(true)); diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 15ee5dd1..494a4529 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -11,7 +11,7 @@ use axum::{ }; use crate::agents::delegator_resolution::{self, AgentContext}; -use crate::agents::{LaunchOptions, Launcher, PreparedLaunch, RelaunchOptions}; +use crate::agents::{LaunchOptions, Launcher, PreparedLaunch, ProofRunner, RelaunchOptions}; use crate::queue::Queue; use crate::rest::dto::{ LaunchTicketRequest, LaunchTicketResponse, NextStepInfo, StepCompleteRequest, @@ -117,6 +117,7 @@ fn handle_multi_agent_completion( /// Convert `PreparedLaunch` to `LaunchTicketResponse` fn prepared_launch_to_response(prepared: PreparedLaunch) -> LaunchTicketResponse { LaunchTicketResponse { + executed_server_side: false, agent_id: prepared.agent_id, ticket_id: prepared.ticket_id, working_directory: prepared.working_directory.to_string_lossy().to_string(), @@ -197,23 +198,103 @@ pub async fn launch_ticket( let launcher = Launcher::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; - let prepared = if in_progress_path.exists() { + // Non-local targets (docker/coder/ssh) execute SERVER-SIDE: workspace + // lifecycle and remote session orchestration belong to the server, and a + // prepared command handed to a remote client could not run them. Local + // targets keep the prepared-handoff (the client owns the terminal). + let response = if in_progress_path.exists() { // Ticket is in-progress - use relaunch flow (no claim needed) - let relaunch_options = build_relaunch_options(&state, &request, agent_context.as_ref())?; - launcher - .prepare_relaunch(&ticket, relaunch_options) - .await - .map_err(|e| ApiError::InternalError(e.to_string()))? + let mut relaunch_options = + build_relaunch_options(&state, &request, agent_context.as_ref())?; + apply_request_target(&state, &request, &mut relaunch_options.launch_options)?; + if relaunch_options.launch_options.target.kind == crate::config::TargetKind::Local { + let prepared = launcher + .prepare_relaunch(&ticket, relaunch_options) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; + prepared_launch_to_response(prepared) + } else { + launcher + .relaunch(&ticket, relaunch_options) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; + server_side_response(&state, &ticket)? + } } else { // New launch - claim ticket from queue - let launch_options = build_launch_options(&state, &request, agent_context.as_ref())?; - launcher - .prepare_launch(&ticket, launch_options) - .await - .map_err(|e| ApiError::InternalError(e.to_string()))? + let mut launch_options = build_launch_options(&state, &request, agent_context.as_ref())?; + apply_request_target(&state, &request, &mut launch_options)?; + if launch_options.target.kind == crate::config::TargetKind::Local { + let prepared = launcher + .prepare_launch(&ticket, launch_options) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; + prepared_launch_to_response(prepared) + } else { + launcher + .launch_with_options(&ticket, launch_options) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))?; + server_side_response(&state, &ticket)? + } }; - Ok(Json(prepared_launch_to_response(prepared))) + Ok(Json(response)) +} + +/// Apply the request's per-launch target override, with the same remote +/// constraints delegator resolution enforces. +fn apply_request_target( + state: &ApiState, + request: &LaunchTicketRequest, + options: &mut LaunchOptions, +) -> Result<(), ApiError> { + if let Some(ref name) = request.target { + let target = delegator_resolution::resolve_named_target(&state.config, name) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + delegator_resolution::apply_target_to_options(options, target, &state.config) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + } + Ok(()) +} + +/// Response for a launch the server executed itself: `command` is empty and +/// the client must not run anything; details come from the agent record. +fn server_side_response( + state: &ApiState, + ticket: &crate::queue::Ticket, +) -> Result { + let app_state = crate::state::State::load(&state.config) + .map_err(|e| ApiError::InternalError(e.to_string()))?; + let agent = app_state + .agents + .iter() + .rfind(|a| a.ticket_id == ticket.id) + .ok_or_else(|| { + ApiError::InternalError("launch succeeded but no agent record found".to_string()) + })?; + Ok(LaunchTicketResponse { + executed_server_side: true, + agent_id: agent.id.clone(), + ticket_id: ticket.id.clone(), + working_directory: agent.worktree_path.clone().unwrap_or_else(|| { + state + .config + .projects_path() + .join(&ticket.project) + .to_string_lossy() + .to_string() + }), + command: String::new(), + terminal_name: agent.session_name.clone().unwrap_or_default(), + tmux_session_name: agent.session_name.clone().unwrap_or_default(), + session_wrapper: agent.session_wrapper.clone(), + session_window_ref: agent.session_window_ref.clone(), + session_context_ref: agent.session_context_ref.clone(), + session_id: String::new(), + worktree_created: agent.worktree_path.is_some(), + branch: ticket.branch.clone(), + }) } /// Build `LaunchOptions` from the request, delegating to the shared resolution module. @@ -249,6 +330,279 @@ fn build_relaunch_options( }) } +/// Pick the agent that ran the just-completed step: prefer a match on the +/// persisted launch-context session id (opr8r's `--session-id`, the LLM +/// session that ran the step) over the first agent on the ticket. With +/// multiple agents on one ticket (retries, manual relaunch) a plain +/// ticket-id match can pick the wrong one's launch context. +fn find_completing_agent<'a>( + agents: &'a [crate::state::AgentState], + ticket_id: &str, + session_id: Option<&str>, +) -> Option<&'a crate::state::AgentState> { + session_id + .and_then(|sid| { + agents.iter().find(|a| { + a.ticket_id == ticket_id + && a.step_launch_context + .as_ref() + .and_then(|c| c.session_id.as_deref()) + == Some(sid) + }) + }) + .or_else(|| agents.iter().find(|a| a.ticket_id == ticket_id)) +} + +/// Build the next step's opr8r-wrapped command from the launch context +/// persisted with the agent record. Agents launched before contexts were +/// persisted fall back to a baseline reconstructed from per-agent fields. +fn build_next_step_command( + state: &ApiState, + ticket: &crate::queue::Ticket, + next_step: &crate::templates::schema::StepSchema, + request: &StepCompleteRequest, +) -> anyhow::Result { + use crate::agents::launcher::step_command::{self, StepLaunchContext}; + + let config: &crate::config::Config = &state.config; + let app_state = crate::state::State::load(config)?; + let agent = find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); + + let ctx = agent + .and_then(|a| a.step_launch_context.clone()) + .unwrap_or_else(|| StepLaunchContext { + delegator: None, + tool: agent + .and_then(|a| a.llm_tool.clone()) + .unwrap_or_else(|| "claude".to_string()), + model: agent + .and_then(|a| a.llm_model.clone()) + .unwrap_or_else(|| "sonnet".to_string()), + yolo: agent + .and_then(|a| a.launch_mode.as_deref()) + .is_some_and(|m| m.contains("yolo")), + session_id: None, + opr8r: "opr8r".to_string(), + operator_relay: None, + extra_flags: vec![], + }); + + // Working directory: the agent's worktree when one exists, else the project + let project_path = agent + .and_then(|a| a.worktree_path.clone()) + .unwrap_or_else(|| { + config + .projects_path() + .join(&ticket.project) + .to_string_lossy() + .to_string() + }); + + let (previous_summary, previous_recommendation) = + request.output.as_ref().map_or((None, None), |o| { + (o.summary.clone(), o.recommendation.clone()) + }); + + step_command::build_step_command( + config, + ticket, + next_step, + &ctx, + &project_path, + previous_summary.as_deref(), + previous_recommendation.as_deref(), + ) +} + +/// Advance the ticket file to the next step and persist the minted session id +/// and agent step, so chain bookkeeping matches what will execute. Best-effort: +/// failures are logged, not fatal — opr8r already holds the command. +/// +/// opr8r retries the completion POST up to 3 times, and the artifact-sync +/// loop (src/agents/sync.rs) can also advance the ticket independently, so a +/// re-entrant call must not advance twice: re-read the ticket fresh and only +/// call `advance_step()` when it is still sitting on `completed_step`. On a +/// duplicate (already advanced), still record the session id for the next +/// step — that part is idempotent. +fn record_step_transition( + state: &ApiState, + ticket: &crate::queue::Ticket, + completed_step: &str, + next_step: &crate::templates::schema::StepSchema, + next_session_id: &str, + request: &StepCompleteRequest, +) { + let mut advanced = match crate::queue::Ticket::from_file(std::path::Path::new(&ticket.filepath)) + { + Ok(fresh) => fresh, + Err(e) => { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to re-read ticket for advance guard; using stale copy"); + ticket.clone() + } + }; + + // Empty step means "sitting on the schema's first step" (the convention + // used everywhere else `ticket.step` is read before a step is chosen). + let first_step_name = advanced + .template_schema() + .and_then(|t| t.first_step().map(|s| s.name.clone())); + let at_completed_step = if advanced.step.is_empty() { + first_step_name.as_deref() == Some(completed_step) + } else { + advanced.step == completed_step + }; + + if at_completed_step { + if let Err(e) = advanced.advance_step() { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to advance ticket step"); + } + } else { + tracing::debug!( + ticket = %ticket.id, + completed_step = %completed_step, + current_step = %advanced.step, + "Ticket already advanced past completed step; skipping duplicate advance" + ); + } + + if let Err(e) = advanced.set_session_id(&next_step.name, next_session_id) { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to store next step session id"); + } + match crate::state::State::load(&state.config) { + Ok(mut app_state) => { + let matched = + find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); + let agent_id = matched.map(|a| a.id.clone()); + // Re-point the persisted context at the uuid minted for the next + // step; without it the session arm of `find_completing_agent` + // stops matching from the second transition onward. + let next_ctx = matched + .and_then(|a| a.step_launch_context.clone()) + .map(|mut c| { + c.session_id = Some(next_session_id.to_string()); + c + }); + if let Some(id) = agent_id { + if let Err(e) = app_state.update_agent_step(&id, &next_step.name) { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to update agent step"); + } + if let Some(ctx) = next_ctx { + if let Err(e) = app_state.update_agent_step_launch_context(&id, ctx) { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to update agent step launch context"); + } + } + } + } + Err(e) => tracing::warn!(ticket = %ticket.id, error = %e, "Failed to load state"), + } +} + +/// Persist `message` as the completing agent's `last_message`. +fn set_proof_status_message( + state: &ApiState, + ticket: &crate::queue::Ticket, + session_id: Option<&str>, + message: &str, +) { + let mut app_state = match crate::state::State::load(&state.config) { + Ok(s) => s, + Err(e) => { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to load state for proof status message"); + return; + } + }; + let Some(agent) = find_completing_agent(&app_state.agents, &ticket.id, session_id) else { + return; + }; + let (agent_id, status) = (agent.id.clone(), agent.status.clone()); + if let Err(e) = app_state.update_agent_status(&agent_id, &status, Some(message.to_string())) { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to persist proof status message"); + } +} + +/// Run a Proof step's assertion synchronously after a successful command +/// exit, recording pass/fail evidence under `.proof/{ticket}/{step}/` and +/// annotating the agent's status message. The caller's status logic already +/// yields `awaiting_review` for this step either way — a human still +/// confirms; this only attaches evidence. +/// +/// Worktree resolution mirrors `build_next_step_command`'s fallback: the +/// completing agent's `worktree_path`, else the project path. +async fn run_proof_review_hook( + state: &ApiState, + ticket: &crate::queue::Ticket, + step: &crate::templates::schema::StepSchema, + request: &StepCompleteRequest, +) { + let Some(proof_config) = step.proof_config.as_ref() else { + tracing::warn!(ticket = %ticket.id, step = %step.name, "Proof step has no proof_config; skipping run"); + set_proof_status_message( + state, + ticket, + request.session_id.as_deref(), + "Proof review (no config) — awaiting review", + ); + return; + }; + + let project_fallback = || { + state + .config + .projects_path() + .join(&ticket.project) + .to_string_lossy() + .to_string() + }; + let worktree_root = match crate::state::State::load(&state.config) { + Ok(app_state) => { + let agent = + find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); + agent + .and_then(|a| a.worktree_path.clone()) + .unwrap_or_else(project_fallback) + } + Err(e) => { + tracing::warn!(ticket = %ticket.id, error = %e, "Failed to load state for proof worktree resolution"); + project_fallback() + } + }; + + let context = serde_json::json!({"ticket_id": ticket.id, "step": step.name}); + let runner = ProofRunner::with_context(context); + let message = match runner + .run( + proof_config, + std::path::Path::new(&worktree_root), + &ticket.id, + &step.name, + ) + .await + { + Ok(result) => { + let proof_ref = format!(".proof/{}/{}", ticket.id, step.name); + if result.timed_out { + format!( + "Proof FAILED (timeout, exit {}) — awaiting review ({proof_ref})", + result.exit_code + ) + } else if result.passed { + format!("Proof passed — awaiting review ({proof_ref})") + } else { + format!( + "Proof FAILED (exit {}) — awaiting review ({proof_ref})", + result.exit_code + ) + } + } + Err(e) => { + tracing::warn!(ticket = %ticket.id, step = %step.name, error = %e, "Proof runner error"); + "Proof runner error — awaiting review".to_string() + } + }; + + set_proof_status_message(state, ticket, request.session_id.as_deref(), &message); +} + /// Report step completion from opr8r wrapper /// /// Called by the opr8r wrapper when an LLM command completes. @@ -306,6 +660,16 @@ pub async fn complete_step( return Ok(Json(response)); } + // Proof review: run the assertion synchronously on a clean exit so its + // evidence (result.json + status message) is ready before the response + // goes out. Status stays `awaiting_review` either way (below) — a human + // still confirms. + if request.exit_code == 0 + && current_step.review_type == crate::templates::schema::ReviewType::Proof + { + run_proof_review_hook(&state, &ticket, current_step, &request).await; + } + // Determine status based on exit code and validation let status = if request.exit_code != 0 { "failed".to_string() @@ -344,15 +708,42 @@ pub async fn complete_step( && next_step_info.is_some() && current_step.review_type == crate::templates::schema::ReviewType::None; - // Build next command if auto-proceeding - // For now, return a placeholder - actual implementation would build the full opr8r command + // Build next command if auto-proceeding: the same builder the launcher + // uses for step one, fed by the launch context persisted with the agent. + // Never target-wrapped — exec() happens inside the already-wrapped + // environment (see step_command module docs). let next_command = if auto_proceed { - next_step_info.as_ref().map(|next| { - format!( - "opr8r --ticket-id={} --step={} -- claude --prompt 'Continue with step {}'", - ticket_id, next.name, next.name - ) - }) + match current_step + .next_step + .as_ref() + .and_then(|n| issue_type.get_step(n).cloned()) + { + Some(next_schema) => { + match build_next_step_command(&state, &ticket, &next_schema, &request) { + Ok(built) => { + record_step_transition( + &state, + &ticket, + &step_name, + &next_schema, + &built.session_id, + &request, + ); + Some(built.command) + } + Err(e) => { + tracing::warn!( + ticket = %ticket.id, + step = %step_name, + error = %e, + "Failed to build next step command; chain will not auto-proceed" + ); + None + } + } + } + None => None, + } } else { None }; @@ -413,6 +804,7 @@ mod tests { fn test_build_launch_options_default() { let state = make_state(); let request = LaunchTicketRequest { + target: None, delegator: None, provider: None, model: None, @@ -435,6 +827,7 @@ mod tests { fn test_build_launch_options_yolo() { let state = make_state(); let request = LaunchTicketRequest { + target: None, delegator: None, provider: None, model: None, @@ -456,6 +849,7 @@ mod tests { fn test_build_launch_options_unknown_provider() { let state = make_state(); let request = LaunchTicketRequest { + target: None, delegator: None, provider: Some("unknown-provider".to_string()), model: None, @@ -474,6 +868,7 @@ mod tests { fn test_build_relaunch_options() { let state = make_state(); let request = LaunchTicketRequest { + target: None, delegator: None, provider: None, model: None, @@ -517,6 +912,7 @@ mod tests { prompt_suffix: Some("SUFFIX".to_string()), operator_relay: None, host: None, + target: None, }), remote_agent: None, x_agnt: None, @@ -526,6 +922,7 @@ mod tests { let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); let request = LaunchTicketRequest { + target: None, delegator: Some("full-delegator".to_string()), provider: None, model: None, @@ -541,7 +938,7 @@ mod tests { let options = result.unwrap(); assert!(options.yolo_mode); - assert!(options.docker_mode); + assert!(options.is_docker()); assert_eq!(options.use_worktrees_override, Some(true)); assert_eq!(options.create_branch_override, Some(false)); assert_eq!(options.prompt_prefix.as_deref(), Some("PREFIX")); @@ -569,6 +966,7 @@ mod tests { let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); let request = LaunchTicketRequest { + target: None, delegator: Some("minimal".to_string()), provider: None, model: None, @@ -584,7 +982,7 @@ mod tests { let options = result.unwrap(); assert!(!options.yolo_mode); - assert!(!options.docker_mode); + assert!(!options.is_docker()); assert!(options.use_worktrees_override.is_none()); assert!(options.create_branch_override.is_none()); assert!(options.prompt_prefix.is_none()); @@ -617,8 +1015,38 @@ mod tests { } } + #[test] + fn test_apply_request_target_unknown_is_bad_request() { + let state = make_state(); + let mut request = empty_request(); + request.target = Some("nope".to_string()); + let mut options = LaunchOptions::default(); + let err = apply_request_target(&state, &request, &mut options).unwrap_err(); + assert!(matches!(err, ApiError::BadRequest(_))); + } + + #[test] + fn test_apply_request_target_docker_overrides_local() { + let state = make_state(); + let mut request = empty_request(); + request.target = Some("docker".to_string()); + let mut options = LaunchOptions::default(); + apply_request_target(&state, &request, &mut options).unwrap(); + assert!(options.is_docker()); + } + + #[test] + fn test_apply_request_target_none_keeps_resolved_target() { + let state = make_state(); + let request = empty_request(); + let mut options = LaunchOptions::default(); + apply_request_target(&state, &request, &mut options).unwrap(); + assert_eq!(options.target.kind, crate::config::TargetKind::Local); + } + fn empty_request() -> LaunchTicketRequest { LaunchTicketRequest { + target: None, delegator: None, provider: None, model: None, @@ -689,6 +1117,7 @@ mod tests { issuetype_agent: Some("claude-opus".to_string()), }; let request = LaunchTicketRequest { + target: None, delegator: Some("gemini-pro".to_string()), ..empty_request() }; @@ -747,6 +1176,7 @@ mod tests { prompt_suffix: Some("END".to_string()), operator_relay: None, host: None, + target: None, }), remote_agent: None, x_agnt: None, @@ -760,7 +1190,7 @@ mod tests { let options = build_launch_options(&state, &empty_request(), Some(&ctx)).unwrap(); assert!(options.yolo_mode); - assert!(!options.docker_mode); + assert!(!options.is_docker()); assert_eq!(options.use_worktrees_override, Some(true)); assert_eq!(options.create_branch_override, Some(true)); assert_eq!(options.extra_flags, vec!["--full-auto".to_string()]); @@ -1008,4 +1438,591 @@ mod tests { .join(format!("{second_agent_id}.json")); assert!(expected.exists()); } + + // ─── complete_step chain-hardening tests ──────────────────────────── + // + // Uses the builtin SYNC issuetype (scan -> validate -> update): "scan" + // and "validate" both have review_type None (auto_proceed), which lets + // duplicate-advance be reproduced (a 2-step type has nowhere further to + // advance to, so it can't show the defect). + + use crate::agents::launcher::step_command::StepLaunchContext; + use crate::config::{DetectedTool, ToolCapabilities}; + use std::path::Path; + + fn make_chain_detected_tool() -> DetectedTool { + DetectedTool { + name: "claude".to_string(), + path: "/usr/bin/claude".to_string(), + version: "1.0.0".to_string(), + min_version: Some("1.0.0".to_string()), + version_ok: true, + model_aliases: vec!["sonnet".to_string(), "opus".to_string()], + command_template: "claude {{config_flags}}{{model_flag}}--session-id {{session_id}} --print-prompt-path {{prompt_file}}".to_string(), + capabilities: ToolCapabilities { + supports_sessions: true, + supports_headless: true, + }, + yolo_flags: vec!["--dangerously-skip-permissions".to_string()], + health_ok: true, + } + } + + /// Temp `.tickets/{queue,in-progress,...}` + state tree wired for the + /// full `complete_step` route, with "claude" detected so + /// `build_step_command` renders a real (non-error) command. + struct ChainFixture { + _temp: TempDir, + state: ApiState, + } + + fn make_chain_fixture() -> ChainFixture { + let temp = TempDir::new().unwrap(); + let tickets_path = temp.path().join(".tickets"); + for d in ["queue", "in-progress", "completed", "templates"] { + std::fs::create_dir_all(tickets_path.join(d)).unwrap(); + } + let state_path = temp.path().join("state"); + std::fs::create_dir_all(&state_path).unwrap(); + + let mut config = Config::default(); + config.paths.tickets = tickets_path.to_string_lossy().to_string(); + config.paths.state = state_path.to_string_lossy().to_string(); + config.paths.projects = temp.path().join("projects").to_string_lossy().to_string(); + config.llm_tools.detected = vec![make_chain_detected_tool()]; + + let state = ApiState::new(config, tickets_path); + ChainFixture { _temp: temp, state } + } + + /// Write a SYNC ticket into `in-progress/` so `Queue::find_ticket` sees it. + fn write_sync_ticket(state: &ApiState, id: &str, step: &str) -> Ticket { + let filename = format!("20260807-0900-SYNC-chainproj-{}.md", id.to_lowercase()); + let content = + format!("---\nid: {id}\nstatus: running\nstep: {step}\n---\n\n# Chain ticket\n"); + let path = state + .config + .tickets_path() + .join("in-progress") + .join(filename); + std::fs::write(&path, content).unwrap(); + Ticket::from_file(&path).unwrap() + } + + fn make_launch_context(model: &str, session_id: &str) -> StepLaunchContext { + StepLaunchContext { + delegator: None, + tool: "claude".to_string(), + model: model.to_string(), + yolo: false, + session_id: Some(session_id.to_string()), + opr8r: "opr8r".to_string(), + operator_relay: None, + extra_flags: vec![], + } + } + + fn make_chain_complete_request(session_id: &str) -> StepCompleteRequest { + StepCompleteRequest { + exit_code: 0, + output_valid: true, + output_schema_errors: None, + session_id: Some(session_id.to_string()), + duration_secs: 5, + output_sample: None, + output: Some(OperatorOutput { + status: "complete".to_string(), + exit_signal: true, + summary: Some("done".to_string()), + ..Default::default() + }), + } + } + + /// Add an agent for `ticket` carrying the given persisted launch context. + fn add_chain_agent(state: &ApiState, ticket: &Ticket, model: &str, session_id: &str) -> String { + let mut app_state = State::load(&state.config).unwrap(); + let agent_id = app_state + .add_agent_with_options( + ticket.id.clone(), + ticket.ticket_type.clone(), + ticket.project.clone(), + false, + Some("claude".to_string()), + None, + ) + .unwrap(); + app_state + .update_agent_step_launch_context(&agent_id, make_launch_context(model, session_id)) + .unwrap(); + agent_id + } + + fn persisted_context_session_id(state: &ApiState, agent_id: &str) -> Option { + State::load(&state.config) + .unwrap() + .agents + .iter() + .find(|a| a.id == agent_id) + .and_then(|a| a.step_launch_context.as_ref()) + .and_then(|c| c.session_id.clone()) + } + + #[tokio::test] + async fn test_complete_step_next_command_uses_persisted_context() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9001", "scan"); + add_chain_agent(&fixture.state, &ticket, "opus", "session-current"); + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "scan".to_string())), + Json(make_chain_complete_request("session-current")), + ) + .await + .unwrap() + .0; + + assert!( + response.auto_proceed, + "scan has review_type None, so it should auto-proceed" + ); + let next_command = response.next_command.expect("next_command present"); + assert!( + next_command.contains("--model opus"), + "persisted launch-context model must reach the command: {next_command}" + ); + assert!( + next_command.contains(&format!("--ticket-id={}", ticket.id)), + "opr8r wrapper must carry the ticket id: {next_command}" + ); + assert!( + !next_command.contains("docker run"), + "next_command must never be target-wrapped: {next_command}" + ); + } + + #[test] + fn test_complete_step_next_command_mints_fresh_session_uuid() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9002", "scan"); + add_chain_agent(&fixture.state, &ticket, "sonnet", "session-scan"); + + let next_step = ticket + .template_schema() + .unwrap() + .get_step("validate") + .unwrap() + .clone(); + let request = make_chain_complete_request("session-scan"); + + let built = build_next_step_command(&fixture.state, &ticket, &next_step, &request) + .expect("build_next_step_command"); + assert_ne!( + built.session_id, "session-scan", + "the next step must mint a fresh session id, not reuse the completed step's" + ); + assert!(built + .command + .contains(&format!("--session-id={}", built.session_id))); + + record_step_transition( + &fixture.state, + &ticket, + "scan", + &next_step, + &built.session_id, + &request, + ); + + let reloaded = Ticket::from_file(Path::new(&ticket.filepath)).unwrap(); + assert_eq!( + reloaded.sessions.get("validate"), + Some(&built.session_id), + "minted session id must be persisted in the ticket's session map" + ); + } + + #[tokio::test] + async fn test_complete_step_duplicate_post_does_not_double_advance() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9003", "scan"); + add_chain_agent(&fixture.state, &ticket, "sonnet", "session-scan"); + + let first = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "scan".to_string())), + Json(make_chain_complete_request("session-scan")), + ) + .await + .unwrap(); + assert!(first.0.auto_proceed); + + let after_first = Ticket::from_file(Path::new(&ticket.filepath)).unwrap(); + assert_eq!( + after_first.step, "validate", + "first POST advances scan -> validate" + ); + + // opr8r retries the same completion POST (duplicate delivery). + let second = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "scan".to_string())), + Json(make_chain_complete_request("session-scan")), + ) + .await + .unwrap(); + assert!(second.0.auto_proceed); + + let after_second = Ticket::from_file(Path::new(&ticket.filepath)).unwrap(); + assert_eq!( + after_second.step, "validate", + "duplicate POST for an already-completed step must not advance a second time" + ); + } + + #[test] + fn test_complete_step_agent_lookup_prefers_session_id() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9004", "scan"); + add_chain_agent(&fixture.state, &ticket, "opus", "session-agent-1"); + add_chain_agent(&fixture.state, &ticket, "sonnet", "session-agent-2"); + + let next_step = ticket + .template_schema() + .unwrap() + .get_step("validate") + .unwrap() + .clone(); + let request = make_chain_complete_request("session-agent-2"); + + let built = build_next_step_command(&fixture.state, &ticket, &next_step, &request) + .expect("build_next_step_command"); + assert!( + built.command.contains("--model sonnet"), + "must prefer the agent whose persisted session id matches the request, \ + not the first agent found by ticket id: {}", + built.command + ); + } + + #[test] + fn test_find_completing_agent_session_arm_requires_ticket_match() { + let fixture = make_chain_fixture(); + let other = write_sync_ticket(&fixture.state, "SYNC-9005", "scan"); + let target = write_sync_ticket(&fixture.state, "SYNC-9006", "scan"); + // The other ticket's agent is registered FIRST and carries the session + // id being reported, so a ticket-blind session arm would pick it. + add_chain_agent(&fixture.state, &other, "opus", "shared-session"); + let target_agent = add_chain_agent(&fixture.state, &target, "sonnet", "target-session"); + + let app_state = State::load(&fixture.state.config).unwrap(); + let found = find_completing_agent(&app_state.agents, &target.id, Some("shared-session")) + .expect("falls back to a ticket match"); + assert_eq!( + found.id, target_agent, + "a session match on another ticket must not win" + ); + } + + #[tokio::test] + async fn test_transition_repoints_context_session_id_for_next_completion() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9007", "scan"); + // Registered first: the ticket-id fallback would pick this one. + add_chain_agent(&fixture.state, &ticket, "sonnet", "session-other"); + let chain_agent = add_chain_agent(&fixture.state, &ticket, "opus", "session-scan"); + + let first = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "scan".to_string())), + Json(make_chain_complete_request("session-scan")), + ) + .await + .unwrap() + .0; + assert!(first.auto_proceed); + + let validate_session = Ticket::from_file(Path::new(&ticket.filepath)) + .unwrap() + .sessions + .get("validate") + .cloned() + .expect("validate session id persisted on the ticket"); + assert_eq!( + persisted_context_session_id(&fixture.state, &chain_agent), + Some(validate_session.clone()), + "the transition must re-point the agent's context at the next step's uuid" + ); + + // Second transition: the session arm must still find the chain agent. + let second = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "validate".to_string())), + Json(make_chain_complete_request(&validate_session)), + ) + .await + .unwrap() + .0; + let next_command = second.next_command.expect("next_command present"); + assert!( + next_command.contains("--model opus"), + "session arm must still match the chain agent on transition 2: {next_command}" + ); + } + + // ─── Proof review hook (Task B3) ──────────────────────────────────── + // + // Registers a synthetic "PROOF" issue type directly into the registry + // (IssueType::validate doesn't check proof_config — that's B1's + // TemplateSchema-level check for filesystem-loaded types — so this can + // also model the "runtime template bypassed validation" case). + + use crate::agents::ProofResult; + use crate::issuetypes::schema::IssueTypeSource; + use crate::issuetypes::IssueType; + use crate::templates::schema::{ExecutionMode, StepSchema}; + + fn make_proof_step(proof_config: Option) -> StepSchema { + serde_json::from_value(serde_json::json!({ + "name": "run", + "outputs": [], + "prompt": "run the proof", + "review_type": "proof", + "proof_config": proof_config, + "next_step": null, + })) + .unwrap() + } + + fn proof_config_json(assertion_command: &str) -> serde_json::Value { + serde_json::json!({"assertion_command": assertion_command}) + } + + fn make_proof_issue_type(step: StepSchema) -> IssueType { + IssueType { + key: "PROOF".to_string(), + name: "Proof".to_string(), + description: "proof hook test type".to_string(), + mode: ExecutionMode::Autonomous, + glyph: "P".to_string(), + color: None, + project_required: true, + fields: vec![], + steps: vec![step], + agent_prompt: None, + agent: None, + source: IssueTypeSource::Builtin, + external_id: None, + } + } + + /// Like `write_sync_ticket`, but for an arbitrary registered `ticket_type`. + fn write_typed_ticket(state: &ApiState, id: &str, ticket_type: &str, step: &str) -> Ticket { + let filename = format!( + "20260807-0900-{ticket_type}-chainproj-{}.md", + id.to_lowercase() + ); + let content = + format!("---\nid: {id}\nstatus: running\nstep: {step}\n---\n\n# Proof ticket\n"); + let path = state + .config + .tickets_path() + .join("in-progress") + .join(filename); + std::fs::write(&path, content).unwrap(); + Ticket::from_file(&path).unwrap() + } + + fn agent_last_message(state: &ApiState, agent_id: &str) -> Option { + State::load(&state.config) + .unwrap() + .agents + .iter() + .find(|a| a.id == agent_id) + .and_then(|a| a.last_message.clone()) + } + + #[tokio::test] + async fn test_complete_step_proof_hook_records_pass_and_message() { + let fixture = make_chain_fixture(); + let worktree_dir = TempDir::new().unwrap(); + let worktree = worktree_dir.path().to_path_buf(); + + fixture + .state + .registry + .write() + .await + .register(make_proof_issue_type(make_proof_step(Some( + proof_config_json("true"), + )))) + .unwrap(); + + let ticket = write_typed_ticket(&fixture.state, "PROOF-9001", "PROOF", "run"); + let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-1"); + State::load(&fixture.state.config) + .unwrap() + .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) + .unwrap(); + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "run".to_string())), + Json(make_chain_complete_request("session-proof-1")), + ) + .await + .unwrap() + .0; + + assert_eq!(response.status, "awaiting_review"); + assert!(!response.auto_proceed); + + let result_path = worktree + .join(".proof") + .join(&ticket.id) + .join("run") + .join("result.json"); + assert!(result_path.exists(), "result.json must be written"); + let result: ProofResult = + serde_json::from_str(&std::fs::read_to_string(result_path).unwrap()).unwrap(); + assert!(result.passed); + + let message = agent_last_message(&fixture.state, &agent_id).expect("message set"); + assert!(message.starts_with("Proof passed"), "message: {message}"); + } + + #[tokio::test] + async fn test_complete_step_proof_hook_records_failure_and_message() { + let fixture = make_chain_fixture(); + let worktree_dir = TempDir::new().unwrap(); + let worktree = worktree_dir.path().to_path_buf(); + + fixture + .state + .registry + .write() + .await + .register(make_proof_issue_type(make_proof_step(Some( + proof_config_json("false"), + )))) + .unwrap(); + + let ticket = write_typed_ticket(&fixture.state, "PROOF-9002", "PROOF", "run"); + let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-2"); + State::load(&fixture.state.config) + .unwrap() + .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) + .unwrap(); + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "run".to_string())), + Json(make_chain_complete_request("session-proof-2")), + ) + .await + .unwrap() + .0; + + assert_eq!(response.status, "awaiting_review"); + + let result_path = worktree + .join(".proof") + .join(&ticket.id) + .join("run") + .join("result.json"); + let result: ProofResult = + serde_json::from_str(&std::fs::read_to_string(result_path).unwrap()).unwrap(); + assert!(!result.passed); + + let message = agent_last_message(&fixture.state, &agent_id).expect("message set"); + assert!(message.starts_with("Proof FAILED"), "message: {message}"); + } + + #[tokio::test] + async fn test_complete_step_proof_hook_skips_run_on_nonzero_exit() { + let fixture = make_chain_fixture(); + let worktree_dir = TempDir::new().unwrap(); + let worktree = worktree_dir.path().to_path_buf(); + + fixture + .state + .registry + .write() + .await + .register(make_proof_issue_type(make_proof_step(Some( + proof_config_json("true"), + )))) + .unwrap(); + + let ticket = write_typed_ticket(&fixture.state, "PROOF-9003", "PROOF", "run"); + let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-3"); + State::load(&fixture.state.config) + .unwrap() + .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) + .unwrap(); + + let mut request = make_chain_complete_request("session-proof-3"); + request.exit_code = 1; + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "run".to_string())), + Json(request), + ) + .await + .unwrap() + .0; + + assert_eq!(response.status, "failed"); + assert!( + !worktree.join(".proof").exists(), + "assertion command must not run on nonzero exit" + ); + let _ = agent_id; + } + + #[tokio::test] + async fn test_complete_step_proof_hook_missing_config_does_not_crash() { + let fixture = make_chain_fixture(); + let worktree_dir = TempDir::new().unwrap(); + let worktree = worktree_dir.path().to_path_buf(); + + fixture + .state + .registry + .write() + .await + .register(make_proof_issue_type(make_proof_step(None))) + .unwrap(); + + let ticket = write_typed_ticket(&fixture.state, "PROOF-9004", "PROOF", "run"); + let agent_id = add_chain_agent(&fixture.state, &ticket, "sonnet", "session-proof-4"); + State::load(&fixture.state.config) + .unwrap() + .update_agent_worktree_path(&agent_id, &worktree.to_string_lossy()) + .unwrap(); + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "run".to_string())), + Json(make_chain_complete_request("session-proof-4")), + ) + .await + .unwrap() + .0; + + assert_eq!(response.status, "awaiting_review"); + assert!( + !worktree.join(".proof").exists(), + "no proof_config means no assertion run" + ); + + let message = agent_last_message(&fixture.state, &agent_id).expect("message set"); + assert!( + message.to_lowercase().contains("no config") + || message.to_lowercase().contains("missing"), + "message must note missing config: {message}" + ); + } } diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs index bd98119f..f240c49b 100644 --- a/src/rest/routes/llm_tools.rs +++ b/src/rest/routes/llm_tools.rs @@ -129,6 +129,7 @@ mod tests { command_template: String::new(), capabilities: ToolCapabilities::default(), yolo_flags: vec![], + health_ok: true, }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index 8ec1f024..b5043b2f 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -31,6 +31,7 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { step_display_name: ticket.current_step_display_name().into(), priority: ticket.priority.clone(), timestamp: ticket.timestamp.clone(), + filename: ticket.filename.clone(), } } diff --git a/src/rest/routes/steps.rs b/src/rest/routes/steps.rs index 7615ef31..d168f846 100644 --- a/src/rest/routes/steps.rs +++ b/src/rest/routes/steps.rs @@ -153,6 +153,7 @@ pub async fn update( "plan" => ReviewType::Plan, "visual" => ReviewType::Visual, "pr" => ReviewType::Pr, + "proof" => ReviewType::Proof, _ => ReviewType::None, }; } diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index 4ff65d6f..dade2ac3 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -282,7 +282,7 @@ "type": "string" }, "review_type": { - "description": "Type of review required for this step (none, plan, visual, pr)", + "description": "Type of review required for this step (none, plan, visual, pr, proof)", "$ref": "#/$defs/ReviewType", "default": "none" }, @@ -298,6 +298,18 @@ ], "default": null }, + "proof_config": { + "description": "Configuration for proof review (required when `review_type` is \"proof\")", + "anyOf": [ + { + "$ref": "#/$defs/ProofReviewConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "on_reject": { "description": "What to do if step output is rejected", "anyOf": [ @@ -603,6 +615,11 @@ "description": "Git interface PR review workflow", "type": "string", "const": "pr" + }, + { + "description": "Assertion command gate, then human confirmation", + "type": "string", + "const": "proof" } ] }, @@ -637,6 +654,45 @@ "url" ] }, + "ProofReviewConfig": { + "description": "Configuration for proof review steps", + "type": "object", + "properties": { + "assertion_command": { + "description": "Assertion command run via `sh -c` in the worktree root; exit code 0 = pass.\nSupports handlebars: `{{ticket_id}}`, `{{step}}`, `{{proof_dir}}`", + "type": "string" + }, + "artifact_command": { + "description": "Artifact-producing command (e.g. screenshot capture), run after the assertion regardless of its result", + "type": [ + "string", + "null" + ], + "default": null + }, + "artifact_patterns": { + "description": "Glob patterns (relative to worktree root) copied into `.proof/{ticket_id}/{step}/`", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "timeout_secs": { + "description": "Per-command timeout in seconds (default 120)", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "default": null + } + }, + "required": [ + "assertion_command" + ] + }, "OnReject": { "description": "Action to take when a step is rejected", "type": "object", diff --git a/src/services/pr_monitor.rs b/src/services/pr_monitor.rs index 23c0cc54..7124936b 100644 --- a/src/services/pr_monitor.rs +++ b/src/services/pr_monitor.rs @@ -12,7 +12,8 @@ use std::time::Duration; use tokio::sync::{mpsc, RwLock}; use tracing::{debug, error, info, instrument, warn}; -use crate::api::{GitHubService, PrService}; +use crate::api::pr_service::PrServiceRouter; +use crate::api::PrService; use crate::types::pr::{GitProvider, PrState, RepoInfo}; /// Default poll interval (60 seconds, matching vibe-kanban) @@ -73,9 +74,9 @@ pub struct PrMonitorService { } impl PrMonitorService { - /// Create a new PR monitor service with default GitHub provider + /// Create a new PR monitor service, routing per-PR by provider pub fn new(event_tx: mpsc::UnboundedSender) -> Self { - Self::with_service(Arc::new(GitHubService::new()), event_tx) + Self::with_service(Arc::new(PrServiceRouter::new()), event_tx) } /// Create a new PR monitor service with a custom provider diff --git a/src/setup.rs b/src/setup.rs index a3a453d4..b31a1430 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -28,7 +28,7 @@ pub struct SetupOptions { pub working_dir: Option, /// Kanban provider to configure: jira, linear pub kanban_provider: Option, - /// Preferred LLM tool: claude, codex, gemini + /// Preferred LLM tool (e.g., claude, codex, gemini, or any configured tool) pub llm_tool: Option, /// Whether to use git worktrees for per-ticket isolation (default: false) pub use_worktrees: bool, diff --git a/src/state.rs b/src/state.rs index c50a0a50..759da91e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -80,16 +80,16 @@ pub struct AgentState { #[serde(default)] #[ts(type = "string | null")] pub last_content_change: Option>, - /// PR URL if created during "pr" step + /// PR/MR URL if created during the "pr" step #[serde(default)] pub pr_url: Option, - /// PR number for GitHub API tracking + /// Code review request (PR/MR) number #[serde(default)] pub pr_number: Option, - /// GitHub repo in format "owner/repo" - #[serde(default)] - pub github_repo: Option, - /// Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + /// Repository in "owner/repo" format on the configured git provider + #[serde(default, alias = "github_repo")] + pub repo: Option, + /// Last known PR/MR status ("open", "approved", "`changes_requested`", "merged", "closed") #[serde(default)] pub pr_status: Option, /// Completed steps for this ticket @@ -101,11 +101,12 @@ pub struct AgentState { /// LLM model alias (e.g., "opus", "sonnet", "gpt-4o") #[serde(default)] pub llm_model: Option, - /// Launch mode: "default", "yolo", "docker", "docker-yolo" + /// Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` + /// (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) #[serde(default)] pub launch_mode: Option, /// Review state for `awaiting_input` agents - /// Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" + /// Values: "`pending_plan`", "`pending_visual`", "`pending_proof`", "`pending_pr_creation`", "`pending_pr_merge`" #[serde(default)] pub review_state: Option, /// Server process ID for visual review cleanup (if applicable) @@ -117,6 +118,12 @@ pub struct AgentState { /// Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) #[serde(default)] pub remote_host: Option, + /// Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. + #[serde(default)] + pub step_launch_context: Option, + /// Name of the resolved execution target this agent launched on + #[serde(default)] + pub target_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -322,7 +329,7 @@ impl State { last_content_change: Some(now), pr_url: None, pr_number: None, - github_repo: None, + repo: None, pr_status: None, completed_steps: Vec::new(), llm_tool, @@ -332,6 +339,8 @@ impl State { dev_server_pid: None, worktree_path: None, remote_host: None, + step_launch_context: None, + target_name: None, }); self.save()?; @@ -377,7 +386,7 @@ impl State { last_content_change: Some(now), pr_url: None, pr_number: None, - github_repo: None, + repo: None, pr_status: None, completed_steps: Vec::new(), llm_tool, @@ -387,6 +396,8 @@ impl State { dev_server_pid: None, worktree_path: None, remote_host: None, + step_launch_context: None, + target_name: None, }); self.save()?; @@ -513,6 +524,26 @@ impl State { self.save() } + /// Record which execution target the agent launched on + pub fn update_agent_target_name(&mut self, agent_id: &str, target_name: &str) -> Result<()> { + if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { + agent.target_name = Some(target_name.to_string()); + } + self.save() + } + + /// Persist the launch context used for multi-step exec-chain transitions + pub fn update_agent_step_launch_context( + &mut self, + agent_id: &str, + ctx: crate::agents::launcher::step_command::StepLaunchContext, + ) -> Result<()> { + if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { + agent.step_launch_context = Some(ctx); + } + self.save() + } + /// Update the content hash for an agent (for change detection) pub fn update_agent_content_hash(&mut self, agent_id: &str, hash: &str) -> Result { if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { @@ -651,25 +682,25 @@ impl State { self.save() } - /// Update PR information for an agent + /// Update PR/MR information for an agent pub fn update_agent_pr( &mut self, agent_id: &str, pr_url: &str, pr_number: u64, - github_repo: &str, + repo: &str, ) -> Result<()> { if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { agent.pr_url = Some(pr_url.to_string()); agent.pr_number = Some(pr_number); - agent.github_repo = Some(github_repo.to_string()); + agent.repo = Some(repo.to_string()); agent.pr_status = Some("open".to_string()); agent.last_activity = Utc::now(); } self.save() } - /// Update PR status for an agent + /// Update PR/MR status for an agent pub fn update_pr_status(&mut self, agent_id: &str, status: &str) -> Result<()> { if let Some(agent) = self.agents.iter_mut().find(|a| a.id == agent_id) { agent.pr_status = Some(status.to_string()); @@ -678,7 +709,7 @@ impl State { self.save() } - /// Get all agents that are waiting for PR approval + /// Get all agents that are waiting for PR/MR approval pub fn agents_awaiting_pr_approval(&self) -> Vec<&AgentState> { self.agents .iter() @@ -691,11 +722,11 @@ impl State { .collect() } - /// Get all agents with active PRs (for status polling) + /// Get all agents with active PRs/MRs (for status polling) pub fn agents_with_prs(&self) -> Vec<&AgentState> { self.agents .iter() - .filter(|a| a.pr_number.is_some() && a.github_repo.is_some()) + .filter(|a| a.pr_number.is_some() && a.repo.is_some()) .collect() } @@ -751,7 +782,20 @@ impl State { .collect() } - /// Get all agents awaiting PR merge + /// Get all agents awaiting proof review + pub fn agents_awaiting_proof_review(&self) -> Vec<&AgentState> { + self.agents + .iter() + .filter(|a| { + a.status == "awaiting_input" + && a.review_state + .as_ref() + .is_some_and(|s| s == "pending_proof") + }) + .collect() + } + + /// Get all agents awaiting PR/MR merge pub fn agents_awaiting_pr_merge(&self) -> Vec<&AgentState> { self.agents .iter() @@ -1308,6 +1352,47 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_agents_awaiting_proof_review() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let proof_id = state + .add_agent( + "FEAT-001".to_string(), + "FEAT".to_string(), + "test".to_string(), + false, + ) + .unwrap(); + state + .update_agent_status(&proof_id, "awaiting_input", None) + .unwrap(); + state + .set_agent_review_state(&proof_id, "pending_proof") + .unwrap(); + + let visual_id = state + .add_agent( + "FEAT-002".to_string(), + "FEAT".to_string(), + "test".to_string(), + false, + ) + .unwrap(); + state + .update_agent_status(&visual_id, "awaiting_input", None) + .unwrap(); + state + .set_agent_review_state(&visual_id, "pending_visual") + .unwrap(); + + let awaiting = state.agents_awaiting_proof_review(); + assert_eq!(awaiting.len(), 1); + assert_eq!(awaiting[0].id, proof_id); + } + // ─── Agent Query Tests ─────────────────────────────────────────────────────── #[test] @@ -1907,4 +1992,97 @@ mod tests { assert_eq!(agent.llm_model, Some("sonnet".to_string())); assert_eq!(agent.status, "running"); } + + // ─── Step Launch Context Tests ─────────────────────────────────────────── + + #[test] + fn test_step_launch_context_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let agent_id = state + .add_agent( + "FEAT-001".to_string(), + "FEAT".to_string(), + "test".to_string(), + false, + ) + .unwrap(); + + let ctx = crate::agents::launcher::step_command::StepLaunchContext { + delegator: Some("my-delegator".into()), + tool: "claude".to_string(), + model: "opus".to_string(), + yolo: true, + session_id: Some("abc-123".into()), + opr8r: "/usr/local/bin/opr8r".to_string(), + operator_relay: Some(true), + extra_flags: vec!["--verbose".into()], + }; + + state + .update_agent_step_launch_context(&agent_id, ctx) + .unwrap(); + + let reloaded = State::load(&config).unwrap(); + let agent = reloaded.agents.iter().find(|a| a.id == agent_id).unwrap(); + let loaded_ctx = agent.step_launch_context.as_ref().unwrap(); + + assert_eq!(loaded_ctx.delegator, Some("my-delegator".into())); + assert_eq!(loaded_ctx.tool, "claude"); + assert_eq!(loaded_ctx.model, "opus"); + assert!(loaded_ctx.yolo); + assert_eq!(loaded_ctx.session_id, Some("abc-123".into())); + assert_eq!(loaded_ctx.opr8r, "/usr/local/bin/opr8r"); + assert_eq!(loaded_ctx.operator_relay, Some(true)); + assert_eq!(loaded_ctx.extra_flags, vec!["--verbose".to_string()]); + } + + #[test] + fn test_agent_state_without_step_launch_context_loads() { + let json = r#"{ + "id": "test-agent-id", + "ticket_id": "FEAT-001", + "ticket_type": "FEAT", + "project": "test", + "status": "running", + "started_at": "2024-01-01T00:00:00Z", + "last_activity": "2024-01-01T00:00:00Z", + "last_message": null, + "paired": false, + "completed_steps": [] + }"#; + + let agent: AgentState = serde_json::from_str(json).unwrap(); + + assert_eq!(agent.id, "test-agent-id"); + assert_eq!(agent.ticket_id, "FEAT-001"); + assert_eq!(agent.ticket_type, "FEAT"); + assert_eq!(agent.project, "test"); + assert_eq!(agent.status, "running"); + assert!(!agent.paired); + assert!(agent.step_launch_context.is_none()); + } + + #[test] + fn test_agent_state_github_repo_alias_deserializes_to_repo() { + let json = r#"{ + "id": "test-agent-id", + "ticket_id": "FEAT-001", + "ticket_type": "FEAT", + "project": "test", + "status": "running", + "started_at": "2024-01-01T00:00:00Z", + "last_activity": "2024-01-01T00:00:00Z", + "last_message": null, + "paired": false, + "github_repo": "owner/name", + "completed_steps": [] + }"#; + + let agent: AgentState = serde_json::from_str(json).unwrap(); + + assert_eq!(agent.repo, Some("owner/name".to_string())); + } } diff --git a/src/steps/session.rs b/src/steps/session.rs index b2be7496..4c058437 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -292,6 +292,7 @@ mod tests { allowed_tools: vec!["Read".to_string(), "Glob".to_string()], review_type: crate::templates::schema::ReviewType::None, visual_config: None, + proof_config: None, on_reject: None, next_step: Some("implement".to_string()), permissions: None, diff --git a/src/templates/schema.rs b/src/templates/schema.rs index 40b42179..ffec26a8 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -146,12 +146,15 @@ pub struct StepSchema { pub outputs: Vec, /// Initial prompt template for the Claude agent pub prompt: String, - /// Type of review required for this step (none, plan, visual, pr) + /// Type of review required for this step (none, plan, visual, pr, proof) #[serde(default)] pub review_type: ReviewType, /// Configuration for visual review (required when `review_type` is "visual") #[serde(default)] pub visual_config: Option, + /// Configuration for proof review (required when `review_type` is "proof") + #[serde(default)] + pub proof_config: Option, /// What to do if step output is rejected #[serde(default)] pub on_reject: Option, @@ -284,6 +287,8 @@ pub enum ReviewType { Visual, /// Git interface PR review workflow Pr, + /// Assertion command gate, then human confirmation + Proof, } /// Configuration for visual review steps @@ -300,6 +305,24 @@ pub struct VisualReviewConfig { pub startup_timeout_secs: Option, } +/// Configuration for proof review steps +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export, optional_fields = nullable)] +pub struct ProofReviewConfig { + /// Assertion command run via `sh -c` in the worktree root; exit code 0 = pass. + /// Supports handlebars: `{{ticket_id}}`, `{{step}}`, `{{proof_dir}}` + pub assertion_command: String, + /// Artifact-producing command (e.g. screenshot capture), run after the assertion regardless of its result + #[serde(default)] + pub artifact_command: Option, + /// Glob patterns (relative to worktree root) copied into `.proof/{ticket_id}/{step}/` + #[serde(default)] + pub artifact_patterns: Vec, + /// Per-command timeout in seconds (default 120) + #[serde(default)] + pub timeout_secs: Option, +} + /// Discriminator tag for step types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema, TS)] #[ts(export)] @@ -719,6 +742,32 @@ impl TemplateSchema { } } + // Validate review_type config presence and constraints + match step.review_type { + ReviewType::Visual if step.visual_config.is_none() => { + errors.push(format!( + "Step '{}': review_type 'visual' requires visual_config", + step.name + )); + } + ReviewType::Proof => match &step.proof_config { + None => { + errors.push(format!( + "Step '{}': review_type 'proof' requires proof_config", + step.name + )); + } + Some(cfg) if cfg.assertion_command.trim().is_empty() => { + errors.push(format!( + "Step '{}': proof_config.assertion_command must not be empty", + step.name + )); + } + Some(_) => {} + }, + _ => {} + } + // Validate step type config presence and constraints step.validate_type_config(&mut errors); } @@ -2154,4 +2203,156 @@ mod tests { "got {errs:?}" ); } + + #[test] + fn test_proof_step_round_trips_and_validates() { + let json = r#"{ + "key": "TEST", + "name": "Test", + "description": "Test template", + "mode": "autonomous", + "glyph": "*", + "fields": [ + { + "name": "id", + "description": "ID", + "type": "string", + "required": true, + "auto": "id" + } + ], + "steps": [ + { + "name": "verify", + "outputs": ["report"], + "prompt": "Verify the change", + "allowed_tools": [], + "review_type": "proof", + "proof_config": { + "assertion_command": "cargo test", + "artifact_command": "cargo test -- --format json", + "artifact_patterns": ["target/proof/*.json"], + "timeout_secs": 300 + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].review_type, ReviewType::Proof); + let cfg = schema.steps[0].proof_config.as_ref().unwrap(); + assert_eq!(cfg.assertion_command, "cargo test"); + assert_eq!( + cfg.artifact_command.as_deref(), + Some("cargo test -- --format json") + ); + assert_eq!(cfg.artifact_patterns, vec!["target/proof/*.json"]); + assert_eq!(cfg.timeout_secs, Some(300)); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_proof_missing_config_fails_validation() { + let json = r#"{ + "key": "TEST", + "name": "Test", + "description": "Test template", + "mode": "autonomous", + "glyph": "*", + "fields": [ + { + "name": "id", + "description": "ID", + "type": "string", + "required": true, + "auto": "id" + } + ], + "steps": [ + { + "name": "verify", + "outputs": ["report"], + "prompt": "Verify the change", + "allowed_tools": [], + "review_type": "proof" + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err()[0].contains("proof_config")); + } + + #[test] + fn test_proof_empty_assertion_command_fails_validation() { + let json = r#"{ + "key": "TEST", + "name": "Test", + "description": "Test template", + "mode": "autonomous", + "glyph": "*", + "fields": [ + { + "name": "id", + "description": "ID", + "type": "string", + "required": true, + "auto": "id" + } + ], + "steps": [ + { + "name": "verify", + "outputs": ["report"], + "prompt": "Verify the change", + "allowed_tools": [], + "review_type": "proof", + "proof_config": { + "assertion_command": " " + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err()[0].contains("assertion_command")); + } + + #[test] + fn test_visual_missing_config_fails_validation() { + let json = r#"{ + "key": "TEST", + "name": "Test", + "description": "Test template", + "mode": "autonomous", + "glyph": "*", + "fields": [ + { + "name": "id", + "description": "ID", + "type": "string", + "required": true, + "auto": "id" + } + ], + "steps": [ + { + "name": "check", + "outputs": ["report"], + "prompt": "Check it looks right", + "allowed_tools": [], + "review_type": "visual" + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err()[0].contains("visual_config")); + } } diff --git a/src/templates/step_type.rs b/src/templates/step_type.rs index 8732d8f1..c5421dae 100644 --- a/src/templates/step_type.rs +++ b/src/templates/step_type.rs @@ -481,6 +481,7 @@ mod tests { prompt: "Test prompt".to_string(), review_type: ReviewType::None, visual_config: None, + proof_config: None, on_reject: None, next_step: None, allowed_tools: vec!["Read".to_string()], diff --git a/src/types/pr.rs b/src/types/pr.rs index dc954531..b2e6b8b8 100644 --- a/src/types/pr.rs +++ b/src/types/pr.rs @@ -1,6 +1,6 @@ //! Pull Request types for Git provider integration. //! -//! These types support multiple Git providers (GitHub, GitLab, Bitbucket, Azure DevOps) +//! These types support multiple Git providers (see [`GitProvider::ALL`]) //! with provider-specific CLI wrappers for operations. use chrono::{DateTime, Utc}; @@ -25,6 +25,10 @@ pub enum GitProvider { Bitbucket, /// Azure DevOps (dev.azure.com) AzureDevOps, + /// Forgejo (codeberg.org or self-hosted) + Forgejo, + /// Gitea (gitea.com or self-hosted) + Gitea, } impl fmt::Display for GitProvider { @@ -34,6 +38,8 @@ impl fmt::Display for GitProvider { GitProvider::GitLab => write!(f, "gitlab"), GitProvider::Bitbucket => write!(f, "bitbucket"), GitProvider::AzureDevOps => write!(f, "azure"), + GitProvider::Forgejo => write!(f, "forgejo"), + GitProvider::Gitea => write!(f, "gitea"), } } } @@ -41,11 +47,13 @@ impl fmt::Display for GitProvider { impl GitProvider { /// The canonical list of git providers, in display order. Single source of /// truth mirrored by the vertical catalog (`crate::integrations::catalog`). - pub const ALL: [GitProvider; 4] = [ + pub const ALL: [GitProvider; 6] = [ GitProvider::GitHub, GitProvider::GitLab, GitProvider::Bitbucket, GitProvider::AzureDevOps, + GitProvider::Forgejo, + GitProvider::Gitea, ]; /// Stable lowercase slug (matches the [`Display`](std::fmt::Display) form and @@ -56,6 +64,8 @@ impl GitProvider { GitProvider::GitLab => "gitlab", GitProvider::Bitbucket => "bitbucket", GitProvider::AzureDevOps => "azure", + GitProvider::Forgejo => "forgejo", + GitProvider::Gitea => "gitea", } } @@ -70,6 +80,10 @@ impl GitProvider { Some(GitProvider::Bitbucket) } else if url_lower.contains("dev.azure.com") || url_lower.contains("visualstudio.com") { Some(GitProvider::AzureDevOps) + } else if url_lower.contains("codeberg.org") { + Some(GitProvider::Forgejo) + } else if url_lower.contains("gitea.com") { + Some(GitProvider::Gitea) } else { None } @@ -141,13 +155,17 @@ fn parse_owner_repo( ) -> Result<(String, String), RepoInfoError> { let pattern = match provider { GitProvider::GitHub => r"github\.com[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)", - GitProvider::GitLab => r"gitlab[^/]*[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)", + GitProvider::GitLab => r"gitlab[^:/]*[:/](?P.+)/(?P[^/]+?)(?:\.git)?(?:/|$)", GitProvider::Bitbucket => { r"bitbucket\.org[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)" } GitProvider::AzureDevOps => { r"(?:dev\.azure\.com|visualstudio\.com)[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)" } + GitProvider::Forgejo => { + r"codeberg\.org[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)" + } + GitProvider::Gitea => r"gitea\.com[:/](?P[^/]+)/(?P[^/]+?)(?:\.git)?(?:/|$)", }; let re = Regex::new(pattern) @@ -205,13 +223,13 @@ pub struct CreatePrRequest { pub draft: Option, } -/// PR info returned from GitHub +/// PR/MR info returned from the git provider #[derive(Debug, Clone, Serialize, Deserialize, TS, JsonSchema)] #[ts(export)] pub struct PullRequestInfo { /// PR number pub number: i64, - /// PR URL on GitHub + /// PR URL on the provider pub url: String, /// Current PR state pub state: PrState, @@ -320,10 +338,12 @@ impl UnifiedPrComment { #[serde(tag = "type", rename_all = "snake_case")] #[ts(export)] pub enum CreatePrError { - /// GitHub CLI is not installed - GithubCliNotInstalled, - /// GitHub CLI is not authenticated - GithubCliNotLoggedIn, + /// Provider CLI (gh/glab/...) is not installed + #[serde(alias = "github_cli_not_installed")] + ProviderCliNotInstalled, + /// Provider CLI is not authenticated + #[serde(alias = "github_cli_not_logged_in")] + ProviderCliNotLoggedIn, /// Git CLI is not installed GitCliNotInstalled, /// Git remote is not configured @@ -334,8 +354,9 @@ pub enum CreatePrError { BranchNotPushed { branch: String }, /// PR already exists for this branch PrAlreadyExists { pr_number: i64, url: String }, - /// GitHub API error - GithubApiError { message: String }, + /// Provider API error + #[serde(alias = "github_api_error")] + ProviderApiError { message: String }, } #[cfg(test)] @@ -395,6 +416,30 @@ mod tests { ); } + #[test] + fn test_detect_forgejo_provider() { + assert_eq!( + GitProvider::from_remote_url("git@codeberg.org:owner/repo.git"), + Some(GitProvider::Forgejo) + ); + assert_eq!( + GitProvider::from_remote_url("https://codeberg.org/owner/repo"), + Some(GitProvider::Forgejo) + ); + } + + #[test] + fn test_detect_gitea_provider() { + assert_eq!( + GitProvider::from_remote_url("git@gitea.com:owner/repo.git"), + Some(GitProvider::Gitea) + ); + assert_eq!( + GitProvider::from_remote_url("https://gitea.com/owner/repo"), + Some(GitProvider::Gitea) + ); + } + // RepoInfo parsing tests (GitHub) #[test] fn test_parse_github_ssh_url() { @@ -437,6 +482,39 @@ mod tests { assert_eq!(info.repo_name, "repo"); } + #[test] + fn test_parse_gitlab_subgroup_https_url() { + let info = RepoInfo::from_remote_url("https://gitlab.com/group/subgroup/repo.git").unwrap(); + assert_eq!(info.provider, GitProvider::GitLab); + assert_eq!(info.owner, "group/subgroup"); + assert_eq!(info.repo_name, "repo"); + } + + #[test] + fn test_parse_gitlab_subgroup_ssh_url() { + let info = RepoInfo::from_remote_url("git@gitlab.com:group/subgroup/repo.git").unwrap(); + assert_eq!(info.provider, GitProvider::GitLab); + assert_eq!(info.owner, "group/subgroup"); + assert_eq!(info.repo_name, "repo"); + } + + // RepoInfo parsing tests (Forgejo / Gitea) + #[test] + fn test_parse_forgejo_https_url() { + let info = RepoInfo::from_remote_url("https://codeberg.org/owner/repo").unwrap(); + assert_eq!(info.provider, GitProvider::Forgejo); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo_name, "repo"); + } + + #[test] + fn test_parse_gitea_https_url() { + let info = RepoInfo::from_remote_url("https://gitea.com/owner/repo").unwrap(); + assert_eq!(info.provider, GitProvider::Gitea); + assert_eq!(info.owner, "owner"); + assert_eq!(info.repo_name, "repo"); + } + // Error handling tests #[test] fn test_invalid_url() { @@ -470,6 +548,22 @@ mod tests { assert_eq!(GitProvider::GitLab.to_string(), "gitlab"); assert_eq!(GitProvider::Bitbucket.to_string(), "bitbucket"); assert_eq!(GitProvider::AzureDevOps.to_string(), "azure"); + assert_eq!(GitProvider::Forgejo.to_string(), "forgejo"); + assert_eq!(GitProvider::Gitea.to_string(), "gitea"); + } + + #[test] + fn test_provider_slug_round_trip() { + for provider in GitProvider::ALL { + assert_eq!(provider.slug(), provider.to_string()); + } + assert_eq!(GitProvider::Forgejo.slug(), "forgejo"); + assert_eq!(GitProvider::Gitea.slug(), "gitea"); + } + + #[test] + fn test_all_has_six_providers() { + assert_eq!(GitProvider::ALL.len(), 6); } // PR comment tests @@ -486,6 +580,43 @@ mod tests { assert!(comment.created_at() <= Utc::now()); } + // CreatePrError wire-compat tests + #[test] + fn test_create_pr_error_alias_old_wire_strings_deserialize() { + let cli_not_installed: CreatePrError = + serde_json::from_str(r#"{"type":"github_cli_not_installed"}"#).unwrap(); + assert!(matches!( + cli_not_installed, + CreatePrError::ProviderCliNotInstalled + )); + + let cli_not_logged_in: CreatePrError = + serde_json::from_str(r#"{"type":"github_cli_not_logged_in"}"#).unwrap(); + assert!(matches!( + cli_not_logged_in, + CreatePrError::ProviderCliNotLoggedIn + )); + + let api_error: CreatePrError = + serde_json::from_str(r#"{"type":"github_api_error","message":"boom"}"#).unwrap(); + assert!(matches!( + api_error, + CreatePrError::ProviderApiError { message } if message == "boom" + )); + } + + #[test] + fn test_create_pr_error_new_wire_strings_round_trip() { + let err = CreatePrError::ProviderCliNotInstalled; + let json = serde_json::to_string(&err).unwrap(); + assert_eq!(json, r#"{"type":"provider_cli_not_installed"}"#); + let round_tripped: CreatePrError = serde_json::from_str(&json).unwrap(); + assert!(matches!( + round_tripped, + CreatePrError::ProviderCliNotInstalled + )); + } + // TypeScript binding tests #[test] fn test_export_bindings_pullrequestinfo() { diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index 38500687..60e814bf 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -86,10 +86,10 @@ pub struct ConfirmDialog { pub provider_options: Vec, /// Currently selected provider index pub selected_provider: usize, - /// Whether docker mode option is available - pub docker_enabled: bool, - /// Whether docker mode is selected - pub docker_selected: bool, + /// Named execution targets to cycle through (always starts with "local") + pub target_options: Vec, + /// Currently selected target index (0 = local) + pub selected_target: usize, /// Whether YOLO mode option is available pub yolo_enabled: bool, /// Whether YOLO mode is selected @@ -128,8 +128,8 @@ impl ConfirmDialog { selected_option: SelectedOption::Provider, provider_options: Vec::new(), selected_provider: 0, - docker_enabled: false, - docker_selected: false, + target_options: vec!["local".to_string()], + selected_target: 0, yolo_enabled: false, yolo_selected: false, project_options: Vec::new(), @@ -144,12 +144,12 @@ impl ConfirmDialog { &mut self, providers: Vec, projects: Vec, - docker_enabled: bool, + targets: Vec, yolo_enabled: bool, ) { self.provider_options = providers; self.project_options = projects; - self.docker_enabled = docker_enabled; + self.target_options = targets; self.yolo_enabled = yolo_enabled; } @@ -170,7 +170,7 @@ impl ConfirmDialog { self.focus = ConfirmDialogFocus::Buttons; // Default to buttons self.selected_option = SelectedOption::Provider; // Reset mode selections but keep provider selection - self.docker_selected = false; + self.selected_target = 0; self.yolo_selected = false; } @@ -181,13 +181,20 @@ impl ConfirmDialog { } } - /// Toggle docker mode - pub fn toggle_docker(&mut self) { - if self.docker_enabled { - self.docker_selected = !self.docker_selected; + /// Cycle to the next execution target (local -> docker -> named targets) + pub fn cycle_target(&mut self) { + if self.target_options.len() > 1 { + self.selected_target = (self.selected_target + 1) % self.target_options.len(); } } + /// The selected execution target name ("local" when nothing else is chosen) + pub fn selected_target_name(&self) -> &str { + self.target_options + .get(self.selected_target) + .map_or("local", String::as_str) + } + /// Toggle YOLO mode pub fn toggle_yolo(&mut self) { if self.yolo_enabled { @@ -204,7 +211,7 @@ impl ConfirmDialog { pub fn has_options(&self) -> bool { self.provider_options.len() > 1 || self.project_options.len() > 1 - || self.docker_enabled + || self.target_options.len() > 1 || self.yolo_enabled } @@ -583,21 +590,18 @@ impl ConfirmDialog { } // Docker option - if self.docker_enabled { - let (indicator, color) = if self.docker_selected { - ("●", Color::Green) + if self.target_options.len() > 1 { + let name = self.selected_target_name().to_string(); + let color = if self.selected_target == 0 { + Color::DarkGray } else { - ("○", Color::DarkGray) + Color::Green }; lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled("[D] ", Style::default().fg(Color::Yellow)), - Span::styled("Docker: ", Style::default().fg(Color::Gray)), - Span::styled(indicator, Style::default().fg(color)), - Span::styled( - if self.docker_selected { " On" } else { " Off" }, - Style::default().fg(color), - ), + Span::styled("Target: ", Style::default().fg(Color::Gray)), + Span::styled(name, Style::default().fg(color)), ])); } @@ -696,7 +700,7 @@ mod tests { assert_eq!(dialog.selection, ConfirmSelection::Yes); assert_eq!(dialog.focus, ConfirmDialogFocus::Buttons); assert!(dialog.provider_options.is_empty()); - assert!(!dialog.docker_selected); + assert_eq!(dialog.selected_target_name(), "local"); assert!(!dialog.yolo_selected); } @@ -719,11 +723,16 @@ mod tests { ]; let projects = vec!["project-a".to_string(), "project-b".to_string()]; - dialog.configure(providers, projects, true, false); + dialog.configure( + providers, + projects, + vec!["local".to_string(), "docker".to_string()], + false, + ); assert_eq!(dialog.provider_options.len(), 2); assert_eq!(dialog.project_options.len(), 2); - assert!(dialog.docker_enabled); + assert_eq!(dialog.target_options.len(), 2); assert!(!dialog.yolo_enabled); } @@ -733,11 +742,11 @@ mod tests { dialog.configure( vec![], vec!["project-a".to_string(), "project-b".to_string()], - false, + vec!["local".to_string(), "docker".to_string()], false, ); dialog.selection = ConfirmSelection::No; - dialog.docker_selected = true; + dialog.selected_target = 1; let ticket = make_test_ticket("project-b"); dialog.show(ticket); @@ -746,7 +755,7 @@ mod tests { assert!(dialog.ticket.is_some()); assert_eq!(dialog.selection, ConfirmSelection::Yes); assert_eq!(dialog.selected_project, 1); // project-b is at index 1 - assert!(!dialog.docker_selected); // Reset + assert_eq!(dialog.selected_target, 0); // Reset to local } #[test] @@ -783,18 +792,23 @@ mod tests { } #[test] - fn test_confirm_dialog_toggle_docker_respects_enabled() { + fn test_confirm_dialog_cycle_target_wraps_and_noops_when_local_only() { let mut dialog = ConfirmDialog::new(); - dialog.docker_enabled = false; - - dialog.toggle_docker(); - assert!(!dialog.docker_selected); // No-op when disabled - - dialog.docker_enabled = true; - dialog.toggle_docker(); - assert!(dialog.docker_selected); - dialog.toggle_docker(); - assert!(!dialog.docker_selected); + // Only "local": cycling is a no-op. + dialog.cycle_target(); + assert_eq!(dialog.selected_target_name(), "local"); + + dialog.target_options = vec![ + "local".to_string(), + "docker".to_string(), + "gpu-vm".to_string(), + ]; + dialog.cycle_target(); + assert_eq!(dialog.selected_target_name(), "docker"); + dialog.cycle_target(); + assert_eq!(dialog.selected_target_name(), "gpu-vm"); + dialog.cycle_target(); + assert_eq!(dialog.selected_target_name(), "local"); // Wraps } #[test] @@ -852,10 +866,10 @@ mod tests { let mut dialog = ConfirmDialog::new(); assert!(!dialog.has_options()); - dialog.docker_enabled = true; + dialog.target_options = vec!["local".to_string(), "docker".to_string()]; assert!(dialog.has_options()); - dialog.docker_enabled = false; + dialog.target_options = vec!["local".to_string()]; dialog.yolo_enabled = true; assert!(dialog.has_options()); @@ -880,7 +894,7 @@ mod tests { #[test] fn test_confirm_dialog_focus_management() { let mut dialog = ConfirmDialog::new(); - dialog.docker_enabled = true; // Enable options + dialog.target_options = vec!["local".to_string(), "docker".to_string()]; // Enable options assert!(!dialog.is_options_focused()); dialog.focus_options(); diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index a02e399f..95e62ce9 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -87,9 +87,14 @@ impl InProgressPanel { _ => (" ", Color::Reset), }; - // Check launch mode for docker and yolo - let is_docker = a.launch_mode.as_ref().is_some_and(|m| m.contains("docker")); - let is_yolo = a.launch_mode.as_ref().is_some_and(|m| m.contains("yolo")); + // Parse the persisted launch mode (never substring-match it) + let parsed = a + .launch_mode + .as_deref() + .map(crate::agents::parse_launch_mode) + .unwrap_or_default(); + let is_docker = parsed.kind == crate::agents::LaunchModeKind::Docker; + let is_yolo = parsed.yolo; // YOLO indicator with rainbow animation (6-second cycle: R -> G -> B) let yolo_indicator = if is_yolo { @@ -344,7 +349,7 @@ mod tests { last_content_change: None, pr_url: None, pr_number: None, - github_repo: None, + repo: None, pr_status: None, completed_steps: Vec::new(), llm_tool: None, @@ -354,6 +359,8 @@ mod tests { dev_server_pid: None, worktree_path: None, remote_host: None, + step_launch_context: None, + target_name: None, } } diff --git a/src/ui/keybindings.rs b/src/ui/keybindings.rs index 43e41206..c289023d 100644 --- a/src/ui/keybindings.rs +++ b/src/ui/keybindings.rs @@ -465,7 +465,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ key: KeyCode::Char('D'), modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('d')), - description: "Toggle Docker mode", + description: "Cycle execution target", category: ShortcutCategory::Actions, context: ShortcutContext::LaunchDialog, }, diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs index f846e8e2..9d64ac00 100644 --- a/src/ui/sections/llm_section.rs +++ b/src/ui/sections/llm_section.rs @@ -19,7 +19,7 @@ impl StatusSection for LlmSection { } fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { - if snapshot.llm_tools.is_empty() { + if snapshot.llm_tools.is_empty() || !snapshot.llm_tools.iter().any(|t| t.health_ok) { SectionHealth::Yellow } else { SectionHealth::Green @@ -48,7 +48,11 @@ impl StatusSection for LlmSection { id: tool.name.clone(), depth: 1, label: tool.name.clone(), - description: tool.version.clone(), + description: if tool.health_ok { + tool.version.clone() + } else { + format!("{} (health check failed)", tool.version) + }, icon: StatusIcon::Tool, brand_icon: None, is_header: false, diff --git a/src/ui/session_preview.rs b/src/ui/session_preview.rs index cb271c7e..92ea394a 100644 --- a/src/ui/session_preview.rs +++ b/src/ui/session_preview.rs @@ -335,7 +335,7 @@ mod tests { last_content_change: None, pr_url: None, pr_number: None, - github_repo: None, + repo: None, pr_status: None, completed_steps: vec![], llm_tool: None, @@ -345,6 +345,8 @@ mod tests { dev_server_pid: None, worktree_path: None, remote_host: None, + step_launch_context: None, + target_name: None, session_wrapper: None, session_window_ref: None, session_context_ref: None, diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index c984627a..e5435ac2 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -428,6 +428,8 @@ pub struct LlmToolInfo { pub name: String, pub version: String, pub model_aliases: Vec, + /// Passed its startup health check; unhealthy tools cannot launch locally + pub health_ok: bool, } /// Information about a configured delegator. @@ -661,8 +663,7 @@ impl StatusSnapshot { /// and the REST `/api/v1/sections` endpoint, which uses the config-derived /// result as-is. `issue_types` is passed in because the TUI and REST source /// it from different registries. Everything else here is derived purely from - /// config, so section *health* for the config-gated sections matches across - /// surfaces; only live runtime detail differs. + /// config, so section *health* for the config-gated sections matches across surfaces. pub fn from_config(config: &Config, issue_types: Vec) -> StatusSnapshot { let working_dir = std::env::current_dir() .map(|p| p.to_string_lossy().into_owned()) @@ -711,6 +712,7 @@ impl StatusSnapshot { name: t.name.clone(), version: t.version.clone(), model_aliases: t.model_aliases.clone(), + health_ok: t.health_ok, }) .collect(); @@ -1636,6 +1638,7 @@ mod tests { name: "Claude".into(), version: "3.5".into(), model_aliases: vec!["opus".into(), "sonnet".into(), "haiku".into()], + health_ok: true, }], default_llm_tool: None, default_llm_model: None, diff --git a/src/workflow_gen/agnt.rs b/src/workflow_gen/agnt.rs index f283cc67..24b9744c 100644 --- a/src/workflow_gen/agnt.rs +++ b/src/workflow_gen/agnt.rs @@ -321,6 +321,7 @@ fn review_label(review: &ReviewType) -> &'static str { ReviewType::Plan => "plan", ReviewType::Visual => "visual", ReviewType::Pr => "pr", + ReviewType::Proof => "proof", } } diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index e151b571..eea5cda2 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -290,6 +290,7 @@ fn review_label(review: &ReviewType) -> &'static str { ReviewType::Plan => "plan", ReviewType::Visual => "visual", ReviewType::Pr => "pr", + ReviewType::Proof => "proof", } } diff --git a/tests/acp_integration.rs b/tests/acp_integration.rs index 45d52d25..61108a12 100644 --- a/tests/acp_integration.rs +++ b/tests/acp_integration.rs @@ -82,6 +82,7 @@ name = "sleeper" path = "/bin/sleep" version = "noop" command_template = "sleep 60" +health_ok = true [[delegators]] name = "test-sleeper" @@ -120,6 +121,7 @@ name = "cat" path = "/bin/cat" version = "noop" command_template = "cat {{{{prompt_file}}}}" +health_ok = true [[delegators]] name = "test-cat" diff --git a/tests/distribution_bundling.rs b/tests/distribution_bundling.rs new file mode 100644 index 00000000..a82daddb --- /dev/null +++ b/tests/distribution_bundling.rs @@ -0,0 +1,83 @@ +//! Asserts the Docker and Coder distributions stage both halves of the +//! operator/opr8r server-client pair, not just the `operator` server. +//! +//! `opr8r` is the client binary agent sessions use to report step completion +//! back to `operator` for multi-step ticket workflows. It ships as a +//! separate release artifact (`opr8r/Cargo.toml`, kept in lockstep with +//! `VERSION` by `version_parity.rs`), but the Dockerfile, the Coder module's +//! install script, and the CI job that stages the Docker build context all +//! only reference `operator` unless they're kept in sync by hand. These +//! tests fail if `opr8r` staging drifts out of any of the three. + +use std::fs; +use std::path::{Path, PathBuf}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) +} + +#[test] +fn test_dockerfile_stages_opr8r() { + let content = read(&repo_root().join("Dockerfile")); + + assert!( + content.contains("COPY opr8r-linux-${TARGETARCH} /usr/local/bin/opr8r"), + "Dockerfile must COPY opr8r-linux-${{TARGETARCH}} alongside operator-linux-${{TARGETARCH}}" + ); + assert!( + content.contains(r#"RUN ["/usr/local/bin/opr8r", "--version"]"#), + "Dockerfile must smoke-test the staged opr8r binary at build time, like it does for operator" + ); +} + +#[test] +fn test_coder_run_script_installs_opr8r() { + let content = read(&repo_root().join("coder-module/run.sh")); + + assert!( + content.contains("opr8r-$PLATFORM"), + "coder-module/run.sh must download an opr8r-$PLATFORM release asset, like it does for operator" + ); + assert!( + content.contains("$CODER_SCRIPT_BIN_DIR/opr8r"), + "coder-module/run.sh must symlink opr8r into $CODER_SCRIPT_BIN_DIR, like it does for operator" + ); + + // Terraform's templatefile only treats `${` specially, so `$$` is emitted + // verbatim and `$$(cmd)` is a bash syntax error in the rendered script. + // Only `$${` (escaping a literal `${`) is legitimate here. + let over_escaped: Vec<_> = content + .lines() + .enumerate() + .filter(|(_, l)| l.contains("$$") && l.replace("$${", "").contains("$$")) + .map(|(i, l)| format!(" line {}: {}", i + 1, l.trim())) + .collect(); + assert!( + over_escaped.is_empty(), + "coder-module/run.sh over-escapes shell variables; `$$` renders literally and breaks the script.\nUse `$VAR` / `$(cmd)`, reserving `$${{` for a literal `${{`:\n{}", + over_escaped.join("\n") + ); +} + +#[test] +fn test_docker_ci_job_stages_opr8r_artifacts() { + let content = read(&repo_root().join(".github/workflows/build.yaml")); + + let docker_job_start = content + .find("\n docker:\n") + .expect("build.yaml must have a top-level `docker:` job"); + let docker_job = &content[docker_job_start..]; + + assert!( + docker_job.contains("opr8r-linux-*"), + "the docker job must download opr8r-linux-* release artifacts, like it does for operator-linux-*" + ); + assert!( + docker_job.contains("opr8r-linux-amd64") && docker_job.contains("opr8r-linux-arm64"), + "the docker job must stage opr8r-linux-amd64/arm64 into the build context, like it does for operator" + ); +} diff --git a/tests/docs_structure.rs b/tests/docs_structure.rs new file mode 100644 index 00000000..0e6a0a00 --- /dev/null +++ b/tests/docs_structure.rs @@ -0,0 +1,515 @@ +//! Structural alignment between the vertical catalog and the docs site. +//! +//! `docs/_data/navigation.yml` is hand-maintained (editorial ordering, section +//! titles) but its *contents* are enforced against +//! `operator::integrations::catalog` — every documented `Alpha`+ integration +//! must be reachable from the sidebar with its catalog icon, and the nav may +//! not advertise integrations the catalog doesn't know. The suite also guards +//! general docs hygiene: every nav URL resolves, every published page is +//! reachable, internal links resolve, and no page duplicates the layout's +//! front-matter title with a body H1. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use operator::api::providers::model_server::ModelServerKind; +use operator::integrations::{all_integrations, SupportStatus, Vertical}; + +/// Catalog entries deliberately absent from the sidebar for now. +/// Deleting a pair from this list forces the corresponding nav addition. +const NAV_DEFERRED: &[(&str, &str)] = &[("workflows", "claude"), ("workflows", "agnt")]; + +/// Leaf URLs under a vertical section that are supporting pages rather than catalog integrations. +const NAV_EXTRA_PAGES: &[&str] = &[ + "/getting-started/git/provider-support/", + "/getting-started/sessions/remote-hosts/", // execution-target concept page, not a session wrapper vertical +]; + +/// Published pages intentionally not linked from the sidebar. +const NAV_ORPHAN_ALLOWLIST: &[&str] = &[ + "index.md", // site home, hardcoded in the header + "VERSION.md", // raw version endpoint + "privacy-policy.md", // footer link + "terms-of-service.md", // footer link + "downloads/index.md", // hardcoded sidebar link + "getting-started/kanban/jira-api.md", // generated API appendix, linked from jira.md + "getting-started/workflows/index.md", // deferred nav section + "getting-started/workflows/claude.md", // deferred nav section + "getting-started/workflows/agnt.md", // deferred nav section +]; + +/// Nav titles allowed to differ from the target page's front-matter title +/// (intentional short sidebar labels), keyed by URL. +const NAV_TITLE_EXCEPTIONS: &[(&str, &str)] = &[ + ("/getting-started/", "Overview"), + ("/getting-started/platform-support/", "Platform Support"), + ("/getting-started/sessions/tmux/", "tmux"), + ("/getting-started/sessions/cmux/", "cmux"), + ("/getting-started/sessions/zellij/", "Zellij"), + ("/getting-started/sessions/vscode/", "VS Code Extension"), + ("/cli/", "CLI"), + ("/shortcuts/", "Shortcuts"), + ("/schemas/", "Overview"), + ("/schemas/config/", "Configuration"), + ("/schemas/state/", "State"), + ("/schemas/metadata/", "Ticket Metadata"), +]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Nav { + docs: Vec
, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Section { + // Deserialized for schema strictness; sections are grouping-only in the sidebar. + #[allow(dead_code)] + title: String, + children: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Item { + title: String, + url: Option, + // Deserialized for schema strictness; rendered by the sidebar, not asserted on. + #[allow(dead_code)] + codicon: Option, + children: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Leaf { + title: String, + url: String, + icon: Option, +} + +fn repo_path(rel: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(rel) +} + +/// Deserializing through the strict structs is itself the shape test: it +/// rejects a 4th nesting level, `icon` on items, `codicon` on leaves, and any +/// unknown key — exactly what `docs/_includes/sidebar.html` would silently drop. +fn load_nav() -> Nav { + let raw = std::fs::read_to_string(repo_path("docs/_data/navigation.yml")) + .expect("docs/_data/navigation.yml should be readable"); + serde_yaml::from_str(&raw).expect("navigation.yml should match the sidebar's 3-level schema") +} + +/// A docs URL resolves if it's `docs/.md` or `docs//index.md`. +fn docs_exists(docs_path: &str) -> bool { + let docs = repo_path("docs"); + docs.join(format!("{docs_path}.md")).exists() || docs.join(docs_path).join("index.md").exists() +} + +fn url_resolves(url: &str) -> bool { + let trimmed = url.trim_matches('/'); + if trimmed.is_empty() { + return repo_path("docs/index.md").exists(); + } + docs_exists(trimmed) +} + +fn nav_urls(nav: &Nav) -> Vec { + let mut urls = Vec::new(); + for section in &nav.docs { + for item in §ion.children { + urls.extend(item.url.clone()); + for leaf in item.children.iter().flatten() { + urls.push(leaf.url.clone()); + } + } + } + urls +} + +/// All leaves grouped by their parent item URL. +fn leaves_by_item(nav: &Nav) -> BTreeMap> { + let mut map: BTreeMap> = BTreeMap::new(); + for section in &nav.docs { + for item in §ion.children { + if let (Some(url), Some(children)) = (&item.url, &item.children) { + map.entry(url.clone()).or_default().extend(children.iter()); + } + } + } + map +} + +fn front_matter(content: &str) -> Option<&str> { + let rest = content.strip_prefix("---\n")?; + rest.split("\n---").next() +} + +fn front_matter_title(content: &str) -> Option { + front_matter(content)? + .lines() + .find_map(|l| l.strip_prefix("title:")) + .map(|t| t.trim().trim_matches('"').to_string()) +} + +fn body_after_front_matter(content: &str) -> &str { + match content + .strip_prefix("---\n") + .and_then(|r| r.split_once("\n---")) + { + Some((_, body)) => body.trim_start_matches('-').trim_start(), + None => content, + } +} + +/// Every published markdown page under `docs/`, as paths relative to `docs/`. +/// Skips Jekyll internals, excluded trees, and `published: false` pages. +fn published_pages() -> Vec { + const SKIP_DIRS: &[&str] = &[ + "_site", + "_includes", + "_layouts", + "_data", + "assets", + "collections", + "superpowers", + "architecture", + "vendor", + ]; + let docs = repo_path("docs"); + let mut pages = Vec::new(); + let mut stack = vec![docs.clone()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("docs dir should be readable") { + let path = entry.expect("dir entry").path(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + if path.is_dir() { + if !(dir == docs && SKIP_DIRS.contains(&name.as_str())) { + stack.push(path); + } + } else if path.extension().is_some_and(|e| e == "md") { + let content = std::fs::read_to_string(&path).expect("page should be readable"); + let unpublished = front_matter(&content) + .is_some_and(|fm| fm.lines().any(|l| l.trim() == "published: false")); + if !unpublished { + let rel = path.strip_prefix(&docs).unwrap(); + pages.push(rel.to_string_lossy().to_string()); + } + } + } + } + pages.sort(); + pages +} + +/// URL a docs-relative page path serves under `permalink: pretty`. +fn page_url(rel: &str) -> String { + let stem = rel.strip_suffix(".md").unwrap_or(rel); + match stem + .strip_suffix("/index") + .or(if stem == "index" { Some("") } else { None }) + { + Some("") => "/".to_string(), + Some(dir) => format!("/{dir}/"), + None => format!("/{stem}/"), + } +} + +#[test] +fn test_nav_urls_resolve() { + let nav = load_nav(); + for url in nav_urls(&nav) { + assert!( + url_resolves(&url), + "navigation.yml links '{url}' but no docs page exists for it" + ); + } +} + +#[test] +fn test_catalog_entries_in_nav() { + let nav = load_nav(); + let by_item = leaves_by_item(&nav); + for e in all_integrations() { + let Some(docs_path) = e.docs_path else { + continue; + }; + if e.status < SupportStatus::Alpha || NAV_DEFERRED.contains(&(e.vertical.slug(), e.slug)) { + continue; + } + let section_url = format!("/{}/", e.vertical.docs_section()); + let expected = format!("/{docs_path}/"); + let leaves = by_item.get(§ion_url).unwrap_or_else(|| { + panic!( + "navigation.yml has no section item at '{section_url}' for the {} vertical", + e.vertical.label() + ) + }); + assert!( + leaves.iter().any(|l| l.url == expected), + "catalog entry '{}/{}' ({}) is missing from navigation.yml under '{section_url}' \ + (expected a leaf with url '{expected}')", + e.vertical.slug(), + e.slug, + e.status.label() + ); + } +} + +#[test] +fn test_nav_vertical_leaves_map_to_catalog() { + let nav = load_nav(); + let by_item = leaves_by_item(&nav); + let section_urls: BTreeSet = Vertical::ALL + .iter() + .map(|v| format!("/{}/", v.docs_section())) + .collect(); + let catalog_urls: BTreeSet = all_integrations() + .iter() + .filter_map(|e| e.docs_path.map(|p| format!("/{p}/"))) + .collect(); + for (item_url, leaves) in &by_item { + if !section_urls.contains(item_url) { + continue; + } + for leaf in leaves { + assert!( + catalog_urls.contains(&leaf.url) || NAV_EXTRA_PAGES.contains(&leaf.url.as_str()), + "nav leaf '{}' ({}) under '{item_url}' advertises a page with no catalog entry — \ + add it to src/integrations/catalog.rs or NAV_EXTRA_PAGES", + leaf.title, + leaf.url + ); + } + } +} + +#[test] +fn test_catalog_icons_exist() { + for e in all_integrations() { + if let Some(icon) = e.icon { + assert!( + repo_path(&format!("docs/assets/icons/{icon}.svg")).exists(), + "catalog entry '{}/{}' names icon '{icon}' but docs/assets/icons/{icon}.svg is missing", + e.vertical.slug(), + e.slug + ); + } + if e.status >= SupportStatus::Alpha && e.docs_path.is_some() { + assert!( + e.icon.is_some(), + "documented Alpha+ entry '{}/{}' must declare a brand icon", + e.vertical.slug(), + e.slug + ); + } + } +} + +#[test] +fn test_nav_icons_match_catalog() { + let nav = load_nav(); + let by_item = leaves_by_item(&nav); + let catalog: BTreeMap> = all_integrations() + .iter() + .filter_map(|e| e.docs_path.map(|p| (format!("/{p}/"), e.icon))) + .collect(); + + let mut referenced: BTreeSet = BTreeSet::new(); + for leaves in by_item.values() { + for leaf in leaves { + if let Some(icon) = &leaf.icon { + referenced.insert(icon.clone()); + assert!( + repo_path(&format!("docs/assets/icons/{icon}.svg")).exists(), + "nav leaf '{}' references icon '{icon}' with no docs/assets/icons/{icon}.svg", + leaf.title + ); + } + if let Some(expected) = catalog.get(&leaf.url) { + assert_eq!( + leaf.icon.as_deref(), + *expected, + "nav leaf '{}' ({}) icon must match the catalog entry's icon", + leaf.title, + leaf.url + ); + } + } + } + + let icons_dir = repo_path("docs/assets/icons"); + for entry in std::fs::read_dir(icons_dir).expect("icons dir should be readable") { + let name = entry + .expect("dir entry") + .file_name() + .to_string_lossy() + .to_string(); + if let Some(stem) = name.strip_suffix(".svg") { + assert!( + referenced.contains(stem), + "docs/assets/icons/{name} is referenced by no navigation.yml entry — remove it or wire it up" + ); + } + } +} + +#[test] +fn test_model_brand_icons_consistent() { + for kind in ModelServerKind::ALL { + if let Some(brand) = kind.brand_icon() { + let entry = all_integrations() + .into_iter() + .find(|e| e.vertical == Vertical::Model && e.slug == kind.slug()) + .unwrap_or_else(|| panic!("model kind '{}' missing from catalog", kind.slug())); + assert_eq!( + entry.icon, + Some(brand), + "catalog icon for model '{}' must match ModelServerKind::brand_icon()", + kind.slug() + ); + } + } +} + +#[test] +fn test_nav_titles_match_pages() { + let nav = load_nav(); + let exceptions: BTreeMap<&str, &str> = NAV_TITLE_EXCEPTIONS.iter().copied().collect(); + let check = |title: &str, url: &str| { + if exceptions.get(url) == Some(&title) { + return; + } + let trimmed = url.trim_matches('/'); + let docs = repo_path("docs"); + let file = if docs.join(format!("{trimmed}.md")).exists() { + docs.join(format!("{trimmed}.md")) + } else { + docs.join(trimmed).join("index.md") + }; + let content = std::fs::read_to_string(&file) + .unwrap_or_else(|_| panic!("page for nav url '{url}' should be readable")); + let page_title = front_matter_title(&content) + .unwrap_or_else(|| panic!("page for nav url '{url}' has no front-matter title")); + assert_eq!( + title, page_title, + "nav title for '{url}' differs from the page's front-matter title — \ + align them or add a NAV_TITLE_EXCEPTIONS entry" + ); + }; + for section in &nav.docs { + for item in §ion.children { + if let Some(url) = &item.url { + check(&item.title, url); + } + for leaf in item.children.iter().flatten() { + check(&leaf.title, &leaf.url); + } + } + } +} + +#[test] +fn test_docs_pages_reachable() { + let nav = load_nav(); + let reachable: BTreeSet = nav_urls(&nav).into_iter().collect(); + for page in published_pages() { + if NAV_ORPHAN_ALLOWLIST.contains(&page.as_str()) { + continue; + } + // The generated collections hub under docs/workflows/ is linked from + // the hardcoded sidebar entry, not navigation.yml. + if page.starts_with("workflows/") { + continue; + } + let url = page_url(&page); + assert!( + reachable.contains(&url), + "docs/{page} ({url}) is published but unreachable from navigation.yml — \ + add a nav entry or extend NAV_ORPHAN_ALLOWLIST" + ); + } +} + +#[test] +fn test_internal_links_resolve() { + for page in published_pages() { + let content = std::fs::read_to_string(repo_path(&format!("docs/{page}"))) + .expect("page should be readable"); + let mut in_fence = false; + for (lineno, line) in content.lines().enumerate() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence { + continue; + } + let mut rest = line; + while let Some(i) = rest.find("](/") { + let after = &rest[i + 2..]; + let Some(end) = after.find(')') else { break }; + let target = after[..end].split(['#', '?']).next().unwrap_or(""); + rest = &after[end..]; + if target.is_empty() || target == "/" { + continue; + } + let as_file = repo_path(&format!("docs/{}", target.trim_start_matches('/'))); + assert!( + url_resolves(target) || as_file.is_file(), + "docs/{page}:{} links to '{target}' which resolves to no page or asset", + lineno + 1 + ); + } + } + } +} + +#[test] +fn test_section_indexes_list_catalog_entries() { + for e in all_integrations() { + let Some(docs_path) = e.docs_path else { + continue; + }; + if e.status < SupportStatus::Alpha { + continue; + } + let index = repo_path(&format!("docs/{}/index.md", e.vertical.docs_section())); + let content = std::fs::read_to_string(&index).unwrap_or_else(|_| { + panic!( + "section index for {} vertical should exist at {}", + e.vertical.label(), + index.display() + ) + }); + assert!( + content.contains(&format!("/{docs_path}/")), + "section index docs/{}/index.md does not link catalog entry '{}/{}' (/{docs_path}/)", + e.vertical.docs_section(), + e.vertical.slug(), + e.slug + ); + } +} + +#[test] +fn test_no_duplicate_body_h1() { + for page in published_pages() { + let content = std::fs::read_to_string(repo_path(&format!("docs/{page}"))) + .expect("page should be readable"); + if front_matter_title(&content).is_none() { + continue; + } + let body = body_after_front_matter(&content); + let first_line = body.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + assert!( + !first_line.starts_with("# "), + "docs/{page} opens with a body H1 ('{first_line}') — the doc layout already \ + renders the front-matter title; remove the duplicate heading" + ); + } +} diff --git a/tests/launch_common/mod.rs b/tests/launch_common/mod.rs index f1f152ef..66901e01 100644 --- a/tests/launch_common/mod.rs +++ b/tests/launch_common/mod.rs @@ -167,6 +167,7 @@ version_ok = true model_aliases = ["sonnet", "opus", "haiku"] command_template = "{mock_llm} {{{{config_flags}}}}{{{{model_flag}}}}--session-id {{{{session_id}}}} --print-prompt-path {{{{prompt_file}}}}" yolo_flags = ["--dangerously-skip-permissions"] +health_ok = true [llm_tools.detected.capabilities] supports_sessions = true @@ -256,6 +257,7 @@ version_ok = true model_aliases = ["sonnet", "opus", "haiku"] command_template = "{mock_llm} {{{{config_flags}}}}{{{{model_flag}}}}--session-id {{{{session_id}}}} --print-prompt-path {{{{prompt_file}}}}" yolo_flags = ["--dangerously-skip-permissions"] +health_ok = true [llm_tools.detected.capabilities] supports_sessions = true diff --git a/tests/version_parity.rs b/tests/version_parity.rs index 0bba544e..1d1a5ece 100644 --- a/tests/version_parity.rs +++ b/tests/version_parity.rs @@ -17,6 +17,10 @@ enum ExtractKind { YamlVersion, /// `const VERSION = '...'` (webhook-server.ts). TsConst, + /// `default = "..."` of the named HCL `variable` block (coder-module/main.tf). + /// The Coder module pins the release tag it downloads, so an unbumped + /// default points workspaces at a nonexistent GitHub release. + HclVariableDefault(&'static str), } /// Repo root = crate manifest dir (tests run with CWD at the crate root). @@ -57,6 +61,13 @@ fn extract(kind: &ExtractKind, content: &str) -> Option { .lines() .find(|l| l.contains("const VERSION")) .and_then(|l| between_quotes_after(l, "=")), + ExtractKind::HclVariableDefault(name) => { + let start = content.find(&format!("variable \"{name}\""))?; + content[start..] + .lines() + .find(|l| l.trim_start().starts_with("default")) + .and_then(|l| between_quotes_after(l, "=")) + } } } @@ -78,6 +89,10 @@ const MANAGED: &[(&str, ExtractKind)] = &[ ("agnt-plugin/package.json", ExtractKind::JsonDotVersion), ("agnt-plugin/manifest.json", ExtractKind::JsonDotVersion), ("docs/schemas/openapi.json", ExtractKind::JsonDotVersion), + ( + "coder-module/main.tf", + ExtractKind::HclVariableDefault("install_version"), + ), ]; #[test] diff --git a/ui/src/components/TicketDetailPanel.tsx b/ui/src/components/TicketDetailPanel.tsx index 8cef7992..821c2f0d 100644 --- a/ui/src/components/TicketDetailPanel.tsx +++ b/ui/src/components/TicketDetailPanel.tsx @@ -27,6 +27,7 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { // Launch form state. const [delegator, setDelegator] = useState(''); // '' = default chain const [wrapper, setWrapper] = useState(''); // '' = configured default + const [target, setTarget] = useState(''); // '' = delegator's target const [yolo, setYolo] = useState(false); const [config, setConfig] = useState(null); @@ -72,6 +73,18 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { const defaultWrapperLabel = config?.sessions.wrapper ?? 'configured'; const delegators = useMemo(() => config?.delegators ?? [], [config]); + // Named execution targets: explicit [[targets]] entries, [[hosts]] synths, + // and the synthesized docker target when an image is configured. + const targets = useMemo(() => { + if (!config) return [] as string[]; + const names = [ + ...(config.targets ?? []).map((t) => t.name), + ...(config.hosts ?? []).map((h) => h.name), + ]; + if (config.launch.docker.image) names.push('docker'); + return names; + }, [config]); + const onLaunch = () => { setLaunching(true); setLaunchError(null); @@ -85,6 +98,7 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { wrapper: wrapper || null, retry_reason: null, resume_session_id: null, + target: target || null, }) .then((r) => setResult(r)) .catch((e) => setLaunchError(e instanceof Error ? e.message : 'Launch failed')) @@ -151,6 +165,24 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { + {targets.length > 0 && ( + + )} +