From 6dd75d3fbc4f047568fc8c44f553ffc852013b1f Mon Sep 17 00:00:00 2001 From: untra Date: Sat, 18 Apr 2026 21:03:48 -0600 Subject: [PATCH 1/6] model servers structure, delegator definition adjustments, multi-step issuetype tasks --- README.md | 52 +- bindings/Config.ts | 8 +- bindings/CreateDelegatorFromToolRequest.ts | 4 + bindings/CreateDelegatorRequest.ts | 4 + bindings/CreateModelServerRequest.ts | 30 + bindings/Delegator.ts | 8 +- bindings/DelegatorResponse.ts | 4 + bindings/ModelServer.ts | 39 + bindings/ModelServerResponse.ts | 34 + bindings/ModelServersResponse.ts | 15 + bindings/MultiAgentGroup.ts | 55 + bindings/MultiAgentPhase.ts | 6 + bindings/PendingSubAgent.ts | 18 + bindings/SectionId.ts | 2 +- bindings/State.ts | 7 +- config/default.toml | 31 +- docs/cli/index.md | 4 + docs/configuration/index.md | 22 +- docs/getting-started/kanban/jira-api.md | 1 + docs/getting-started/model-servers/index.md | 126 ++ docs/schemas/config.json | 267 +++- docs/schemas/config.md | 112 +- docs/schemas/issuetype.md | 347 +++- docs/schemas/jira-api.json | 8 + docs/schemas/openapi.json | 743 ++++++++- docs/schemas/operator_output.json | 99 ++ docs/schemas/project_analysis.json | 919 +++++++++++ docs/schemas/state.json | 148 +- docs/schemas/state.md | 45 +- docs/shortcuts/index.md | 34 +- .../2026-04-18-polymorphic-steps-handoff.md | 397 +++++ shared/types.ts | 133 +- src/agents/agent_switcher.rs | 14 +- src/agents/delegator_resolution.rs | 84 +- src/agents/launcher/cmux_session.rs | 8 +- src/agents/launcher/mod.rs | 342 +++- src/agents/launcher/options.rs | 4 + src/agents/launcher/step_config.rs | 68 +- src/agents/launcher/tests.rs | 204 +++ src/agents/launcher/tmux_session.rs | 10 +- src/agents/launcher/zellij_session.rs | 10 +- src/agents/sync.rs | 197 ++- src/api/providers/kanban/github_projects.rs | 39 + src/api/providers/kanban/jira.rs | 43 + src/api/providers/kanban/linear.rs | 42 +- src/api/providers/kanban/mod.rs | 2 + src/api/providers/kanban/onboarding.rs | 178 +++ src/backstage/analyzer.rs | 43 +- src/config.rs | 297 ++++ src/docs_gen/issuetype.rs | 31 +- src/docs_gen/issuetype_json_schema.rs | 133 ++ src/docs_gen/mod.rs | 6 + src/docs_gen/operator_output_schema.rs | 99 ++ src/docs_gen/project_analysis_schema.rs | 115 ++ src/issuetypes/schema.rs | 21 +- src/lib.rs | 1 + src/main.rs | 111 +- src/permissions/mod.rs | 15 +- src/queue/ticket.rs | 6 +- src/rest/dto.rs | 76 + src/rest/mod.rs | 11 + src/rest/openapi.rs | 19 +- src/rest/routes/delegators.rs | 8 + src/rest/routes/launch.rs | 344 ++++ src/rest/routes/mod.rs | 1 + src/rest/routes/model_servers.rs | 252 +++ src/schemas/issuetype_schema.json | 1394 ++++++++++++++--- src/services/kanban_issuetype_service.rs | 114 ++ src/state.rs | 389 +++++ src/steps/manager.rs | 286 ++++ src/steps/session.rs | 17 +- src/templates/mod.rs | 1 + src/templates/schema.rs | 994 +++++++++++- src/templates/step_type.rs | 891 +++++++++++ src/ui/dashboard.rs | 27 + src/ui/sections/connections_section.rs | 1 + src/ui/sections/delegator_section.rs | 81 +- src/ui/sections/git_section.rs | 1 + src/ui/sections/kanban_section.rs | 1 + src/ui/sections/mod.rs | 2 + src/ui/sections/modelserver_section.rs | 217 +++ src/ui/status_panel.rs | 21 + .../src/sections/config-section.ts | 1 + .../src/sections/connections-section.ts | 1 + .../src/sections/delegator-section.ts | 6 +- vscode-extension/src/sections/git-section.ts | 2 + vscode-extension/src/sections/index.ts | 1 + .../src/sections/issuetype-section.ts | 2 + .../src/sections/kanban-section.ts | 1 + vscode-extension/src/sections/llm-section.ts | 1 + .../src/sections/managed-projects-section.ts | 2 + .../src/sections/modelserver-section.ts | 114 ++ vscode-extension/src/status-item.ts | 23 +- vscode-extension/src/status-provider.ts | 4 + 94 files changed, 10577 insertions(+), 574 deletions(-) create mode 100644 bindings/CreateModelServerRequest.ts create mode 100644 bindings/ModelServer.ts create mode 100644 bindings/ModelServerResponse.ts create mode 100644 bindings/ModelServersResponse.ts create mode 100644 bindings/MultiAgentGroup.ts create mode 100644 bindings/MultiAgentPhase.ts create mode 100644 bindings/PendingSubAgent.ts create mode 100644 docs/getting-started/model-servers/index.md create mode 100644 docs/schemas/operator_output.json create mode 100644 docs/schemas/project_analysis.json create mode 100644 docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md create mode 100644 src/api/providers/kanban/onboarding.rs create mode 100644 src/docs_gen/issuetype_json_schema.rs create mode 100644 src/docs_gen/operator_output_schema.rs create mode 100644 src/docs_gen/project_analysis_schema.rs create mode 100644 src/rest/routes/model_servers.rs create mode 100644 src/templates/step_type.rs create mode 100644 src/ui/sections/modelserver_section.rs create mode 100644 vscode-extension/src/sections/modelserver-section.ts diff --git a/README.md b/README.md index 494e5af..446c75d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-st **Operator** is for you if: - you do work assigned from tickets on a kanban board, such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/), [_Linear_](https://operator.untra.io/getting-started/kanban/linear/), or [_GitHub Projects_](https://operator.untra.io/getting-started/kanban/github/) -- you use LLM assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/), or [_Gemini CLI_](https://operator.untra.io/getting-started/agents/gemini-cli/) +- you use LLM assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/), or [_Google Gemini CLI_](https://operator.untra.io/getting-started/agents/gemini-cli/) - your work is version controlled with a git repository provider like [_GitHub_](https://operator.untra.io/getting-started/git/github/) or [_GitLab_](https://operator.untra.io/getting-started/git/gitlab/) - you are drowning in the AI software development soup. @@ -236,6 +236,56 @@ Create a new JSON tool config following the schema in `src/llm/tools/tool_config - Should support session/conversation ID for continuity - Should run interactively in a terminal (for session wrapper integration) +## Model Servers + +Operator's agent-launch hierarchy has three layers: + +``` +┌─ llm_tools ─────────┐ ┌─ model_servers ──────┐ +│ claude (detected) │ │ anthropic-api (impl.)│ +│ codex (detected) │ │ openai-api (impl.)│ +│ gemini (detected) │ │ google-api (impl.)│ +│ │ │ ollama-local (user) │ +└─────────────────────┘ └──────────────────────┘ + ▲ ▲ + │ │ + └───── delegators ───────┘ + name, llm_tool, model, model_server (optional) +``` + +- **`llm_tools`** are the agentic coding-agent CLIs (claude/codex/gemini). They're detected on PATH and drive the session. +- **`model_servers`** name the host that serves the model weights. Implicit builtins (`anthropic-api`, `openai-api`, `google-api`) exist without declaration. Users can declare additional servers for ollama, lmstudio, vllm, or any OpenAI-compatible endpoint. +- **`delegators`** are named `(llm_tool, model, model_server?)` triples used to launch a ticket. When `model_server` is omitted, the llm_tool's implicit vendor default is used. + +Example `operator.toml`: + +```toml +[[model_servers]] +name = "ollama-local" +kind = "ollama" +base_url = "http://localhost:11434" + +[[delegators]] +name = "codex-local-qwen" +llm_tool = "codex" +model = "qwen2.5-coder" +model_server = "ollama-local" +``` + +Ad-hoc launch flags: + +```bash +# Named delegator (recommended) +operator launch --delegator codex-local-qwen + +# Ad-hoc override +operator launch --llm-tool codex --model qwen2.5-coder --model-server ollama-local +``` + +**Protocol compatibility.** Codex speaks the OpenAI API — pairing with ollama requires no bridge. Claude and Gemini use their own vendor protocols and require a translating proxy (e.g. `claude-code-router`, `litellm-proxy`) between the CLI and ollama; declare the bridge URL as your `model_server.base_url`. + +Current release ships the infrastructure — ollama detection and automatic env-var injection on spawn land in the next release. See `docs/getting-started/model-servers/` for the full walkthrough. + ## Development ```bash diff --git a/bindings/Config.ts b/bindings/Config.ts index fc10970..231cc7b 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -8,6 +8,7 @@ import type { KanbanConfig } from "./KanbanConfig"; import type { LaunchConfig } from "./LaunchConfig"; import type { LlmToolsConfig } from "./LlmToolsConfig"; import type { LoggingConfig } from "./LoggingConfig"; +import type { ModelServer } from "./ModelServer"; import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; import type { QueueConfig } from "./QueueConfig"; @@ -38,4 +39,9 @@ version_check: VersionCheckConfig, /** * Agent delegator configurations for autonomous ticket launching */ -delegators: Array, }; +delegators: Array, +/** + * 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. + */ +model_servers: Array, }; diff --git a/bindings/CreateDelegatorFromToolRequest.ts b/bindings/CreateDelegatorFromToolRequest.ts index 4c5a9a0..f4ac888 100644 --- a/bindings/CreateDelegatorFromToolRequest.ts +++ b/bindings/CreateDelegatorFromToolRequest.ts @@ -25,6 +25,10 @@ name: string | null, * Optional display name for UI */ display_name: string | null, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, /** * Optional launch configuration */ diff --git a/bindings/CreateDelegatorRequest.ts b/bindings/CreateDelegatorRequest.ts index 254e43b..40743b6 100644 --- a/bindings/CreateDelegatorRequest.ts +++ b/bindings/CreateDelegatorRequest.ts @@ -25,6 +25,10 @@ display_name: string | null, * Arbitrary model properties */ model_properties: { [key in string]?: string }, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, /** * Optional launch configuration */ diff --git a/bindings/CreateModelServerRequest.ts b/bindings/CreateModelServerRequest.ts new file mode 100644 index 0000000..baeed11 --- /dev/null +++ b/bindings/CreateModelServerRequest.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to create a new model server + */ +export type CreateModelServerRequest = { +/** + * Unique name for this model server + */ +name: string, +/** + * Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + */ +kind: string, +/** + * Base URL of the inference endpoint + */ +base_url: string | null, +/** + * Name of an env var providing the API key + */ +api_key_env: string | null, +/** + * Additional environment variables + */ +extra_env: { [key in string]?: string }, +/** + * Optional display name for UI + */ +display_name: string | null, }; diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index bdbe2c3..ea631ee 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -31,4 +31,10 @@ model_properties: { [key in string]?: string }, /** * Optional launch configuration */ -launch_config: DelegatorLaunchConfig | null, }; +launch_config: DelegatorLaunchConfig | null, +/** + * Name of a declared `ModelServer` (from `Config.model_servers`). + * `None` means use the `llm_tool`'s implicit vendor default + * (claude → anthropic-api, codex → openai-api, gemini → google-api). + */ +model_server: string | null, }; diff --git a/bindings/DelegatorResponse.ts b/bindings/DelegatorResponse.ts index 4a10788..5cdecef 100644 --- a/bindings/DelegatorResponse.ts +++ b/bindings/DelegatorResponse.ts @@ -25,6 +25,10 @@ display_name: string | null, * Arbitrary model properties */ model_properties: { [key in string]?: string }, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, /** * Optional launch configuration */ diff --git a/bindings/ModelServer.ts b/bindings/ModelServer.ts new file mode 100644 index 0000000..c1650f7 --- /dev/null +++ b/bindings/ModelServer.ts @@ -0,0 +1,39 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A named host that serves models via an inference API. + * + * Model servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI + * (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint + * (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). + * + * Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are + * returned by [`implicit_model_server_for_tool`] and do not need to be declared + * in config. + */ +export type ModelServer = { +/** + * Unique name (e.g., "ollama-local", "vllm-gpu1") + */ +name: string, +/** + * Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + */ +kind: string, +/** + * Base URL of the inference endpoint (e.g., `http://localhost:11434`). + * `None` for implicit vendor servers means use the SDK default. + */ +base_url: string | null, +/** + * Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + */ +api_key_env: string | null, +/** + * Additional environment variables set when spawning agents that use this server + */ +extra_env: { [key in string]?: string }, +/** + * Optional display name for UI + */ +display_name: string | null, }; diff --git a/bindings/ModelServerResponse.ts b/bindings/ModelServerResponse.ts new file mode 100644 index 0000000..a0bb471 --- /dev/null +++ b/bindings/ModelServerResponse.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response for a single model server + */ +export type ModelServerResponse = { +/** + * Unique name (e.g., "ollama-local") + */ +name: string, +/** + * Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + */ +kind: string, +/** + * Base URL of the inference endpoint (e.g., `http://localhost:11434`) + */ +base_url: string | null, +/** + * Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + */ +api_key_env: string | null, +/** + * Additional environment variables set when spawning agents that use this server + */ +extra_env: { [key in string]?: string }, +/** + * Optional display name for UI + */ +display_name: string | null, +/** + * Whether this is a user-declared server (true) or an implicit builtin (false) + */ +user_declared: boolean, }; diff --git a/bindings/ModelServersResponse.ts b/bindings/ModelServersResponse.ts new file mode 100644 index 0000000..bf950e1 --- /dev/null +++ b/bindings/ModelServersResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelServerResponse } from "./ModelServerResponse"; + +/** + * Response listing all model servers (declared + implicit builtins) + */ +export type ModelServersResponse = { +/** + * List of model servers + */ +servers: Array, +/** + * Total count + */ +total: number, }; diff --git a/bindings/MultiAgentGroup.ts b/bindings/MultiAgentGroup.ts new file mode 100644 index 0000000..41fa3cd --- /dev/null +++ b/bindings/MultiAgentGroup.ts @@ -0,0 +1,55 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; +import type { MultiAgentPhase } from "./MultiAgentPhase"; +import type { PendingSubAgent } from "./PendingSubAgent"; + +/** + * Tracks a group of agents working on a single multi-agent step + */ +export type MultiAgentGroup = { +/** + * Unique group identifier + */ +group_id: string, +/** + * Ticket this group belongs to + */ +ticket_id: string, +/** + * Step name being executed + */ +step_name: string, +/** + * Step type (`multi_model`, `multi_prompt`, `matrixed`) + */ +step_type: string, +/** + * Agent IDs in this group (populated as sub-agents launch) + */ +agent_ids: Array, +/** + * Current execution phase + */ +phase: MultiAgentPhase, +/** + * Collected outputs from completed sub-agents, keyed by `variant_key` + * (delegator name for `multi_model`, index for `multi_prompt`, + * `{delegator}:{prompt_idx}` for `matrixed`). + */ +individual_outputs: { [key in string]?: JsonValue }, +/** + * Final aggregated output (set when phase = Complete) + */ +aggregated_output: JsonValue | null, +/** + * Total sub-agents expected (`agent_ids.len() + pending_launches.len()`). + */ +expected_total: number, +/** + * Sub-agents that still need launching (waiting for a free slot). + */ +pending_launches: Array, +/** + * Maps launched `agent_id` to the `variant_key` used as the output key. + */ +agent_variant_keys: { [key in string]?: string }, }; diff --git a/bindings/MultiAgentPhase.ts b/bindings/MultiAgentPhase.ts new file mode 100644 index 0000000..f236bb6 --- /dev/null +++ b/bindings/MultiAgentPhase.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Execution phase for a multi-agent group + */ +export type MultiAgentPhase = "fan_out" | "voting" | "complete" | "failed"; diff --git a/bindings/PendingSubAgent.ts b/bindings/PendingSubAgent.ts new file mode 100644 index 0000000..9639ea6 --- /dev/null +++ b/bindings/PendingSubAgent.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. + +/** + * A sub-agent that has been planned but not yet launched (slot queue). + */ +export type PendingSubAgent = { +/** + * Delegator (from `config.delegators`) this sub-agent should use. + */ +delegator_name: string, +/** + * Fully-rendered prompt text for this sub-agent. + */ +prompt: string, +/** + * Key under which this sub-agent's output is recorded (see `individual_outputs`). + */ +variant_key: string, }; diff --git a/bindings/SectionId.ts b/bindings/SectionId.ts index e3bfcca..6b0d98a 100644 --- a/bindings/SectionId.ts +++ b/bindings/SectionId.ts @@ -5,4 +5,4 @@ * * String values match the `sectionId` used in the `VSCode` extension tree routing. */ -export type SectionId = "config" | "connections" | "kanban" | "llm" | "git" | "issuetypes" | "delegators" | "projects"; +export type SectionId = "config" | "connections" | "kanban" | "llm" | "model-servers" | "git" | "issuetypes" | "delegators" | "projects"; diff --git a/bindings/State.ts b/bindings/State.ts index 09e35dd..c2852cb 100644 --- a/bindings/State.ts +++ b/bindings/State.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AgentState } from "./AgentState"; import type { CompletedTicket } from "./CompletedTicket"; +import type { MultiAgentGroup } from "./MultiAgentGroup"; import type { ProjectLlmStats } from "./ProjectLlmStats"; export type State = { paused: boolean, agents: Array, completed: Array, @@ -11,4 +12,8 @@ project_llm_stats: { [key in string]?: ProjectLlmStats }, /** * Per-project issue type collection preferences (`project_name` -> `collection_name`) */ -project_collection_prefs: { [key in string]?: string }, }; +project_collection_prefs: { [key in string]?: string }, +/** + * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) + */ +multi_agent_groups: Array, }; diff --git a/config/default.toml b/config/default.toml index 37a7d93..a100a6b 100644 --- a/config/default.toml +++ b/config/default.toml @@ -106,8 +106,30 @@ webhook_port = 7009 # Connection timeout in milliseconds connect_timeout_ms = 5000 +# Model servers (where models are hosted) +# Implicit builtins exist for anthropic-api, openai-api, google-api and do not +# need to be declared. Declare a `[[model_servers]]` entry when pointing a +# delegator at an alternate host (ollama, lmstudio, vllm, any OpenAI-compatible +# endpoint). +# +# [[model_servers]] +# name = "ollama-local" +# kind = "ollama" +# base_url = "http://localhost:11434" +# display_name = "Ollama (local)" +# +# # Example for running an Anthropic-protocol bridge (e.g. claude-code-router) +# # in front of ollama so the `claude` CLI can talk to local models: +# [[model_servers]] +# name = "claude-router" +# kind = "anthropic-api" +# base_url = "http://localhost:4000" +# api_key_env = "ANTHROPIC_API_KEY" + # Agent delegator configurations -# Delegators are named {tool, model} pairings for autonomous ticket launching +# Delegators are named {tool, model, model_server} triples for autonomous +# ticket launching. `model_server` is optional — omit it to use the llm_tool's +# implicit vendor default (claude → anthropic-api, codex → openai-api, etc.). # [[delegators]] # name = "claude-opus-auto" # llm_tool = "claude" @@ -117,6 +139,13 @@ connect_timeout_ms = 5000 # yolo = true # permission_mode = "delegate" # flags = [] +# +# # Codex pointed at a locally-hosted Qwen model via ollama +# [[delegators]] +# name = "codex-local-qwen" +# llm_tool = "codex" +# model = "qwen2.5-coder" +# model_server = "ollama-local" [version_check] # Enable automatic version checking on startup diff --git a/docs/cli/index.md b/docs/cli/index.md index b13efd8..db57386 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -38,6 +38,10 @@ 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 | +| `--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 | ### `agents` diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 36e5310..8fc1c55 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -102,7 +102,7 @@ Issue type collections and presets | Field | Type | Default | Description | | --- | --- | --- | --- | -| `preset` | → `CollectionPreset` | - | Named preset for issue type collection Options: simple, dev_kanban, devops_kanban, custom | +| `preset` | → `CollectionPreset` | - | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | | `collection` | `array`[`string`] | - | Custom issuetype collection (only used when preset = custom) List of issue type keys: TASK, FEAT, FIX, SPIKE, INV | | `active_collection` | `string` \| `null` | - | Active collection name (overrides preset if set) Can be a builtin preset name or a user-defined collection | @@ -140,12 +140,13 @@ Backstage server integration | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | true | Whether Backstage integration is enabled | +| `display` | `boolean` | - | Whether to show Backstage in the Connections status section | | `port` | `integer` | 7007 | Port for the Backstage server | | `auto_start` | `boolean` | false | Auto-start Backstage server when TUI launches | -| `subpath` | `string` | backstage | Subdirectory within state_path for Backstage installation | +| `subpath` | `string` | backstage | Subdirectory within `state_path` for Backstage installation | | `branding_subpath` | `string` | branding | Subdirectory within backstage path for branding customization | | `release_url` | `string` | - | Base URL for downloading backstage-server binary | -| `local_binary_path` | `string` \| `null` | - | Optional local path to backstage-server binary If set, this is used instead of downloading from release_url | +| `local_binary_path` | `string` \| `null` | - | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` | | `branding` | → `BrandingConfig` | - | Branding and theming configuration | ## `[llm_tools]` @@ -157,13 +158,16 @@ LLM CLI tool detection and providers | `detected` | `array`[→ `DetectedTool`] | - | Detected CLI tools (populated on first startup) | | `providers` | `array`[→ `LlmProvider`] | - | Available {tool, model} pairs for launching tickets Built from detected tools + their model aliases | | `detection_complete` | `boolean` | - | Whether detection has been completed | -| `skill_directory_overrides` | `object` | - | Per-tool overrides for skill directories (keyed by tool_name) | +| `default_tool` | `string` \| `null` | - | User's preferred default LLM tool (e.g., "claude") | +| `default_model` | `string` \| `null` | - | User's preferred default model alias (e.g., "opus") | +| `skill_directory_overrides` | `object` | - | Per-tool overrides for skill directories (keyed by `tool_name`) | ## Example Configuration ```toml projects = [] delegators = [] +model_servers = [] [agents] max_parallel = 5 @@ -212,9 +216,9 @@ completed_history_hours = 24 summary_max_length = 40 [ui.panel_names] +status = "STATUS" queue = "TODO QUEUE" -agents = "DOING" -awaiting = "AWAITING" +in_progress = "IN PROGRESS" completed = "DONE" [launch] @@ -264,6 +268,9 @@ binary_path = "/Applications/cmux.app/Contents/Resources/bin/cmux" require_in_cmux = true placement = "auto" +[sessions.zellij] +require_in_zellij = true + [llm_tools] detected = [] providers = [] @@ -273,6 +280,7 @@ detection_complete = false [backstage] enabled = true +display = false port = 7007 auto_start = false subpath = "backstage" @@ -312,6 +320,8 @@ token_env = "" [kanban.linear] +[kanban.github] + [version_check] enabled = true url = "https://operator.untra.io/VERSION" diff --git a/docs/getting-started/kanban/jira-api.md b/docs/getting-started/kanban/jira-api.md index d0a3e0d..8ae764b 100644 --- a/docs/getting-started/kanban/jira-api.md +++ b/docs/getting-started/kanban/jira-api.md @@ -75,6 +75,7 @@ Reference to an issue type | Property | Type | Description | | --- | --- | --- | +| `id` | `string` (optional) | Issue type ID (e.g., "10001") | | `name` | `string` | Issue type name (e.g., "Bug", "Story", "Task") | ### JiraPriority diff --git a/docs/getting-started/model-servers/index.md b/docs/getting-started/model-servers/index.md new file mode 100644 index 0000000..7ff3678 --- /dev/null +++ b/docs/getting-started/model-servers/index.md @@ -0,0 +1,126 @@ +--- +layout: default +title: Model Servers +parent: Getting Started +nav_order: 5 +has_children: false +--- + +# Model Servers + +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 local/alt host like ollama, lmstudio, or vllm. + +A delegator pairs an LLM tool with a model (and, optionally, a model server). + +## The three-layer hierarchy + +``` +┌─ llm_tools ─────────┐ ┌─ model_servers ──────┐ +│ claude (detected) │ │ anthropic-api (impl.)│ +│ codex (detected) │ │ openai-api (impl.)│ +│ gemini (detected) │ │ google-api (impl.)│ +│ │ │ ollama-local (user) │ +└─────────────────────┘ └──────────────────────┘ + ▲ ▲ + │ │ + └───── delegators ───────┘ + name, llm_tool, model, model_server (optional) +``` + +## Implicit builtins + +You don't need to declare a model server for the vendor-default path. Every detected LLM tool has an implicit builtin: + +| llm_tool | implicit model_server | +|----------|------------------------| +| `claude` | `anthropic-api` | +| `codex` | `openai-api` | +| `gemini` | `google-api` | + +Delegators that omit `model_server` resolve to these builtins automatically. Existing configs keep working unchanged. + +## Kinds + +| `kind` | Use for | +|-----------------|--------------------------------------------------------------------------| +| `anthropic-api` | Anthropic Console / a compatible proxy (bridge for local models) | +| `openai-api` | OpenAI / a compatible proxy | +| `google-api` | Google Gemini API | +| `ollama` | Local ollama server (`ollama serve`, default `http://localhost:11434`) | +| `openai-compat` | Any OpenAI-API-compatible server (vllm, lmstudio, together.ai, groq, …) | +| `lmstudio` | LM Studio's local server | + +## Declaring a model server + +Edit `operator.toml` (or create a delegator via the REST API / VS Code status tree): + +```toml +[[model_servers]] +name = "ollama-local" +kind = "ollama" +base_url = "http://localhost:11434" +display_name = "Ollama (local)" +``` + +Then reference it from a delegator: + +```toml +[[delegators]] +name = "codex-local-qwen" +llm_tool = "codex" +model = "qwen2.5-coder" +model_server = "ollama-local" +``` + +## Ad-hoc CLI usage + +```bash +# Named delegator (recommended for repeatable runs) +operator launch --delegator codex-local-qwen + +# Ad-hoc overrides (for one-off experiments) +operator launch \ + --llm-tool codex \ + --model qwen2.5-coder \ + --model-server ollama-local +``` + +`--delegator` and the ad-hoc trio (`--llm-tool`, `--model`, `--model-server`) are mutually exclusive. + +## Protocol compatibility + +| llm_tool | ollama-compatible? | Notes | +|----------|--------------------|----------------------------------------------------------------------------------------| +| `codex` | Yes, directly | Codex speaks OpenAI API; ollama exposes `/v1` out of the box. | +| `claude` | Only via bridge | Claude CLI speaks Anthropic protocol. Run `claude-code-router` (or similar) at a port and point `base_url` at that bridge with `kind = "anthropic-api"`. | +| `gemini` | Only via bridge | Same story as claude; use `litellm-proxy` or similar. | + +## REST API + +``` +GET /api/v1/model-servers # list (declared + implicit builtins) +GET /api/v1/model-servers/{name} # fetch by name +POST /api/v1/model-servers # create +DELETE /api/v1/model-servers/{name} # delete (implicit builtins are protected) +``` + +## What ships in this release + +This release lays down the infrastructure: + +- Data model and config schema +- REST CRUD endpoints +- TUI and VS Code status tree sections +- `operator launch --model-server ` flag (validated, resolved through the normal delegator path) + +**What's explicitly deferred:** + +- Automatic ollama detection during `operator setup` +- Environment-variable injection on spawn (`OPENAI_BASE_URL=…` etc.) +- Full walkthroughs for wiring up claude/gemini via a bridge +- Bundled bridge binaries + +Those ship in the next release. In the meantime: declare your model server, attach it to a delegator, and set the appropriate `*_BASE_URL` env var in your shell before invoking operator — the spawned agent inherits it. diff --git a/docs/schemas/config.json b/docs/schemas/config.json index ac3fdba..2da1698 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -74,6 +74,9 @@ "binary_path": "/Applications/cmux.app/Contents/Resources/bin/cmux", "require_in_cmux": true, "placement": "auto" + }, + "zellij": { + "require_in_zellij": true } } }, @@ -83,6 +86,8 @@ "detected": [], "providers": [], "detection_complete": false, + "default_tool": null, + "default_model": null, "skill_directory_overrides": {} } }, @@ -90,6 +95,7 @@ "$ref": "#/$defs/BackstageConfig", "default": { "enabled": true, + "display": false, "port": 7007, "auto_start": false, "subpath": "backstage", @@ -140,7 +146,8 @@ "$ref": "#/$defs/KanbanConfig", "default": { "jira": {}, - "linear": {} + "linear": {}, + "github": {} } }, "version_check": { @@ -159,6 +166,14 @@ "$ref": "#/$defs/Delegator" }, "default": [] + }, + "model_servers": { + "description": "User-declared model servers (ollama, lmstudio, any OpenAI-compat host).\nImplicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration.", + "type": "array", + "items": { + "$ref": "#/$defs/ModelServer" + }, + "default": [] } }, "required": [ @@ -281,7 +296,7 @@ "default": false }, "events": { - "description": "Events to send (empty = all events)\nPossible values: agent.started, agent.completed, agent.failed,\nagent.awaiting_input, agent.session_lost, pr.created, pr.merged,\npr.closed, pr.ready_to_merge, pr.changes_requested,\nticket.returned, investigation.created", + "description": "Events to send (empty = all events)\nPossible values: agent.started, agent.completed, agent.failed,\n`agent.awaiting_input`, `agent.session_lost`, pr.created, pr.merged,\npr.closed, `pr.ready_to_merge`, `pr.changes_requested`,\nticket.returned, investigation.created", "type": "array", "items": { "type": "string" @@ -426,9 +441,9 @@ "panel_names": { "$ref": "#/$defs/PanelNamesConfig", "default": { + "status": "STATUS", "queue": "TODO QUEUE", - "agents": "DOING", - "awaiting": "AWAITING", + "in_progress": "IN PROGRESS", "completed": "DONE" } } @@ -442,17 +457,17 @@ "PanelNamesConfig": { "type": "object", "properties": { - "queue": { + "status": { "type": "string", - "default": "TODO QUEUE" + "default": "STATUS" }, - "agents": { + "queue": { "type": "string", - "default": "DOING" + "default": "TODO QUEUE" }, - "awaiting": { + "in_progress": { "type": "string", - "default": "AWAITING" + "default": "IN PROGRESS" }, "completed": { "type": "string", @@ -551,7 +566,7 @@ "type": "object", "properties": { "preset": { - "description": "Named preset for issue type collection\nOptions: simple, dev_kanban, devops_kanban, custom", + "description": "Named preset for issue type collection\nOptions: simple, `dev_kanban`, `devops_kanban`, custom", "$ref": "#/$defs/CollectionPreset", "default": "dev_kanban" }, @@ -651,7 +666,7 @@ } }, "SessionsConfig": { - "description": "Session wrapper configuration\n\nControls how operator creates and manages terminal sessions for agents.\nThree modes are supported:\n- tmux: Standalone tmux sessions (default)\n- vscode: VS Code integrated terminal (requires extension)\n- cmux: macOS terminal multiplexer (requires running inside cmux)", + "description": "Session wrapper configuration\n\nControls how operator creates and manages terminal sessions for agents.\nFour modes are supported:\n- tmux: Standalone tmux sessions (default)\n- vscode: VS Code integrated terminal (requires extension)\n- cmux: macOS terminal multiplexer (requires running inside cmux)\n- zellij: Zellij terminal workspace manager", "type": "object", "properties": { "wrapper": { @@ -683,6 +698,13 @@ "require_in_cmux": true, "placement": "auto" } + }, + "zellij": { + "description": "Zellij-specific configuration", + "$ref": "#/$defs/SessionsZellijConfig", + "default": { + "require_in_zellij": true + } } } }, @@ -703,6 +725,11 @@ "description": "cmux macOS terminal multiplexer", "type": "string", "const": "cmux" + }, + { + "description": "Zellij terminal workspace manager", + "type": "string", + "const": "zellij" } ] }, @@ -753,7 +780,7 @@ "default": "/Applications/cmux.app/Contents/Resources/bin/cmux" }, "require_in_cmux": { - "description": "Require running inside cmux (CMUX_WORKSPACE_ID env var present)", + "description": "Require running inside cmux (`CMUX_WORKSPACE_ID` env var present)", "type": "boolean", "default": true }, @@ -784,6 +811,17 @@ } ] }, + "SessionsZellijConfig": { + "description": "Zellij terminal workspace manager session configuration", + "type": "object", + "properties": { + "require_in_zellij": { + "description": "Require running inside Zellij (ZELLIJ env var present)", + "type": "boolean", + "default": true + } + } + }, "LlmToolsConfig": { "description": "LLM CLI tools configuration", "type": "object", @@ -809,8 +847,24 @@ "type": "boolean", "default": false }, + "default_tool": { + "description": "User's preferred default LLM tool (e.g., \"claude\")", + "type": [ + "string", + "null" + ], + "default": null + }, + "default_model": { + "description": "User's preferred default model alias (e.g., \"opus\")", + "type": [ + "string", + "null" + ], + "default": null + }, "skill_directory_overrides": { - "description": "Per-tool overrides for skill directories (keyed by tool_name)", + "description": "Per-tool overrides for skill directories (keyed by `tool_name`)", "type": "object", "additionalProperties": { "$ref": "#/$defs/SkillDirectoriesOverride" @@ -853,10 +907,11 @@ "type": "array", "items": { "type": "string" - } + }, + "default": [] }, "command_template": { - "description": "Command template with {{model}}, {{session_id}}, {{prompt_file}} placeholders", + "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders", "type": "string", "default": "" }, @@ -880,8 +935,7 @@ "required": [ "name", "path", - "version", - "model_aliases" + "version" ] }, "ToolCapabilities": { @@ -999,6 +1053,11 @@ "type": "boolean", "default": true }, + "display": { + "description": "Whether to show Backstage in the Connections status section", + "type": "boolean", + "default": false + }, "port": { "description": "Port for the Backstage server", "type": "integer", @@ -1013,7 +1072,7 @@ "default": false }, "subpath": { - "description": "Subdirectory within state_path for Backstage installation", + "description": "Subdirectory within `state_path` for Backstage installation", "type": "string", "default": "backstage" }, @@ -1028,7 +1087,7 @@ "default": "https://github.com/untra/operator/releases/latest/download" }, "local_binary_path": { - "description": "Optional local path to backstage-server binary\nIf set, this is used instead of downloading from release_url", + "description": "Optional local path to backstage-server binary\nIf set, this is used instead of downloading from `release_url`", "type": [ "string", "null" @@ -1226,7 +1285,7 @@ "default": true }, "token_env": { - "description": "Environment variable containing the GitHub token (default: GITHUB_TOKEN)", + "description": "Environment variable containing the GitHub token (default: `GITHUB_TOKEN`)", "type": "string", "default": "GITHUB_TOKEN" } @@ -1242,7 +1301,7 @@ "default": false }, "token_env": { - "description": "Environment variable containing the GitLab token (default: GITLAB_TOKEN)", + "description": "Environment variable containing the GitLab token (default: `GITLAB_TOKEN`)", "type": "string", "default": "GITLAB_TOKEN" }, @@ -1257,7 +1316,7 @@ } }, "KanbanConfig": { - "description": "Kanban provider configuration for syncing issues from external systems\n\nProviders are keyed by domain/workspace:\n- Jira: keyed by domain (e.g., \"foobar.atlassian.net\")\n- Linear: keyed by workspace slug (e.g., \"myworkspace\")", + "description": "Kanban provider configuration for syncing issues from external systems\n\nProviders are keyed by domain/workspace:\n- Jira: keyed by domain (e.g., \"foobar.atlassian.net\")\n- Linear: keyed by workspace slug (e.g., \"myworkspace\")\n- GitHub Projects: keyed by owner login (e.g., \"my-org\")", "type": "object", "properties": { "jira": { @@ -1275,11 +1334,19 @@ "$ref": "#/$defs/LinearConfig" }, "default": {} + }, + "github": { + "description": "GitHub Projects v2 instances keyed by owner login (user or org)\n\nNOTE: This is the *kanban* GitHub integration (Projects v2), distinct\nfrom `GitHubConfig` which is the *git provider* used for PRs and\nbranches. The two use different env vars and different scopes — see\n`docs/getting-started/kanban/github.md` for the full disambiguation.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/GithubProjectsConfig" + }, + "default": {} } } }, "JiraConfig": { - "description": "Jira Cloud provider configuration\n\nThe domain is specified as the HashMap key in KanbanConfig.jira", + "description": "Jira Cloud provider configuration\n\nThe domain is specified as the `HashMap` key in KanbanConfig.jira", "type": "object", "properties": { "enabled": { @@ -1288,13 +1355,14 @@ "default": false }, "api_key_env": { - "description": "Environment variable name containing the API key (default: OPERATOR_JIRA_API_KEY)", + "description": "Environment variable name containing the API key (default: `OPERATOR_JIRA_API_KEY`)", "type": "string", "default": "OPERATOR_JIRA_API_KEY" }, "email": { "description": "Atlassian account email for authentication", - "type": "string" + "type": "string", + "default": "" }, "projects": { "description": "Per-project sync configuration", @@ -1304,17 +1372,14 @@ }, "default": {} } - }, - "required": [ - "email" - ] + } }, "ProjectSyncConfig": { "description": "Per-project/team sync configuration for a kanban provider", "type": "object", "properties": { "sync_user_id": { - "description": "User ID to sync issues for (provider-specific format)\n- Jira: accountId (e.g., \"5e3f7acd9876543210abcdef\")\n- Linear: user ID (e.g., \"abc12345-6789-0abc-def0-123456789abc\")", + "description": "User ID to sync issues for (provider-specific format)\n- Jira: accountId (e.g., \"5e3f7acd9876543210abcdef\")\n- Linear: user ID (e.g., \"abc12345-6789-0abc-def0-123456789abc\")\n- GitHub Projects: numeric GitHub `databaseId` (e.g., \"12345678\")", "type": "string", "default": "" }, @@ -1327,12 +1392,14 @@ "default": [] }, "collection_name": { - "description": "IssueTypeCollection name this project maps to", - "type": "string", - "default": "" + "description": "Optional `IssueTypeCollection` name this project maps to.\nNot required for kanban onboarding or sync.", + "type": [ + "string", + "null" + ] }, "type_mappings": { - "description": "Optional explicit mapping overrides: external issue type name → operator issue type key\nWhen empty, convention-based auto-matching is used (Bug→FIX, Story→FEAT, etc.)", + "description": "Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX).\nMultiple kanban types can map to the same operator template.", "type": "object", "additionalProperties": { "type": "string" @@ -1342,7 +1409,7 @@ } }, "LinearConfig": { - "description": "Linear provider configuration\n\nThe workspace slug is specified as the HashMap key in KanbanConfig.linear", + "description": "Linear provider configuration\n\nThe workspace slug is specified as the `HashMap` key in KanbanConfig.linear", "type": "object", "properties": { "enabled": { @@ -1351,7 +1418,7 @@ "default": false }, "api_key_env": { - "description": "Environment variable name containing the API key (default: OPERATOR_LINEAR_API_KEY)", + "description": "Environment variable name containing the API key (default: `OPERATOR_LINEAR_API_KEY`)", "type": "string", "default": "OPERATOR_LINEAR_API_KEY" }, @@ -1365,6 +1432,30 @@ } } }, + "GithubProjectsConfig": { + "description": "GitHub Projects v2 (kanban) provider configuration\n\nThe owner login (user or org) is specified as the `HashMap` key in\n`KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node\nIDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly\nby every GitHub Projects v2 mutation without needing a lookup.\n\n**Distinct from `GitHubConfig`** (the git provider used for PR/branch\noperations). They live in different parts of the config tree, use\ndifferent env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and\nrequire different OAuth scopes (`project` vs `repo`). See\n`docs/getting-started/kanban/github.md` for the full rationale.", + "type": "object", + "properties": { + "enabled": { + "description": "Whether this provider is enabled", + "type": "boolean", + "default": false + }, + "api_key_env": { + "description": "Environment variable name containing the GitHub token (default:\n`OPERATOR_GITHUB_TOKEN`). The token must have `project` (or\n`read:project`) scope, NOT just `repo` — see the disambiguation\nguide in the kanban github docs.", + "type": "string", + "default": "OPERATOR_GITHUB_TOKEN" + }, + "projects": { + "description": "Per-project sync configuration. Keys are `GraphQL` project node IDs.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ProjectSyncConfig" + }, + "default": {} + } + } + }, "VersionCheckConfig": { "description": "Version check configuration for automatic update notifications", "type": "object", @@ -1416,7 +1507,7 @@ "default": null }, "model_properties": { - "description": "Arbitrary model properties (e.g., reasoning_effort, sandbox)", + "description": "Arbitrary model properties (e.g., `reasoning_effort`, sandbox)", "type": "object", "additionalProperties": { "type": "string" @@ -1434,6 +1525,14 @@ } ], "default": null + }, + "model_server": { + "description": "Name of a declared `ModelServer` (from `Config.model_servers`).\n`None` means use the `llm_tool`'s implicit vendor default\n(claude → anthropic-api, codex → openai-api, gemini → google-api).", + "type": [ + "string", + "null" + ], + "default": null } }, "required": [ @@ -1443,7 +1542,7 @@ ] }, "DelegatorLaunchConfig": { - "description": "Launch configuration for a delegator", + "description": "Launch configuration for a delegator\n\nControls how the delegator launches agents. Optional fields use tri-state\nsemantics: `None` = inherit from global config, `Some(true/false)` = override.", "type": "object", "properties": { "yolo": { @@ -1466,8 +1565,98 @@ "type": "string" }, "default": [] + }, + "use_worktrees": { + "description": "Override global `git.use_worktrees` per-delegator (None = use global setting)", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "create_branch": { + "description": "Whether to create a git branch for the ticket (None = default behavior)", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "docker": { + "description": "Run in docker container (None = use global `launch.docker.enabled`)", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "prompt_prefix": { + "description": "Prompt text to prepend before the generated step prompt", + "type": [ + "string", + "null" + ], + "default": null + }, + "prompt_suffix": { + "description": "Prompt text to append after the generated step prompt", + "type": [ + "string", + "null" + ], + "default": null } } + }, + "ModelServer": { + "description": "A named host that serves models via an inference API.\n\nModel servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI\n(`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint\n(`model_server`, e.g. ollama-local, openai-api, a custom vllm host).\n\nImplicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are\nreturned by [`implicit_model_server_for_tool`] and do not need to be declared\nin config.", + "type": "object", + "properties": { + "name": { + "description": "Unique name (e.g., \"ollama-local\", \"vllm-gpu1\")", + "type": "string" + }, + "kind": { + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"", + "type": "string" + }, + "base_url": { + "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`).\n`None` for implicit vendor servers means use the SDK default.", + "type": [ + "string", + "null" + ], + "default": null + }, + "api_key_env": { + "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)", + "type": [ + "string", + "null" + ], + "default": null + }, + "extra_env": { + "description": "Additional environment variables set when spawning agents that use this server", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "display_name": { + "description": "Optional display name for UI", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "name", + "kind" + ] } } } \ No newline at end of file diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 1baa116..7ff5b7f 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -48,6 +48,7 @@ JSON Schema for the Operator configuration file (`config.toml`). | `kanban` | → `KanbanConfig` | No | Kanban provider configuration for syncing issues from Jira, Linear, etc. | | `version_check` | → `VersionCheckConfig` | No | Version check configuration for automatic update notifications | | `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. | ## Type Definitions @@ -82,7 +83,7 @@ OS notification configuration. | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether OS notifications are enabled | | `sound` | `boolean` | No | Play sound with notifications | -| `events` | `array` | No | Events to send (empty = all events) Possible values: agent.started, agent.completed, agent.failed, agent.awaiting_input, agent.session_lost, pr.created, pr.merged, pr.closed, pr.ready_to_merge, pr.changes_requested, ticket.returned, investigation.created | +| `events` | `array` | No | Events to send (empty = all events) Possible values: agent.started, agent.completed, agent.failed, `agent.awaiting_input`, `agent.session_lost`, pr.created, pr.merged, pr.closed, `pr.ready_to_merge`, `pr.changes_requested`, ticket.returned, investigation.created | ### WebhookConfig @@ -129,9 +130,9 @@ Webhook notification configuration. | Property | Type | Required | Description | | --- | --- | --- | --- | +| `status` | `string` | No | | | `queue` | `string` | No | | -| `agents` | `string` | No | | -| `awaiting` | `string` | No | | +| `in_progress` | `string` | No | | | `completed` | `string` | No | | ### LaunchConfig @@ -168,7 +169,7 @@ YOLO (auto-accept) mode configuration for fully autonomous execution | Property | Type | Required | Description | | --- | --- | --- | --- | -| `preset` | → `CollectionPreset` | No | Named preset for issue type collection Options: simple, dev_kanban, devops_kanban, custom | +| `preset` | → `CollectionPreset` | No | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | | `collection` | `array` | No | Custom issuetype collection (only used when preset = custom) List of issue type keys: TASK, FEAT, FIX, SPIKE, INV | | `active_collection` | `string` \| `null` | No | Active collection name (overrides preset if set) Can be a builtin preset name or a user-defined collection | @@ -213,10 +214,11 @@ Logging configuration Session wrapper configuration Controls how operator creates and manages terminal sessions for agents. -Three modes are supported: +Four modes are supported: - tmux: Standalone tmux sessions (default) - vscode: VS Code integrated terminal (requires extension) - cmux: macOS terminal multiplexer (requires running inside cmux) +- zellij: Zellij terminal workspace manager | Property | Type | Required | Description | | --- | --- | --- | --- | @@ -224,6 +226,7 @@ Three modes are supported: | `tmux` | → `SessionsTmuxConfig` | No | Tmux-specific configuration | | `vscode` | → `SessionsVSCodeConfig` | No | VS Code-specific configuration | | `cmux` | → `SessionsCmuxConfig` | No | cmux-specific configuration | +| `zellij` | → `SessionsZellijConfig` | No | Zellij-specific configuration | ### SessionWrapperType @@ -234,6 +237,7 @@ Session wrapper type for terminal session management - `tmux` - Standalone tmux sessions (default) - `vscode` - VS Code integrated terminal (via extension webhook) - `cmux` - cmux macOS terminal multiplexer +- `zellij` - Zellij terminal workspace manager ### SessionsTmuxConfig @@ -260,7 +264,7 @@ cmux macOS terminal multiplexer session configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `binary_path` | `string` | No | Path to the cmux binary | -| `require_in_cmux` | `boolean` | No | Require running inside cmux (CMUX_WORKSPACE_ID env var present) | +| `require_in_cmux` | `boolean` | No | Require running inside cmux (`CMUX_WORKSPACE_ID` env var present) | | `placement` | → `CmuxPlacementPolicy` | No | Where to place new agent sessions: "auto", "workspace", or "window" | ### CmuxPlacementPolicy @@ -273,6 +277,14 @@ Placement policy for cmux sessions: where to create new agent terminals - `workspace` - Always create a new workspace in the active window - `window` - Always create a new window for each ticket +### SessionsZellijConfig + +Zellij terminal workspace manager session configuration + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `require_in_zellij` | `boolean` | No | Require running inside Zellij (ZELLIJ env var present) | + ### LlmToolsConfig LLM CLI tools configuration @@ -282,7 +294,9 @@ LLM CLI tools configuration | `detected` | `array` | No | Detected CLI tools (populated on first startup) | | `providers` | `array` | No | Available {tool, model} pairs for launching tickets Built from detected tools + their model aliases | | `detection_complete` | `boolean` | No | Whether detection has been completed | -| `skill_directory_overrides` | `object` | No | Per-tool overrides for skill directories (keyed by tool_name) | +| `default_tool` | `string` \| `null` | No | User's preferred default LLM tool (e.g., "claude") | +| `default_model` | `string` \| `null` | No | User's preferred default model alias (e.g., "opus") | +| `skill_directory_overrides` | `object` | No | Per-tool overrides for skill directories (keyed by `tool_name`) | ### DetectedTool @@ -295,8 +309,8 @@ A detected CLI tool (e.g., claude binary) | `version` | `string` | Yes | Version string | | `min_version` | `string` \| `null` | No | Minimum required version for Operator compatibility | | `version_ok` | `boolean` | No | Whether the installed version meets the minimum requirement | -| `model_aliases` | `array` | Yes | Available model aliases (e.g., ["opus", "sonnet", "haiku"]) | -| `command_template` | `string` | No | Command template with {{model}}, {{session_id}}, {{prompt_file}} placeholders | +| `model_aliases` | `array` | No | Available model aliases (e.g., ["opus", "sonnet", "haiku"]) | +| `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 | @@ -342,12 +356,13 @@ Backstage integration configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether Backstage integration is enabled | +| `display` | `boolean` | No | Whether to show Backstage in the Connections status section | | `port` | `integer` | No | Port for the Backstage server | | `auto_start` | `boolean` | No | Auto-start Backstage server when TUI launches | -| `subpath` | `string` | No | Subdirectory within state_path for Backstage installation | +| `subpath` | `string` | No | Subdirectory within `state_path` for Backstage installation | | `branding_subpath` | `string` | No | Subdirectory within backstage path for branding customization | | `release_url` | `string` | No | Base URL for downloading backstage-server binary | -| `local_binary_path` | `string` \| `null` | No | Optional local path to backstage-server binary If set, this is used instead of downloading from release_url | +| `local_binary_path` | `string` \| `null` | No | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` | | `branding` | → `BrandingConfig` | No | Branding and theming configuration | ### BrandingConfig @@ -414,7 +429,7 @@ GitHub-specific configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether GitHub integration is enabled | -| `token_env` | `string` | No | Environment variable containing the GitHub token (default: GITHUB_TOKEN) | +| `token_env` | `string` | No | Environment variable containing the GitHub token (default: `GITHUB_TOKEN`) | ### GitLabConfig @@ -423,7 +438,7 @@ GitLab-specific configuration (planned) | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether GitLab integration is enabled | -| `token_env` | `string` | No | Environment variable containing the GitLab token (default: GITLAB_TOKEN) | +| `token_env` | `string` | No | Environment variable containing the GitLab token (default: `GITLAB_TOKEN`) | | `host` | `string` \| `null` | No | GitLab host (default: gitlab.com, can be self-hosted) | ### KanbanConfig @@ -433,23 +448,25 @@ Kanban provider configuration for syncing issues from external systems Providers are keyed by domain/workspace: - Jira: keyed by domain (e.g., "foobar.atlassian.net") - Linear: keyed by workspace slug (e.g., "myworkspace") +- GitHub Projects: keyed by owner login (e.g., "my-org") | Property | Type | Required | Description | | --- | --- | --- | --- | | `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. | ### JiraConfig Jira Cloud provider configuration -The domain is specified as the HashMap key in KanbanConfig.jira +The domain is specified as the `HashMap` key in KanbanConfig.jira | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether this provider is enabled | -| `api_key_env` | `string` | No | Environment variable name containing the API key (default: OPERATOR_JIRA_API_KEY) | -| `email` | `string` | Yes | Atlassian account email for authentication | +| `api_key_env` | `string` | No | Environment variable name containing the API key (default: `OPERATOR_JIRA_API_KEY`) | +| `email` | `string` | No | Atlassian account email for authentication | | `projects` | `object` | No | Per-project sync configuration | ### ProjectSyncConfig @@ -458,23 +475,44 @@ Per-project/team sync configuration for a kanban provider | Property | Type | Required | Description | | --- | --- | --- | --- | -| `sync_user_id` | `string` | No | User ID to sync issues for (provider-specific format) - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") | +| `sync_user_id` | `string` | No | User ID to sync issues for (provider-specific format) - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") | | `sync_statuses` | `array` | No | Workflow statuses to sync (empty = default/first status only) | -| `collection_name` | `string` | No | IssueTypeCollection name this project maps to | -| `type_mappings` | `object` | No | Optional explicit mapping overrides: external issue type name → operator issue type key When empty, convention-based auto-matching is used (Bug→FIX, Story→FEAT, etc.) | +| `collection_name` | `string` \| `null` | No | Optional `IssueTypeCollection` name this project maps to. Not required for kanban onboarding or sync. | +| `type_mappings` | `object` | No | Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). Multiple kanban types can map to the same operator template. | ### LinearConfig Linear provider configuration -The workspace slug is specified as the HashMap key in KanbanConfig.linear +The workspace slug is specified as the `HashMap` key in KanbanConfig.linear | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether this provider is enabled | -| `api_key_env` | `string` | No | Environment variable name containing the API key (default: OPERATOR_LINEAR_API_KEY) | +| `api_key_env` | `string` | No | Environment variable name containing the API key (default: `OPERATOR_LINEAR_API_KEY`) | | `projects` | `object` | No | Per-team sync configuration | +### GithubProjectsConfig + +GitHub Projects v2 (kanban) provider configuration + +The owner login (user or org) is specified as the `HashMap` key in +`KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node +IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly +by every GitHub Projects v2 mutation without needing a lookup. + +**Distinct from `GitHubConfig`** (the git provider used for PR/branch +operations). They live in different parts of the config tree, use +different env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and +require different OAuth scopes (`project` vs `repo`). See +`docs/getting-started/kanban/github.md` for the full rationale. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | No | Whether this provider is enabled | +| `api_key_env` | `string` | No | Environment variable name containing the GitHub token (default: `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or `read:project`) scope, NOT just `repo` — see the disambiguation guide in the kanban github docs. | +| `projects` | `object` | No | Per-project sync configuration. Keys are `GraphQL` project node IDs. | + ### VersionCheckConfig Version check configuration for automatic update notifications @@ -498,16 +536,46 @@ that can be used to launch agents for tickets. | `llm_tool` | `string` | Yes | LLM tool name (must match a detected tool, e.g., "claude", "codex") | | `model` | `string` | Yes | Model alias (e.g., "opus", "sonnet", "gpt-4o") | | `display_name` | `string` \| `null` | No | Optional display name for UI | -| `model_properties` | `object` | No | Arbitrary model properties (e.g., reasoning_effort, sandbox) | +| `model_properties` | `object` | No | Arbitrary model properties (e.g., `reasoning_effort`, sandbox) | | `launch_config` | object | No | Optional launch configuration | +| `model_server` | `string` \| `null` | No | Name of a declared `ModelServer` (from `Config.model_servers`). `None` means use the `llm_tool`'s implicit vendor default (claude → anthropic-api, codex → openai-api, gemini → google-api). | ### DelegatorLaunchConfig Launch configuration for a delegator +Controls how the delegator launches agents. Optional fields use tri-state +semantics: `None` = inherit from global config, `Some(true/false)` = override. + | Property | Type | Required | Description | | --- | --- | --- | --- | | `yolo` | `boolean` | No | Run in YOLO (auto-accept) mode | | `permission_mode` | `string` \| `null` | No | Permission mode 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`) | +| `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 | + +### ModelServer + +A named host that serves models via an inference API. + +Model servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI +(`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint +(`model_server`, e.g. ollama-local, openai-api, a custom vllm host). + +Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are +returned by [`implicit_model_server_for_tool`] and do not need to be declared +in config. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | Unique name (e.g., "ollama-local", "vllm-gpu1") | +| `kind` | `string` | Yes | Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" | +| `base_url` | `string` \| `null` | No | Base URL of the inference endpoint (e.g., `http://localhost:11434`). `None` for implicit vendor servers means use the SDK default. | +| `api_key_env` | `string` \| `null` | No | Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) | +| `extra_env` | `object` | No | Additional environment variables set when spawning agents that use this server | +| `display_name` | `string` \| `null` | No | Optional display name for UI | diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md index c2c3bea..8668cc4 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -3,17 +3,17 @@ title: "Issue Type Schema" layout: doc --- - + # Issue Type Schema -Schema for validating operator issuetype template configurations +Schema definition for an issuetype template ## Schema Information -- **$schema**: `http://json-schema.org/draft-07/schema#` -- **$id**: `https://gbqr.us/operator/issuetype-template.schema.json` +- **$schema**: `https://json-schema.org/draft/2020-12/schema` +- **$id**: `N/A` ## Required Fields @@ -30,53 +30,48 @@ Schema for validating operator issuetype template configurations | Property | Type | Required | Description | | --- | --- | --- | --- | | `key` | `string` | Yes | Unique issuetype key (e.g., FEAT, FIX, SPIKE, INV, TASK) | -| `name` | `string` | Yes | Human-readable name of the issuetype, eg. bug, feature, task, chore, spike, etc. | -| `description` | `string` | Yes | Description of what this issuetype is for | -| `mode` | `string` | Yes | Whether this issuetype work runs autonomously or requires human pairing | -| `glyph` | `string` | Yes | Icon/glyph character displayed in the UI for this issuetype (e.g., '*', '#', '!', '?', '>') | -| `color` | `string` | No | Optional color for the glyph in TUI display | +| `name` | `string` | Yes | Display name of the template type | +| `description` | `string` | Yes | Brief description of when to use this template | +| `mode` | → `ExecutionMode` | Yes | Whether this issuetype runs autonomously or requires human pairing | +| `glyph` | `string` | Yes | Glyph character displayed in UI for this issuetype | +| `color` | `string` \| `null` | No | Optional color for glyph display in TUI | | `project_required` | `boolean` | No | Whether a project must be specified for this issuetype | -| `fields` | `array` | Yes | Field definitions for the ticket form | +| `fields` | `array` | Yes | Field definitions for this template | | `steps` | `array` | Yes | Lifecycle steps for completing this ticket type | -| `prompt` | `string` | No | Issue prompt to apply to work creation. | -| `agent_creation_prompt` | `string` | No | Optional prompt for generating an operator agent for this issuetype via 'claude -p' for this prompt. Should instruct Claude to output ONLY the agent system prompt. If omitted, no operator agent will be generated for this issuetype. | +| `prompt` | `string` \| `null` | No | Optional prompt for work launching (interpolated with handlebars) | +| `agent_prompt` | `string` \| `null` | No | Prompt for generating this issue type's operator agent via `claude -p` | +| `agent` | `string` \| `null` | No | Default delegator name for this issuetype (overridden by step.agent) | ### key - **Description**: Unique issuetype key (e.g., FEAT, FIX, SPIKE, INV, TASK) - **Type**: `string` -- **Pattern**: `^[A-Z]+$` -- **Examples**: `FEAT`, `FIX`, `SPIKE`, `INV`, `TASK` ### name -- **Description**: Human-readable name of the issuetype, eg. bug, feature, task, chore, spike, etc. +- **Description**: Display name of the template type - **Type**: `string` ### description -- **Description**: Description of what this issuetype is for +- **Description**: Brief description of when to use this template - **Type**: `string` ### mode -- **Description**: Whether this issuetype work runs autonomously or requires human pairing -- **Type**: `string` -- **Default**: `"paired"` -- **Allowed Values**: `autonomous`, `paired` +- **Description**: Whether this issuetype runs autonomously or requires human pairing +- **Type**: → `ExecutionMode` ### glyph -- **Description**: Icon/glyph character displayed in the UI for this issuetype (e.g., '*', '#', '!', '?', '>') +- **Description**: Glyph character displayed in UI for this issuetype - **Type**: `string` -- **Examples**: `*`, `#`, `!`, `?`, `>` ### color -- **Description**: Optional color for the glyph in TUI display -- **Type**: `string` -- **Default**: `"white"` -- **Allowed Values**: `white`, `blue`, `cyan`, `green`, `yellow`, `magenta`, `red`, `black` +- **Description**: Optional color for glyph display in TUI +- **Type**: `string` \| `null` +- **Default**: `null` ### project_required @@ -86,7 +81,7 @@ Schema for validating operator issuetype template configurations ### fields -- **Description**: Field definitions for the ticket form +- **Description**: Field definitions for this template - **Type**: `array` ### steps @@ -96,75 +91,174 @@ Schema for validating operator issuetype template configurations ### prompt -- **Description**: Issue prompt to apply to work creation. -- **Type**: `string` +- **Description**: Optional prompt for work launching (interpolated with handlebars) +- **Type**: `string` \| `null` +- **Default**: `null` -### agent_creation_prompt +### agent_prompt -- **Description**: Optional prompt for generating an operator agent for this issuetype via 'claude -p' for this prompt. Should instruct Claude to output ONLY the agent system prompt. If omitted, no operator agent will be generated for this issuetype. -- **Type**: `string` +- **Description**: Prompt for generating this issue type's operator agent via `claude -p` +- **Type**: `string` \| `null` +- **Default**: `null` + +### agent + +- **Description**: Default delegator name for this issuetype (overridden by step.agent) +- **Type**: `string` \| `null` +- **Default**: `null` ## Definitions -### Definition: field +### Definition: ExecutionMode + +Execution mode for an issuetype + +### Definition: FieldSchema + +Schema definition for a single field in a template | Property | Type | Required | Description | | --- | --- | --- | --- | -| `name` | `string` | Yes | Field identifier (lowercase with underscores) | -| `description` | `string` | Yes | Human-readable description of the field | -| `type` | `string` | Yes | Field data type | +| `name` | `string` | Yes | Field identifier (matches handlebar variable name) | +| `description` | `string` | Yes | Help text for the field | +| `type` | → `FieldType` | Yes | Type of the field | | `required` | `boolean` | No | Whether this field must be filled | -| `default` | `string` \| `boolean` \| `integer` \| `null` | No | Default value for the field. Required if field is required (except for 'id' field) | -| `min` | `integer` | No | Minimum value for integer fields | -| `max` | `integer` | No | Maximum value for integer fields | -| `auto` | `string` | No | Auto-generation strategy for this field | -| `options` | `array` | No | Available options for enum fields | -| `placeholder` | `string` | No | Placeholder text shown in empty field | -| `max_length` | `integer` | No | Maximum character length for string/text fields | -| `display_order` | `integer` | No | Order in which to display this field in the form | +| `default` | `string` \| `null` | No | Default value if any | +| `auto` | object | No | Auto-generation strategy for this field | +| `options` | `array` | No | Options for enum fields | +| `placeholder` | `string` \| `null` | No | Placeholder text shown in template | +| `max_length` | `integer` \| `null` | No | Maximum length for string fields | +| `display_order` | `integer` \| `null` | No | Display order in form (lower = first) | | `user_editable` | `boolean` | No | Whether the user can edit this field (false for auto-generated) | -### Definition: step +### Definition: FieldType + +Types of fields supported in template schemas + +### Definition: AutoGenStrategy + +Auto-generation strategies for fields + +### Definition: StepSchema + +Schema definition for a lifecycle step | Property | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | Yes | Step identifier (lowercase) | -| `display_name` | `string` | No | Human-readable step name | +| `display_name` | `string` \| `null` | No | Human-readable step name | +| `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 at this step | -| `allowed_tools` | `array` | Yes | Claude Code tools allowed in this step (e.g., 'Read', 'Write', 'Bash') | -| `review_type` | `string` | No | Type of review required: none (auto-proceed), plan (approve plan), visual (browser check), pr (GitHub PR review) | -| `visual_config` | `object` | No | Configuration for visual review (required when review_type is 'visual') | -| `on_reject` | `object` | No | What to do if step output is rejected | -| `next_step` | `string` \| `null` | No | Name of the next step (null for final step) | -| `permissions` | → `stepPermissions` | No | Provider-agnostic permissions for this step, merged additively with project settings | -| `cli_args` | → `providerCliArgs` | No | Arbitrary CLI arguments per provider | -| `permission_mode` | `string` | No | Preferred LLM permission mode for this step. Only applies to providers that support it (e.g., Claude). No-op for unsupported providers. | -| `jsonSchema` | `object` | No | Inline JSON schema for structured output. Claude-specific: sets --json-schema flag. Takes precedence over jsonSchemaFile if both are defined. | -| `jsonSchemaFile` | `string` | No | Path to a local JSON schema file for structured output, relative to the project root. Claude-specific: sets --json-schema flag. | -| `artifact_patterns` | `array` | No | File glob patterns in the worktree that signal this step is complete (e.g., '.tickets/plans/*.md') | +| `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) | +| `visual_config` | object | No | Configuration for visual review (required when `review_type` is "visual") | +| `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 | +| `agent` | `string` \| `null` | No | Optional agent (delegator) name for this step (overrides ticket's default agent) | +| `permissions` | object | No | Provider-agnostic permissions for this step | +| `cli_args` | object | No | Arbitrary CLI arguments per provider | +| `permission_mode` | → `PermissionMode` | No | Preferred LLM permission mode for this step | +| `jsonSchema` | object | No | Inline JSON schema for structured output (Claude-specific) | +| `jsonSchemaFile` | `string` \| `null` | No | Path to JSON schema file for structured output (Claude-specific) | +| `artifact_patterns` | `array` | No | File glob patterns in the worktree that signal this step is complete | +| `classifier_config` | object | No | Configuration for classifier steps (required when type=classifier) | +| `rag_config` | object | No | Configuration for RAG steps (required when type=rag) | +| `delegator_config` | object | No | Configuration for delegator steps (required when type=delegator) | +| `mcp_config` | object | No | Configuration for MCP steps (required when type=mcp) | +| `multi_model_config` | object | No | Configuration for multi-model steps (required when `type=multi_model`) | +| `multi_prompt_config` | object | No | Configuration for multi-prompt steps (required when `type=multi_prompt`) | +| `matrixed_config` | object | No | Configuration for matrixed steps (required when type=matrixed) | + +### Definition: StepTypeTag + +Discriminator tag for step types + +### Definition: StepOutput + +Types of outputs a step can produce + +### Definition: ReviewType + +Type of review required for a step + +### Definition: VisualReviewConfig + +Configuration for visual review steps + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `url` | `string` | Yes | URL to open for visual check (supports handlebars templates) | +| `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: OnReject -### Definition: stepPermissions +Action to take when a step is rejected -Provider-agnostic permissions for a step +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `goto_step` | `string` | Yes | Step name to return to on rejection | +| `prompt` | `string` | Yes | Prompt to use when restarting after rejection | + +### Definition: StepPermissions + +Complete permission set for a step (as defined in issuetype schema) + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `tools` | → `ToolPermissions` | No | Tool-level allow/deny lists | +| `directories` | → `DirectoryPermissions` | No | Directory-level allow/deny lists | +| `mcp_servers` | → `McpServerPermissions` | No | MCP server enable/disable configuration | +| `custom_flags` | → `CustomFlags` | No | Per-provider custom configuration flags | + +### Definition: ToolPermissions + +Tool-level permissions (allow/deny lists) + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `allow` | `array` | No | Tools/patterns to allow | +| `deny` | `array` | No | Tools/patterns to deny | + +### Definition: ToolPattern + +Provider-agnostic tool pattern | Property | Type | Required | Description | | --- | --- | --- | --- | -| `tools` | `object` | No | Tool-level allow/deny lists | -| `directories` | `object` | No | Directory-level allow/deny lists | -| `mcp_servers` | `object` | No | MCP server enable/disable configuration | -| `custom_flags` | `object` | No | Per-provider custom configuration flags | +| `tool` | `string` | Yes | Tool name: Read, Write, Edit, Bash, Glob, Grep, `WebFetch`, etc. | +| `pattern` | `string` \| `null` | No | Optional pattern for tool arguments (e.g., "cargo test:*" for Bash) | -### Definition: toolPattern +### Definition: DirectoryPermissions -Provider-agnostic tool permission pattern +Directory-level permissions | Property | Type | Required | Description | | --- | --- | --- | --- | -| `tool` | `string` | Yes | Tool name: Read, Write, Edit, Bash, Glob, Grep, WebFetch, etc. | -| `pattern` | `string` | No | Optional pattern for tool arguments (e.g., 'cargo test:*' for Bash) | +| `allow` | `array` | No | Additional directories to allow access to (glob patterns) | +| `deny` | `array` | No | Directories to deny access to (glob patterns) | + +### Definition: McpServerPermissions + +MCP server permissions (server-level enable/disable only) + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enable` | `array` | No | MCP servers to enable for this step | +| `disable` | `array` | No | MCP servers to disable for this step | + +### Definition: CustomFlags -### Definition: providerCliArgs +Per-provider custom configuration flags + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `claude` | `object` | No | Claude-specific configuration flags | +| `gemini` | `object` | No | Gemini-specific configuration flags | +| `codex` | `object` | No | Codex-specific configuration flags | + +### Definition: ProviderCliArgs Arbitrary CLI arguments per provider @@ -174,3 +268,118 @@ Arbitrary CLI arguments per provider | `gemini` | `array` | No | CLI arguments for Gemini | | `codex` | `array` | No | CLI arguments for Codex | +### Definition: PermissionMode + +Permission mode for LLM interaction + +### Definition: ClassifierConfig + +Configuration for classifier steps that return structured typed output + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `output_type` | → `ClassifierOutputType` | Yes | What type of answer the classifier returns | +| `options` | `array` \| `null` | No | For enum type: the allowed options | +| `max_length` | `integer` \| `null` | No | For `short_string`: max character length (default 255) | +| `agent` | `string` \| `null` | No | Agent/delegator to use (overrides issuetype default) | + +### Definition: ClassifierOutputType + +Output types for classifier steps + +### Definition: RagConfig + +Configuration for RAG (retrieval-augmented generation) steps + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `sources` | `array` | Yes | Context sources to retrieve before running the prompt | +| `max_context_tokens` | `integer` \| `null` | No | Maximum tokens of context to inject (default: 50000) | +| `agent` | `string` \| `null` | No | Agent/delegator to use | +| `allowed_tools` | `array` | No | Tools allowed for the agent | + +### Definition: RagSource + +A source of context for RAG steps + +### Definition: DelegatorStepConfig + +Configuration for delegator steps that run with a specific model+flavor + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `delegator` | `string` | Yes | Named delegator reference (from config.delegators) | +| `prompt_flavor` | `string` \| `null` | No | Additional prompt flavor text prepended to the step prompt | +| `allowed_tools` | `array` | No | Tools allowed | +| `permissions` | object | No | Permissions | + +### Definition: McpStepConfig + +Configuration for MCP steps that require specific MCP tools + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `required_tools` | `array` | Yes | MCP tools that MUST be available (step fails if missing) | +| `optional_tools` | `array` | No | MCP tools that SHOULD be available (warning if missing) | +| `agent` | `string` \| `null` | No | Agent/delegator to use | +| `allowed_tools` | `array` | No | Tools allowed (in addition to MCP tools) | + +### Definition: McpToolRef + +Reference to a specific MCP server tool + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `server` | `string` | Yes | MCP server name | +| `tool` | `string` \| `null` | No | Specific tool name (None = all tools from this server) | + +### Definition: MultiModelConfig + +Configuration for multi-model delegation steps (fan-out + vote) + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `delegators` | `array` | Yes | Named delegator references (from config.delegators), minimum 2 | +| `voting_strategy` | → `VotingStrategy` | Yes | How to aggregate/select the final answer | +| `share_answers` | `boolean` | No | Whether to share all answers with all models in the voting round | +| `voting_prompt` | `string` \| `null` | No | Prompt for the voting round (Handlebars, receives {{ answers }} array) | +| `voting_mode` | → `VotingMode` | No | How the voting round executes | + +### Definition: VotingStrategy + +Voting strategy for multi-model steps + +### Definition: VotingMode + +How the voting round is executed in multi-model steps + +### Definition: MultiPromptConfig + +Configuration for multi-prompt interrogation steps (N variations, select best) + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `prompt_variations` | `array` | Yes | Prompt variations (Handlebars templates), minimum 2 | +| `selection_strategy` | → `SelectionStrategy` | Yes | How to select the best result | +| `agent` | `string` \| `null` | No | Agent/delegator to use for all variations | +| `selection_prompt` | `string` \| `null` | No | Prompt for the selection/review round | + +### Definition: SelectionStrategy + +Selection strategy for multi-prompt steps + +### Definition: MatrixedConfig + +Configuration for matrixed work output steps (N x M delegators x prompts) + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `delegators` | `array` | Yes | Named delegator references (N), minimum 2 | +| `prompt_variations` | `array` | Yes | Prompt variations (M) — Handlebars templates, minimum 2 | +| `output_format` | → `MatrixedOutputFormat` | Yes | How to organize/present the N x M output | +| `aggregation_prompt` | `string` \| `null` | No | Optional aggregation prompt (receives the full matrix of results) | + +### Definition: MatrixedOutputFormat + +Output format for matrixed steps + diff --git a/docs/schemas/jira-api.json b/docs/schemas/jira-api.json index c4a7c4d..fb904c1 100644 --- a/docs/schemas/jira-api.json +++ b/docs/schemas/jira-api.json @@ -117,6 +117,14 @@ "description": "Reference to an issue type", "type": "object", "properties": { + "id": { + "description": "Issue type ID (e.g., \"10001\")", + "type": [ + "string", + "null" + ], + "default": null + }, "name": { "description": "Issue type name (e.g., \"Bug\", \"Story\", \"Task\")", "type": "string" diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 3ca7d9f..98ed274 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "MIT" }, - "version": "0.1.26" + "version": "0.1.30" }, "paths": { "/api/v1/collections": { @@ -205,6 +205,44 @@ } } }, + "/api/v1/delegators/from-tool": { + "post": { + "tags": [ + "Delegators" + ], + "summary": "Create a delegator from a detected LLM tool", + "description": "Pre-populates delegator fields from the detected tool, requiring minimal input.", + "operationId": "create_from_tool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDelegatorFromToolRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Delegator created from tool", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegatorResponse" + } + } + } + }, + "404": { + "description": "Tool not detected" + }, + "409": { + "description": "Delegator already exists" + } + } + } + }, "/api/v1/delegators/{name}": { "get": { "tags": [ @@ -239,6 +277,49 @@ } } }, + "put": { + "tags": [ + "Delegators" + ], + "summary": "Update an existing delegator", + "operationId": "update", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Delegator name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDelegatorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Delegator updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegatorResponse" + } + } + } + }, + "404": { + "description": "Delegator not found" + } + } + }, "delete": { "tags": [ "Delegators" @@ -697,6 +778,227 @@ } } }, + "/api/v1/llm-tools": { + "get": { + "tags": [ + "LLM Tools" + ], + "summary": "List detected LLM tools with model aliases", + "operationId": "list", + "responses": { + "200": { + "description": "List of detected LLM tools", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LlmToolsResponse" + } + } + } + } + } + } + }, + "/api/v1/llm-tools/default": { + "get": { + "tags": [ + "LLM Tools" + ], + "summary": "Get the current default LLM tool and model", + "operationId": "get_default", + "responses": { + "200": { + "description": "Current default LLM", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultLlmResponse" + } + } + } + } + } + }, + "put": { + "tags": [ + "LLM Tools" + ], + "summary": "Set the global default LLM tool and model", + "operationId": "set_default", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDefaultLlmRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default LLM set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultLlmResponse" + } + } + } + }, + "404": { + "description": "Tool not detected" + } + } + } + }, + "/api/v1/mcp/descriptor": { + "get": { + "tags": [ + "MCP" + ], + "summary": "MCP descriptor endpoint", + "description": "Returns metadata for building a VS Code MCP deep link.\nThe transport URL is derived from the request Host header\nso it reflects the actual running port.", + "operationId": "descriptor", + "responses": { + "200": { + "description": "MCP server descriptor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpDescriptorResponse" + } + } + } + } + } + } + }, + "/api/v1/model-servers": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "List all model servers (user-declared + implicit builtins)", + "operationId": "list", + "responses": { + "200": { + "description": "List of model servers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServersResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "ModelServers" + ], + "summary": "Create a new model server", + "operationId": "create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateModelServerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Model server created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "409": { + "description": "Model server already exists" + } + } + } + }, + "/api/v1/model-servers/{name}": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "Get a single model server by name", + "operationId": "get_one", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model server details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + } + } + }, + "delete": { + "tags": [ + "ModelServers" + ], + "summary": "Delete a user-declared model server by name", + "description": "Implicit builtin servers cannot be deleted.", + "operationId": "delete", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model server deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + }, + "409": { + "description": "Cannot delete implicit builtin server" + } + } + } + }, "/api/v1/skills": { "get": { "tags": [ @@ -821,6 +1123,58 @@ } } }, + "CreateDelegatorFromToolRequest": { + "type": "object", + "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.", + "required": [ + "tool_name" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ], + "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias." + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`." + }, + "tool_name": { + "type": "string", + "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")" + } + } + }, "CreateDelegatorRequest": { "type": "object", "description": "Request to create a new delegator", @@ -866,6 +1220,13 @@ "type": "string" } }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, "name": { "type": "string", "description": "Unique name for the delegator" @@ -971,6 +1332,55 @@ } } }, + "CreateModelServerRequest": { + "type": "object", + "description": "Request to create a new model server", + "required": [ + "name", + "kind" + ], + "properties": { + "api_key_env": { + "type": [ + "string", + "null" + ], + "description": "Name of an env var providing the API key" + }, + "base_url": { + "type": [ + "string", + "null" + ], + "description": "Base URL of the inference endpoint" + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "extra_env": { + "type": "object", + "description": "Additional environment variables", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + }, + "name": { + "type": "string", + "description": "Unique name for this model server" + } + } + }, "CreateStepRequest": { "type": "object", "description": "Request to create a step", @@ -1018,10 +1428,42 @@ } } }, + "DefaultLlmResponse": { + "type": "object", + "description": "Response with the current default LLM tool and model", + "required": [ + "tool", + "model" + ], + "properties": { + "model": { + "type": "string", + "description": "Default model alias (empty string if not set)" + }, + "tool": { + "type": "string", + "description": "Default tool name (empty string if not set)" + } + } + }, "DelegatorLaunchConfigDto": { "type": "object", - "description": "Launch configuration DTO for delegators", + "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.", "properties": { + "create_branch": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to create a git branch for the ticket (None = default behavior)" + }, + "docker": { + "type": [ + "boolean", + "null" + ], + "description": "Run in docker container (None = use global `launch.docker.enabled`)" + }, "flags": { "type": "array", "items": { @@ -1036,6 +1478,27 @@ ], "description": "Permission mode override" }, + "prompt_prefix": { + "type": [ + "string", + "null" + ], + "description": "Prompt text to prepend before the generated step prompt" + }, + "prompt_suffix": { + "type": [ + "string", + "null" + ], + "description": "Prompt text to append after the generated step prompt" + }, + "use_worktrees": { + "type": [ + "boolean", + "null" + ], + "description": "Override global `git.use_worktrees` (None = use global setting)" + }, "yolo": { "type": "boolean", "description": "Run in YOLO mode" @@ -1088,6 +1551,13 @@ "type": "string" } }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, "name": { "type": "string", "description": "Unique name" @@ -1116,6 +1586,62 @@ } } }, + "DetectedTool": { + "type": "object", + "description": "A detected CLI tool (e.g., claude binary)", + "required": [ + "name", + "path", + "version" + ], + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ToolCapabilities", + "description": "Tool capabilities" + }, + "command_template": { + "type": "string", + "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" + }, + "min_version": { + "type": [ + "string", + "null" + ], + "description": "Minimum required version for Operator compatibility" + }, + "model_aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])" + }, + "name": { + "type": "string", + "description": "Tool name (e.g., \"claude\")" + }, + "path": { + "type": "string", + "description": "Path to the binary" + }, + "version": { + "type": "string", + "description": "Version string" + }, + "version_ok": { + "type": "boolean", + "description": "Whether the installed version meets the minimum requirement" + }, + "yolo_flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CLI flags for YOLO (auto-accept) mode" + } + } + }, "ErrorResponse": { "type": "object", "description": "Error response body", @@ -1304,19 +1830,26 @@ "type": "object", "description": "Request to launch a ticket", "properties": { + "delegator": { + "type": [ + "string", + "null" + ], + "description": "Named delegator to use (takes precedence over provider/model)" + }, "model": { "type": [ "string", "null" ], - "description": "Model to use (e.g., \"sonnet\", \"opus\")" + "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator" }, "provider": { "type": [ "string", "null" ], - "description": "LLM provider to use (e.g., \"claude\")" + "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator" }, "resume_session_id": { "type": [ @@ -1370,21 +1903,28 @@ ], "description": "Branch name (if worktree was created)" }, - "cmux_window_ref": { + "command": { + "type": "string", + "description": "Command to execute in terminal" + }, + "session_context_ref": { "type": [ "string", "null" ], - "description": "cmux window reference ID (when using cmux wrapper)" - }, - "command": { - "type": "string", - "description": "Command to execute in terminal" + "description": "Session context reference (e.g. cmux workspace, zellij session)" }, "session_id": { "type": "string", "description": "Session UUID for the LLM tool" }, + "session_window_ref": { + "type": [ + "string", + "null" + ], + "description": "Session window reference ID (e.g. cmux window, tmux session)" + }, "session_wrapper": { "type": [ "string", @@ -1394,7 +1934,7 @@ }, "terminal_name": { "type": "string", - "description": "Terminal name to use (same value as tmux_session_name)" + "description": "Terminal name to use (same value as `tmux_session_name`)" }, "ticket_id": { "type": "string", @@ -1402,7 +1942,7 @@ }, "tmux_session_name": { "type": "string", - "description": "Tmux session name for attaching (same value as terminal_name, kept for backward compat)" + "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)" }, "working_directory": { "type": "string", @@ -1414,6 +1954,163 @@ } } }, + "LlmToolsResponse": { + "type": "object", + "description": "Response listing detected LLM tools", + "required": [ + "tools", + "total" + ], + "properties": { + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DetectedTool" + }, + "description": "Detected CLI tools with model aliases and capabilities" + }, + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 + } + } + }, + "McpDescriptorResponse": { + "type": "object", + "description": "MCP server descriptor for client discovery", + "required": [ + "server_name", + "server_id", + "version", + "transport_url", + "label" + ], + "properties": { + "label": { + "type": "string", + "description": "Human-readable label for the server" + }, + "openapi_url": { + "type": [ + "string", + "null" + ], + "description": "URL of the OpenAPI spec for reference" + }, + "server_id": { + "type": "string", + "description": "Unique server identifier (e.g. \"operator-mcp\")" + }, + "server_name": { + "type": "string", + "description": "Server name used in MCP registration (e.g. \"operator\")" + }, + "transport_url": { + "type": "string", + "description": "Full URL of the MCP SSE transport endpoint" + }, + "version": { + "type": "string", + "description": "Server version from Cargo.toml" + } + } + }, + "ModelServerResponse": { + "type": "object", + "description": "Response for a single model server", + "required": [ + "name", + "kind", + "extra_env", + "user_declared" + ], + "properties": { + "api_key_env": { + "type": [ + "string", + "null" + ], + "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)" + }, + "base_url": { + "type": [ + "string", + "null" + ], + "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)" + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "extra_env": { + "type": "object", + "description": "Additional environment variables set when spawning agents that use this server", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + }, + "name": { + "type": "string", + "description": "Unique name (e.g., \"ollama-local\")" + }, + "user_declared": { + "type": "boolean", + "description": "Whether this is a user-declared server (true) or an implicit builtin (false)" + } + } + }, + "ModelServersResponse": { + "type": "object", + "description": "Response listing all model servers (declared + implicit builtins)", + "required": [ + "servers", + "total" + ], + "properties": { + "servers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelServerResponse" + }, + "description": "List of model servers" + }, + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 + } + } + }, + "SetDefaultLlmRequest": { + "type": "object", + "description": "Request to set the global default LLM tool and model", + "required": [ + "tool", + "model" + ], + "properties": { + "model": { + "type": "string", + "description": "Model alias (e.g., \"opus\", \"sonnet\")" + }, + "tool": { + "type": "string", + "description": "Tool name (must match a detected tool, e.g., \"claude\")" + } + } + }, "SkillEntry": { "type": "object", "description": "A single discovered skill file", @@ -1545,6 +2242,20 @@ } } }, + "ToolCapabilities": { + "type": "object", + "description": "Tool capabilities", + "properties": { + "supports_headless": { + "type": "boolean", + "description": "Whether the tool can run in headless/non-interactive mode" + }, + "supports_sessions": { + "type": "boolean", + "description": "Whether the tool supports session continuity via UUID" + } + } + }, "UpdateIssueTypeRequest": { "type": "object", "description": "Request to update an issue type", @@ -1690,6 +2401,14 @@ { "name": "Delegators", "description": "Agent delegator CRUD operations" + }, + { + "name": "ModelServers", + "description": "Model server (ollama, openai-compat, etc.) CRUD operations" + }, + { + "name": "MCP", + "description": "Model Context Protocol integration" } ] } \ No newline at end of file diff --git a/docs/schemas/operator_output.json b/docs/schemas/operator_output.json new file mode 100644 index 0000000..457429a --- /dev/null +++ b/docs/schemas/operator_output.json @@ -0,0 +1,99 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OperatorOutput", + "description": "Standardized agent output for progress tracking and step transitions.\n\nAgents output a status block in their response which is parsed into this structure.\nUsed for progress tracking, loop detection, and intelligent step transitions.", + "type": "object", + "properties": { + "status": { + "description": "Current work status: `in_progress`, complete, blocked, failed", + "type": "string" + }, + "exit_signal": { + "description": "Agent signals done with step (true) or more work remains (false)", + "type": "boolean" + }, + "confidence": { + "description": "Agent's confidence in completion (0-100%)", + "type": [ + "integer", + "null" + ], + "format": "uint8", + "minimum": 0, + "maximum": 255 + }, + "files_modified": { + "description": "Number of files changed this iteration", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "tests_status": { + "description": "Test suite status: passing, failing, skipped, `not_run`", + "type": [ + "string", + "null" + ] + }, + "error_count": { + "description": "Number of errors encountered", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "tasks_completed": { + "description": "Number of sub-tasks completed this iteration", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "tasks_remaining": { + "description": "Estimated remaining sub-tasks", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "summary": { + "description": "Brief description of work done (max 500 chars)", + "type": [ + "string", + "null" + ] + }, + "recommendation": { + "description": "Suggested next action (max 200 chars)", + "type": [ + "string", + "null" + ] + }, + "blockers": { + "description": "Issues preventing progress (signals intervention needed)", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "required": [ + "status", + "exit_signal" + ], + "$id": "https://operator.dev/schemas/operator_output.schema.json", + "$comment": "AUTO-GENERATED FROM src/rest/dto.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only operator-output-schema" +} \ No newline at end of file diff --git a/docs/schemas/project_analysis.json b/docs/schemas/project_analysis.json new file mode 100644 index 0000000..fbd2ca2 --- /dev/null +++ b/docs/schemas/project_analysis.json @@ -0,0 +1,919 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProjectAnalysis", + "description": "Complete project analysis result.\n\nThis is the top-level structure that conforms to `project_analysis.schema.json`.\nClaude fills this structure during the ASSESS issuetype's analyze step.", + "type": "object", + "properties": { + "project_name": { + "description": "Project directory name", + "type": "string" + }, + "project_path": { + "description": "Absolute path to project root", + "type": "string" + }, + "analyzed_at": { + "description": "ISO 8601 timestamp of analysis", + "type": "string" + }, + "kind_assessment": { + "description": "Detected project Kind from taxonomy", + "$ref": "#/$defs/KindAssessment" + }, + "languages": { + "description": "Detected programming languages", + "type": "array", + "items": { + "$ref": "#/$defs/LanguageDetection" + } + }, + "frameworks": { + "description": "Detected frameworks and libraries", + "type": "array", + "items": { + "$ref": "#/$defs/FrameworkDetection" + } + }, + "databases": { + "description": "Detected database systems", + "type": "array", + "items": { + "$ref": "#/$defs/DatabaseDetection" + } + }, + "docker": { + "description": "Docker configuration detection", + "$ref": "#/$defs/DockerDetection" + }, + "ports": { + "description": "Detected port configurations", + "type": "array", + "items": { + "$ref": "#/$defs/PortDetection" + } + }, + "testing": { + "description": "Detected test frameworks", + "type": "array", + "items": { + "$ref": "#/$defs/TestFrameworkDetection" + } + }, + "file_stats": { + "description": "File statistics for context", + "$ref": "#/$defs/FileStats" + }, + "commands": { + "description": "Executable commands for common operations", + "$ref": "#/$defs/Commands" + }, + "entry_points": { + "description": "Key entry points into the codebase", + "type": "array", + "items": { + "$ref": "#/$defs/EntryPoint" + } + }, + "environment": { + "description": "Environment variables used by the project", + "type": "array", + "items": { + "$ref": "#/$defs/EnvVar" + } + } + }, + "required": [ + "project_name", + "project_path", + "analyzed_at", + "kind_assessment", + "languages", + "frameworks", + "databases", + "docker", + "ports", + "testing", + "file_stats", + "commands", + "entry_points", + "environment" + ], + "$defs": { + "KindAssessment": { + "description": "Kind assessment from the 25-Kind taxonomy.\n\nMaps to one of the Kinds defined in `taxonomy.toml`:\n- Foundation (1-4): infrastructure, identity-access, config-policy, monorepo-meta\n- Standards (5-10): design-system, software-library, proto-sdk, blueprint, security-tooling, compliance-audit\n- Engines (11-16): ml-model, data-etl, microservice, api-gateway, ui-frontend, internal-tool\n- Ecosystem (17-21): build-tool, e2e-test, docs-site, playbook, cli-devtool\n- Noncurrent (22-25): reference-example, experiment-sandbox, archival-fork, test-data-fixtures\n\n## Tier-Based Assessment Scoping\n\nNot all assessment types apply to all tiers:\n- **Frameworks**: Assessed for Standards, Engines, Ecosystem (not Foundation, Noncurrent)\n- **Databases**: Assessed for Engines, Ecosystem (not Foundation, Standards, Noncurrent)\n- **Testing**: Assessed for Standards, Engines, Ecosystem (not Foundation, Noncurrent)\n\nFoundation tier projects are pure infrastructure with no application-level code.\nNoncurrent tier projects are low-importance repos where detailed analysis is skipped.", + "type": "object", + "properties": { + "primary_kind": { + "description": "Primary detected Kind key (e.g., \"microservice\", \"ui-frontend\")", + "type": "string" + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "tier": { + "description": "Taxonomy tier: foundation, standards, engines, or ecosystem", + "type": "string" + }, + "matching_files": { + "description": "Files that matched Kind patterns", + "type": "array", + "items": { + "type": "string" + } + }, + "alternatives": { + "description": "Alternative Kind candidates with their scores", + "type": "array", + "items": { + "$ref": "#/$defs/KindCandidate" + } + } + }, + "required": [ + "primary_kind", + "confidence", + "tier", + "matching_files" + ] + }, + "KindCandidate": { + "description": "Alternative Kind candidate with confidence score.", + "type": "object", + "properties": { + "kind": { + "description": "Kind key", + "type": "string" + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "match_count": { + "description": "Number of file pattern matches", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "kind", + "confidence", + "match_count" + ] + }, + "LanguageDetection": { + "description": "Language detection result.\n\nSupports both known languages (rust, typescript, python, etc.) and\nunknown/emerging languages via free-form string.", + "type": "object", + "properties": { + "language": { + "description": "Language identifier (e.g., \"rust\", \"typescript\", \"python\")", + "type": "string" + }, + "display_name": { + "description": "Human-readable name (e.g., \"Rust\", \"TypeScript\", \"Python\")", + "type": "string" + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "is_primary": { + "description": "Whether this is the primary/dominant language", + "type": "boolean" + }, + "file_count": { + "description": "Number of files in this language", + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "evidence": { + "description": "Evidence supporting this detection", + "type": "array", + "items": { + "$ref": "#/$defs/Evidence" + } + } + }, + "required": [ + "language", + "display_name", + "confidence", + "is_primary", + "file_count", + "evidence" + ] + }, + "Evidence": { + "description": "Evidence supporting a detection.\n\nProvides explainability for why something was detected.", + "type": "object", + "properties": { + "evidence_type": { + "description": "Type of evidence", + "$ref": "#/$defs/EvidenceType" + }, + "file_path": { + "description": "File path relative to project root", + "type": [ + "string", + "null" + ] + }, + "pattern": { + "description": "Pattern that matched (glob or regex)", + "type": [ + "string", + "null" + ] + }, + "matched_content": { + "description": "Matched content excerpt (max ~200 chars)", + "type": [ + "string", + "null" + ] + }, + "line_number": { + "description": "Line number if applicable", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "evidence_type" + ] + }, + "EvidenceType": { + "description": "Types of evidence for detections.", + "oneOf": [ + { + "description": "File exists at expected path", + "type": "string", + "const": "file_exists" + }, + { + "description": "File name matches a pattern", + "type": "string", + "const": "file_pattern" + }, + { + "description": "Content within file matches a pattern", + "type": "string", + "const": "content_match" + }, + { + "description": "Configuration key found", + "type": "string", + "const": "config_key" + }, + { + "description": "Listed as dependency in manifest", + "type": "string", + "const": "dependency" + }, + { + "description": "Import/require statement found", + "type": "string", + "const": "import" + }, + { + "description": "File extension indicates language", + "type": "string", + "const": "extension" + } + ] + }, + "FrameworkDetection": { + "description": "Framework/library detection result.\n\nSupports both known frameworks and unknown/custom frameworks.", + "type": "object", + "properties": { + "framework": { + "description": "Framework identifier (e.g., \"axum\", \"react\", \"django\")", + "type": "string" + }, + "display_name": { + "description": "Human-readable name (e.g., \"Axum\", \"React\", \"Django\")", + "type": "string" + }, + "category": { + "description": "Framework category", + "$ref": "#/$defs/FrameworkCategory" + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "version": { + "description": "Version if detected (e.g., \"0.7.5\", \"18.2.0\")", + "type": [ + "string", + "null" + ] + }, + "evidence": { + "description": "Evidence supporting this detection", + "type": "array", + "items": { + "$ref": "#/$defs/Evidence" + } + } + }, + "required": [ + "framework", + "display_name", + "category", + "confidence", + "evidence" + ] + }, + "FrameworkCategory": { + "description": "Framework categories for classification.", + "oneOf": [ + { + "description": "Web frameworks (Axum, Express, Django, etc.)", + "type": "string", + "const": "web" + }, + { + "description": "Object-Relational Mappers (Diesel, `SQLAlchemy`, Prisma)", + "type": "string", + "const": "orm" + }, + { + "description": "Testing frameworks (Jest, Pytest)", + "type": "string", + "const": "testing" + }, + { + "description": "Build tools (Webpack, Vite, esbuild)", + "type": "string", + "const": "build" + }, + { + "description": "Logging frameworks (tracing, winston)", + "type": "string", + "const": "logging" + }, + { + "description": "Serialization libraries (serde, Jackson)", + "type": "string", + "const": "serialization" + }, + { + "description": "CLI frameworks (clap, commander)", + "type": "string", + "const": "cli" + }, + { + "description": "Async runtimes (Tokio, asyncio)", + "type": "string", + "const": "async" + }, + { + "description": "API frameworks (tonic, `GraphQL`)", + "type": "string", + "const": "api" + }, + { + "description": "UI frameworks (React, Vue, Yew)", + "type": "string", + "const": "ui" + }, + { + "description": "Other/uncategorized", + "type": "string", + "const": "other" + } + ] + }, + "DatabaseDetection": { + "description": "Database detection result.\n\nSupports both known databases and unknown/custom databases.", + "type": "object", + "properties": { + "database": { + "description": "Database identifier (e.g., \"postgres\", \"mongodb\", \"redis\")", + "type": "string" + }, + "display_name": { + "description": "Human-readable name (e.g., \"`PostgreSQL`\", \"`MongoDB`\", \"Redis\")", + "type": "string" + }, + "category": { + "description": "Database category", + "$ref": "#/$defs/DatabaseCategory" + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "port": { + "description": "Default or detected port", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0, + "maximum": 65535 + }, + "evidence": { + "description": "Evidence supporting this detection", + "type": "array", + "items": { + "$ref": "#/$defs/Evidence" + } + } + }, + "required": [ + "database", + "display_name", + "category", + "confidence", + "evidence" + ] + }, + "DatabaseCategory": { + "description": "Database categories for classification.", + "oneOf": [ + { + "description": "SQL databases (`PostgreSQL`, `MySQL`, `SQLite`)", + "type": "string", + "const": "relational" + }, + { + "description": "Document stores (`MongoDB`, `CouchDB`)", + "type": "string", + "const": "document" + }, + { + "description": "Key-value stores (Redis as KV, etcd)", + "type": "string", + "const": "key_value" + }, + { + "description": "Graph databases (Neo4j, Neptune)", + "type": "string", + "const": "graph" + }, + { + "description": "Time-series databases (`InfluxDB`, `TimescaleDB`)", + "type": "string", + "const": "time_series" + }, + { + "description": "Message queues (`RabbitMQ`, Kafka)", + "type": "string", + "const": "message_queue" + }, + { + "description": "Search engines (Elasticsearch, Meilisearch)", + "type": "string", + "const": "search" + }, + { + "description": "Caching systems (Redis as cache, Memcached)", + "type": "string", + "const": "cache" + } + ] + }, + "DockerDetection": { + "description": "Docker configuration detection.", + "type": "object", + "properties": { + "has_dockerfile": { + "description": "Whether Dockerfile exists", + "type": "boolean" + }, + "has_compose": { + "description": "Whether docker-compose.yml/yaml exists", + "type": "boolean" + }, + "base_images": { + "description": "Base images detected from Dockerfile(s)", + "type": "array", + "items": { + "$ref": "#/$defs/DockerImage" + } + }, + "compose_services": { + "description": "Service names from docker-compose", + "type": "array", + "items": { + "type": "string" + } + }, + "evidence": { + "description": "Evidence supporting this detection", + "type": "array", + "items": { + "$ref": "#/$defs/Evidence" + } + } + }, + "required": [ + "has_dockerfile", + "has_compose", + "base_images", + "compose_services", + "evidence" + ] + }, + "DockerImage": { + "description": "Docker base image information.", + "type": "object", + "properties": { + "image": { + "description": "Image name (e.g., \"rust\", \"node\", \"postgres\")", + "type": "string" + }, + "tag": { + "description": "Image tag if specified (e.g., \"1.75\", \"20-alpine\")", + "type": [ + "string", + "null" + ] + }, + "stage": { + "description": "Build stage name if multi-stage (e.g., \"builder\", \"runtime\")", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "image" + ] + }, + "PortDetection": { + "description": "Port detection result.", + "type": "object", + "properties": { + "port_type": { + "description": "Port type category", + "$ref": "#/$defs/PortType" + }, + "port_number": { + "description": "Actual port number if detected", + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0, + "maximum": 65535 + }, + "env_var": { + "description": "Environment variable name if port is configured via env", + "type": [ + "string", + "null" + ] + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "evidence": { + "description": "Evidence supporting this detection", + "type": "array", + "items": { + "$ref": "#/$defs/Evidence" + } + } + }, + "required": [ + "port_type", + "confidence", + "evidence" + ] + }, + "PortType": { + "description": "Port type categories.", + "oneOf": [ + { + "description": "HTTP server port (typically 80, 8080, 3000)", + "type": "string", + "const": "http" + }, + { + "description": "HTTPS server port (typically 443, 8443)", + "type": "string", + "const": "https" + }, + { + "description": "gRPC server port (typically 50051)", + "type": "string", + "const": "grpc" + }, + { + "description": "Database connection port (5432, 3306, 27017)", + "type": "string", + "const": "database" + }, + { + "description": "Redis port (typically 6379)", + "type": "string", + "const": "redis" + }, + { + "description": "`RabbitMQ` port (typically 5672)", + "type": "string", + "const": "rabbitmq" + }, + { + "description": "WebSocket port", + "type": "string", + "const": "websocket" + }, + { + "description": "Metrics/observability port (Prometheus 9090, etc.)", + "type": "string", + "const": "metrics" + }, + { + "description": "Debug port (Node 9229, etc.)", + "type": "string", + "const": "debug" + }, + { + "description": "Other/unknown port type", + "type": "string", + "const": "other" + } + ] + }, + "TestFrameworkDetection": { + "description": "Test framework detection result.", + "type": "object", + "properties": { + "framework": { + "description": "Framework identifier (e.g., \"`cargo_test`\", \"jest\", \"pytest\")", + "type": "string" + }, + "display_name": { + "description": "Human-readable name (e.g., \"Cargo Test\", \"Jest\", \"Pytest\")", + "type": "string" + }, + "category": { + "description": "Test category", + "$ref": "#/$defs/TestCategory" + }, + "confidence": { + "description": "Confidence score 0.0-1.0", + "type": "number", + "format": "float" + }, + "evidence": { + "description": "Evidence supporting this detection", + "type": "array", + "items": { + "$ref": "#/$defs/Evidence" + } + } + }, + "required": [ + "framework", + "display_name", + "category", + "confidence", + "evidence" + ] + }, + "TestCategory": { + "description": "Test categories for classification.", + "oneOf": [ + { + "description": "Unit tests", + "type": "string", + "const": "unit" + }, + { + "description": "Integration tests", + "type": "string", + "const": "integration" + }, + { + "description": "End-to-end tests", + "type": "string", + "const": "e2e" + }, + { + "description": "Performance/load tests", + "type": "string", + "const": "performance" + }, + { + "description": "Security tests", + "type": "string", + "const": "security" + }, + { + "description": "Mixed/general testing framework", + "type": "string", + "const": "mixed" + } + ] + }, + "FileStats": { + "description": "File statistics providing context for the analysis.", + "type": "object", + "properties": { + "total_files": { + "description": "Total number of files analyzed", + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "by_extension": { + "description": "File count by extension (e.g., {\"rs\": 42, \"toml\": 3})", + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "directories": { + "description": "Number of directories traversed", + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "excluded_files": { + "description": "Number of files excluded (`node_modules`, target, etc.)", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "total_files", + "by_extension", + "directories", + "excluded_files" + ] + }, + "Commands": { + "description": "Executable commands for common project operations.\n\nThese commands are detected from package.json scripts, Makefile, Cargo.toml,\nor other project configuration files.", + "type": "object", + "properties": { + "start": { + "description": "Command to start the application (e.g., \"cargo run\", \"npm start\")", + "type": [ + "string", + "null" + ] + }, + "dev": { + "description": "Command to start in development mode (e.g., \"npm run dev\")", + "type": [ + "string", + "null" + ] + }, + "test": { + "description": "Command to run tests (e.g., \"cargo test\", \"npm test\")", + "type": [ + "string", + "null" + ] + }, + "build": { + "description": "Command to build for production (e.g., \"cargo build --release\")", + "type": [ + "string", + "null" + ] + }, + "lint": { + "description": "Command to run linter (e.g., \"cargo clippy\", \"npm run lint\")", + "type": [ + "string", + "null" + ] + }, + "fmt": { + "description": "Command to format code (e.g., \"cargo fmt\", \"npm run fmt\")", + "type": [ + "string", + "null" + ] + }, + "typecheck": { + "description": "Command to run type checker (e.g., \"tsc --noEmit\")", + "type": [ + "string", + "null" + ] + } + } + }, + "EntryPoint": { + "description": "A key entry point into the codebase.\n\nEntry points help AI agents understand where to start when exploring\nor modifying specific aspects of the project.", + "type": "object", + "properties": { + "file": { + "description": "Relative path from project root", + "type": "string" + }, + "purpose": { + "description": "Purpose of this entry point", + "$ref": "#/$defs/EntryPointPurpose" + } + }, + "required": [ + "file", + "purpose" + ] + }, + "EntryPointPurpose": { + "description": "Purpose categories for entry points.", + "oneOf": [ + { + "description": "Main binary entry point (e.g., src/main.rs, index.js)", + "type": "string", + "const": "binary_entry" + }, + { + "description": "Library entry point (e.g., src/lib.rs, lib/index.js)", + "type": "string", + "const": "library_entry" + }, + { + "description": "Test entry point (e.g., tests/main.rs)", + "type": "string", + "const": "test_entry" + }, + { + "description": "Configuration file (e.g., config/default.toml)", + "type": "string", + "const": "config" + }, + { + "description": "Route definitions (e.g., src/routes.rs, routes/index.js)", + "type": "string", + "const": "routes" + }, + { + "description": "Main UI component (e.g., src/App.tsx)", + "type": "string", + "const": "main_component" + } + ] + }, + "EnvVar": { + "description": "An environment variable used by the project.\n\nDetected from .env.example, docker-compose.yml, config files, or code.", + "type": "object", + "properties": { + "name": { + "description": "Environment variable name (e.g., \"`DATABASE_URL`\")", + "type": "string" + }, + "required": { + "description": "Whether this variable is required for the app to run", + "type": "boolean" + }, + "purpose": { + "description": "What this variable is used for", + "type": [ + "string", + "null" + ] + }, + "default": { + "description": "Default value if not set", + "type": [ + "string", + "null" + ] + }, + "example": { + "description": "Example value for documentation", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "required" + ] + } + }, + "$id": "https://gbqr.us/operator/project-analysis.schema.json", + "$comment": "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema" +} \ No newline at end of file diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 4bf159d..929dc12 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -31,12 +31,20 @@ "default": {} }, "project_collection_prefs": { - "description": "Per-project issue type collection preferences (project_name -> collection_name)", + "description": "Per-project issue type collection preferences (`project_name` -> `collection_name`)", "type": "object", "additionalProperties": { "type": "string" }, "default": {} + }, + "multi_agent_groups": { + "description": "Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`)", + "type": "array", + "items": { + "$ref": "#/$defs/MultiAgentGroup" + }, + "default": [] } }, "required": [ @@ -96,24 +104,24 @@ ], "default": null }, - "cmux_window_ref": { - "description": "cmux window reference ID", + "session_window_ref": { + "description": "Session window reference ID (top-level grouping: cmux window, tmux session, etc.)", "type": [ "string", "null" ], "default": null }, - "cmux_workspace_ref": { - "description": "cmux workspace reference ID", + "session_context_ref": { + "description": "Session context reference ID (mid-level: cmux workspace, tmux window, etc.)", "type": [ "string", "null" ], "default": null }, - "cmux_surface_ref": { - "description": "cmux surface reference ID", + "session_pane_ref": { + "description": "Session pane reference ID (leaf-level: cmux surface, tmux pane, etc.)", "type": [ "string", "null" @@ -181,7 +189,7 @@ "default": null }, "pr_status": { - "description": "Last known PR status (\"open\", \"approved\", \"changes_requested\", \"merged\", \"closed\")", + "description": "Last known PR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")", "type": [ "string", "null" @@ -221,7 +229,7 @@ "default": null }, "review_state": { - "description": "Review state for awaiting_input agents\nValues: \"pending_plan\", \"pending_visual\", \"pending_pr_creation\", \"pending_pr_merge\"", + "description": "Review state for `awaiting_input` agents\nValues: \"`pending_plan`\", \"`pending_visual`\", \"`pending_pr_creation`\", \"`pending_pr_merge`\"", "type": [ "string", "null" @@ -437,6 +445,128 @@ "required": [ "model" ] + }, + "MultiAgentGroup": { + "description": "Tracks a group of agents working on a single multi-agent step", + "type": "object", + "properties": { + "group_id": { + "description": "Unique group identifier", + "type": "string" + }, + "ticket_id": { + "description": "Ticket this group belongs to", + "type": "string" + }, + "step_name": { + "description": "Step name being executed", + "type": "string" + }, + "step_type": { + "description": "Step type (`multi_model`, `multi_prompt`, `matrixed`)", + "type": "string" + }, + "agent_ids": { + "description": "Agent IDs in this group (populated as sub-agents launch)", + "type": "array", + "items": { + "type": "string" + } + }, + "phase": { + "description": "Current execution phase", + "$ref": "#/$defs/MultiAgentPhase" + }, + "individual_outputs": { + "description": "Collected outputs from completed sub-agents, keyed by `variant_key`\n(delegator name for `multi_model`, index for `multi_prompt`,\n`{delegator}:{prompt_idx}` for `matrixed`).", + "type": "object", + "additionalProperties": true, + "default": {} + }, + "aggregated_output": { + "description": "Final aggregated output (set when phase = Complete)", + "default": null + }, + "expected_total": { + "description": "Total sub-agents expected (`agent_ids.len() + pending_launches.len()`).", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 0 + }, + "pending_launches": { + "description": "Sub-agents that still need launching (waiting for a free slot).", + "type": "array", + "items": { + "$ref": "#/$defs/PendingSubAgent" + }, + "default": [] + }, + "agent_variant_keys": { + "description": "Maps launched `agent_id` to the `variant_key` used as the output key.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + } + }, + "required": [ + "group_id", + "ticket_id", + "step_name", + "step_type", + "agent_ids", + "phase" + ] + }, + "MultiAgentPhase": { + "description": "Execution phase for a multi-agent group", + "oneOf": [ + { + "description": "Phase 1: all sub-agents running the initial prompt", + "type": "string", + "const": "fan_out" + }, + { + "description": "Phase 2: voting/selection round (`multi_model` with `share_answers`)", + "type": "string", + "const": "voting" + }, + { + "description": "All done, aggregated output ready", + "type": "string", + "const": "complete" + }, + { + "description": "One or more sub-agents failed", + "type": "string", + "const": "failed" + } + ] + }, + "PendingSubAgent": { + "description": "A sub-agent that has been planned but not yet launched (slot queue).", + "type": "object", + "properties": { + "delegator_name": { + "description": "Delegator (from `config.delegators`) this sub-agent should use.", + "type": "string" + }, + "prompt": { + "description": "Fully-rendered prompt text for this sub-agent.", + "type": "string" + }, + "variant_key": { + "description": "Key under which this sub-agent's output is recorded (see `individual_outputs`).", + "type": "string" + } + }, + "required": [ + "delegator_name", + "prompt", + "variant_key" + ] } } } \ No newline at end of file diff --git a/docs/schemas/state.md b/docs/schemas/state.md index cc865c2..27025fa 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -31,7 +31,8 @@ This file tracks the current state of agents, completed tickets, and system stat | `agents` | `array` | Yes | Currently active agents | | `completed` | `array` | Yes | Recently completed tickets | | `project_llm_stats` | `object` | No | Per-project LLM usage statistics | -| `project_collection_prefs` | `object` | No | Per-project issue type collection preferences (project_name -> collection_name) | +| `project_collection_prefs` | `object` | No | Per-project issue type collection preferences (`project_name` -> `collection_name`) | +| `multi_agent_groups` | `array` | No | Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) | ## Type Definitions @@ -50,9 +51,9 @@ This file tracks the current state of agents, completed tickets, and system stat | `paired` | `boolean` | Yes | | | `session_name` | `string` \| `null` | No | The terminal session name for this agent (for recovery) | | `session_wrapper` | `string` \| `null` | No | Which session wrapper manages this agent: "tmux", "vscode", or "cmux" (None = legacy tmux) | -| `cmux_window_ref` | `string` \| `null` | No | cmux window reference ID | -| `cmux_workspace_ref` | `string` \| `null` | No | cmux workspace reference ID | -| `cmux_surface_ref` | `string` \| `null` | No | cmux surface reference ID | +| `session_window_ref` | `string` \| `null` | No | Session window reference ID (top-level grouping: cmux window, tmux session, etc.) | +| `session_context_ref` | `string` \| `null` | No | Session context reference ID (mid-level: cmux workspace, tmux window, etc.) | +| `session_pane_ref` | `string` \| `null` | No | Session pane reference ID (leaf-level: cmux surface, tmux pane, etc.) | | `content_hash` | `string` \| `null` | No | Hash of the last captured pane content (for change detection) | | `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) | @@ -60,12 +61,12 @@ This file tracks the current state of agents, completed tickets, and system stat | `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_status` | `string` \| `null` | No | Last known PR 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" | -| `review_state` | `string` \| `null` | No | Review state for awaiting_input agents Values: "pending_plan", "pending_visual", "pending_pr_creation", "pending_pr_merge" | +| `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) | @@ -119,3 +120,35 @@ Usage statistics for a specific model | `failure_count` | `integer` | No | Failure count | | `total_time_secs` | `integer` | No | Total time (seconds) | +### MultiAgentGroup + +Tracks a group of agents working on a single multi-agent step + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `group_id` | `string` | Yes | Unique group identifier | +| `ticket_id` | `string` | Yes | Ticket this group belongs to | +| `step_name` | `string` | Yes | Step name being executed | +| `step_type` | `string` | Yes | Step type (`multi_model`, `multi_prompt`, `matrixed`) | +| `agent_ids` | `array` | Yes | Agent IDs in this group (populated as sub-agents launch) | +| `phase` | → `MultiAgentPhase` | Yes | Current execution phase | +| `individual_outputs` | `object` | No | Collected outputs from completed sub-agents, keyed by `variant_key` (delegator name for `multi_model`, index for `multi_prompt`, `{delegator}:{prompt_idx}` for `matrixed`). | +| `aggregated_output` | object | No | Final aggregated output (set when phase = Complete) | +| `expected_total` | `integer` | No | Total sub-agents expected (`agent_ids.len() + pending_launches.len()`). | +| `pending_launches` | `array` | No | Sub-agents that still need launching (waiting for a free slot). | +| `agent_variant_keys` | `object` | No | Maps launched `agent_id` to the `variant_key` used as the output key. | + +### MultiAgentPhase + +Execution phase for a multi-agent group + +### PendingSubAgent + +A sub-agent that has been planned but not yet launched (slot queue). + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `delegator_name` | `string` | Yes | Delegator (from `config.delegators`) this sub-agent should use. | +| `prompt` | `string` | Yes | Fully-rendered prompt text for this sub-agent. | +| `variant_key` | `string` | Yes | Key under which this sub-agent's output is recorded (see `individual_outputs`). | + diff --git a/docs/shortcuts/index.md b/docs/shortcuts/index.md index c04e567..b0956df 100644 --- a/docs/shortcuts/index.md +++ b/docs/shortcuts/index.md @@ -21,9 +21,12 @@ Operator uses vim-style keybindings for navigation and actions. This reference d | `k/↑` | Move up | Dashboard | | `Q` | Focus Queue panel | Dashboard | | `A/a` | Focus Agents panel | Dashboard | +| `h/←` | Previous panel | Dashboard | +| `l/→` | Next panel | Dashboard | | `Enter` | Select / Confirm | Dashboard | +| `Shift+Enter` | Auto-launch (delegator chain) | Dashboard | | `Esc` | Cancel / Close | Dashboard | -| `L/l` | Launch selected ticket | Dashboard | +| `L` | Launch selected ticket | Dashboard | | `P/p` | Pause queue processing | Dashboard | | `R/r` | Resume queue processing | Dashboard | | `S` | Sync kanban collections | Dashboard | @@ -31,10 +34,15 @@ Operator uses vim-style keybindings for navigation and actions. This reference d | `X/x` | Reject review (agents panel) | Dashboard | | `W/w` | Toggle Backstage server | Dashboard | | `V/v` | Show session preview | Dashboard | +| `F` | Focus cmux window | Dashboard | | `C` | Create new ticket | Dashboard | | `J` | Open Projects menu | Dashboard | | `T/t` | Switch issue type collection | Dashboard | | `K` | Open Kanban providers view | Dashboard | +| `Enter` | Activate (A) | Status Panel | +| `Esc/Backspace` | Go back (B) | Status Panel | +| `Shift+Enter` | Special action (X) * | Status Panel | +| `Ctrl+Enter` | Refresh (Y) ⟳ | Status Panel | | `g` | Scroll to top | Session Preview | | `G` | Scroll to bottom | Session Preview | | `PgUp` | Page up | Session Preview | @@ -68,14 +76,17 @@ These shortcuts are available in the main dashboard view. | `k/↑` | Move up | | `Q` | Focus Queue panel | | `A/a` | Focus Agents panel | +| `h/←` | Previous panel | +| `l/→` | Next panel | ### Actions | Key | Action | | --- | --- | | `Enter` | Select / Confirm | +| `Shift+Enter` | Auto-launch (delegator chain) | | `Esc` | Cancel / Close | -| `L/l` | Launch selected ticket | +| `L` | Launch selected ticket | | `P/p` | Pause queue processing | | `R/r` | Resume queue processing | | `S` | Sync kanban collections | @@ -83,6 +94,7 @@ These shortcuts are available in the main dashboard view. | `X/x` | Reject review (agents panel) | | `W/w` | Toggle Backstage server | | `V/v` | Show session preview | +| `F` | Focus cmux window | ### Dialogs @@ -93,6 +105,24 @@ These shortcuts are available in the main dashboard view. | `T/t` | Switch issue type collection | | `K` | Open Kanban providers view | +## Status Panel + +These shortcuts are available when the status panel is focused. Actions use an ABXY gamepad-style mapping. + +### Navigation + +| Key | Action | +| --- | --- | +| `Esc/Backspace` | Go back (B) | + +### Actions + +| Key | Action | +| --- | --- | +| `Enter` | Activate (A) | +| `Shift+Enter` | Special action (X) * | +| `Ctrl+Enter` | Refresh (Y) ⟳ | + ## Session Preview These shortcuts are available when viewing a session preview. diff --git a/docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md b/docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md new file mode 100644 index 0000000..6971abb --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md @@ -0,0 +1,397 @@ +# Polymorphic Step Types — Remaining Work Handoff + +## What's Done (Phases 1-3 + Phase 4 partial) + +All schema types, validation, step output artifacts, single-agent executors, and multi-agent aggregation functions are implemented and tested. 1,666 tests pass, `cargo fmt && cargo clippy -- -D warnings` clean. + +### Completed artifacts: + +| File | What was added | +|------|---------------| +| `src/templates/schema.rs` | `StepTypeTag` enum (8 variants), `ClassifierConfig`, `ClassifierOutputType`, `RagConfig`, `RagSource`, `DelegatorStepConfig`, `McpStepConfig`, `McpToolRef`, `MultiModelConfig`, `VotingStrategy`, `VotingMode`, `MultiPromptConfig`, `SelectionStrategy`, `MatrixedConfig`, `MatrixedOutputFormat`. `StepSchema` updated with `step_type` field + 7 optional `*_config` fields. `validate_type_config()` on StepSchema. | +| `src/templates/step_type.rs` | `classifier_json_schema()`, `prompt_augmentation()`, `effective_allowed_tools()`, `effective_agent()`, `aggregate_multi_model()`, `aggregate_multi_prompt()`, `aggregate_matrixed()`, `apply_votes()`, `apply_selection()`, `apply_aggregation()`, `select_winner_by_strategy()`. 24 unit tests. | +| `src/steps/manager.rs` | `load_step_outputs()` reads `{worktree}/.tickets/steps/{name}.output.json` into Handlebars context as `{{ steps.{name}.* }}`. `render_prompt()` injects step outputs. 5 tests. | +| `src/steps/session.rs` | `generate_prompt()` uses `effective_allowed_tools()` and `prompt_augmentation()`. | +| `src/agents/launcher/step_config.rs` | `get_step_config()` derives `json_schema` from classifier config, injects MCP server permissions, merges delegator permissions. | +| `src/agents/agent_switcher.rs` | `needs_switch()` uses `effective_agent()`. | +| `src/agents/launcher/mod.rs` | `collect_tools_for_ticket()` uses `effective_agent()`. | +| `src/queue/ticket.rs` | `advance_step()` uses `effective_agent()`. | +| `src/state.rs` | `MultiAgentGroup`, `MultiAgentPhase`, `multi_agent_groups` field on `State`, helper methods: `create_multi_agent_group()`, `get_group_for_agent()`, `get_group_for_ticket()`, `record_agent_output()`, `update_group_phase()`, `complete_group()`, `cleanup_finished_groups()`. | +| `src/docs_gen/issuetype_json_schema.rs` | Generator now outputs to `src/schemas/issuetype_schema.json` (overwrites stale hand-written file). | +| `src/schemas/issuetype_schema.json` | Auto-generated, includes all new step types. | + +--- + +## Remaining Work + +### Phase 4d: Fan-out launch in `src/agents/launcher/mod.rs` + +**Where to insert**: In `launch_with_options()` (line 227), after getting the effective step (around line 247), detect if the current step is a multi-agent type and branch: + +```rust +// After getting the step schema for the ticket: +let step = ticket.current_step_schema(); +if let Some(ref step) = step { + match step.step_type { + StepTypeTag::MultiModel => return self.launch_multi_model(ticket, step, options).await, + StepTypeTag::MultiPrompt => return self.launch_multi_prompt(ticket, step, options).await, + StepTypeTag::Matrixed => return self.launch_matrixed(ticket, step, options).await, + _ => {} // fall through to existing single-agent launch + } +} +``` + +**New methods to add on `Launcher`**: + +1. `launch_multi_model()`: + - Read `step.multi_model_config.delegators` (e.g., `["claude-opus", "gemini-pro"]`) + - For each delegator name, resolve to a `Delegator` from `config.delegators` + - Call `launch_with_options()` (or the inner launch_in_tmux/cmux) N times, each with: + - `session_name = format!("op-{}-{}", ticket.id, delegator_name)` + - The delegator's tool+model + - Same prompt (from the step) + - Same worktree + - Register each sub-agent via `state.add_agent_with_options()` + - Create a `MultiAgentGroup` via `state.create_multi_agent_group(ticket_id, step_name, "multi_model", agent_ids)` + - Return the group_id (or first agent_id) + +2. `launch_multi_prompt()`: + - Read `step.multi_prompt_config.prompt_variations` + - Same delegator for all (from `multi_prompt_config.agent` or default) + - N launches, each with a different prompt from `prompt_variations[i]` + - Session names: `format!("op-{}-v{}", ticket.id, i)` + - Create group with keys `"0"`, `"1"`, ... for `individual_outputs` + +3. `launch_matrixed()`: + - NxM launches: delegators × prompt_variations + - Session names: `format!("op-{}-{}-v{}", ticket.id, delegator_name, prompt_idx)` + - Create group with keys `"{delegator}:{prompt_idx}"` + +**Important**: Each sub-agent writes its output to `{worktree}/.tickets/steps/{step_name}/{agent_id_or_key}.json`. The sub-agent's prompt should include an instruction like: "Write your final output to `.tickets/steps/{step_name}/{key}.json`." + +**Slot accounting**: Check `state.running_agents().len() + N <= config.effective_max_agents()` before launching. If not enough slots, either fail with an error or launch partial (queuing is complex — fail-fast is simpler for v1). + +### Phase 4e: Group-aware sync in `src/agents/sync.rs` + +**Where to insert**: In `sync_all()` (line 99), at the `SyncAction::StepCompleted` handler (line 249): + +```rust +SyncAction::StepCompleted => { + // Check if this agent belongs to a multi-agent group + if let Some(group) = state.get_group_for_agent(&agent_id) { + let group_id = group.group_id.clone(); + let step_type = group.step_type.clone(); + let step_name = group.step_name.clone(); + + // Read this agent's output from worktree + let output = read_agent_step_output(ticket, &step_name, &agent_id); + + // Record it; returns true if all agents in group are done + let all_done = state.record_agent_output(&agent_id, output)?; + + if all_done { + let group = state.get_group_for_ticket(&ticket.id).unwrap().clone(); + + // Get step schema for config access + let step_schema = ticket.current_step_schema(); + + let aggregated = match step_type.as_str() { + "multi_model" => { + let config = step_schema.and_then(|s| s.multi_model_config.clone()); + if let Some(cfg) = config { + let mut result = step_type::aggregate_multi_model( + &group.individual_outputs, &cfg + ); + // TODO: Phase 2 voting round if cfg.share_answers + // For v1, use the pre-vote winner selection + result + } else { serde_json::json!(null) } + } + "multi_prompt" => { /* similar with aggregate_multi_prompt */ } + "matrixed" => { /* similar with aggregate_matrixed */ } + _ => serde_json::json!(null), + }; + + // Write aggregated output artifact + write_step_output_artifact(ticket, &step_name, &aggregated)?; + + // Mark group complete + state.complete_group(&group_id, aggregated)?; + + // Advance the ticket's step (single advance for the whole group) + ticket.advance_step()?; + + // Clean up sub-agent records + for aid in &group.agent_ids { + state.remove_agent(aid)?; + } + } + // else: not all done yet, wait for remaining sub-agents + } else { + // Existing single-agent completion logic (unchanged) + match ticket.advance_step() { ... } + } +} +``` + +**Helper functions to add**: + +```rust +fn read_agent_step_output(ticket: &Ticket, step_name: &str, key: &str) -> serde_json::Value { + let worktree = ticket.worktree_path.as_deref().unwrap_or("."); + let path = format!("{worktree}/.tickets/steps/{step_name}/{key}.json"); + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(serde_json::Value::Null) +} + +fn write_step_output_artifact( + ticket: &Ticket, step_name: &str, output: &serde_json::Value +) -> Result<()> { + let worktree = ticket.worktree_path.as_deref().unwrap_or("."); + let dir = format!("{worktree}/.tickets/steps"); + std::fs::create_dir_all(&dir)?; + let path = format!("{dir}/{step_name}.output.json"); + std::fs::write(&path, serde_json::to_string_pretty(output)?)?; + Ok(()) +} +``` + +### Phase 4f: REST API for grouped completion in `src/rest/routes/launch.rs` + +**Where to insert**: In `complete_step()` (line 173), after determining the status: + +```rust +// After existing status determination (line 204-225): + +// Check if this agent is part of a multi-agent group +let api_state = state.lock().await; +if let Some(group) = api_state.get_group_for_agent(&request.session_id.unwrap_or_default()) { + // This is a sub-agent completion — collect but don't advance + let output = request.output.as_ref() + .and_then(|o| o.summary.as_ref()) + .map(|s| serde_json::json!(s)) + .unwrap_or(serde_json::Value::Null); + + let all_done = api_state.record_agent_output(&agent_id, output)?; + + if all_done { + // Return status indicating the group is ready for aggregation + // The sync loop will handle the actual aggregation + return Ok(Json(StepCompleteResponse { + status: "group_complete".to_string(), + auto_proceed: false, // sync loop handles advancement + ..default_response + })); + } else { + return Ok(Json(StepCompleteResponse { + status: "group_partial".to_string(), + auto_proceed: false, + ..default_response + })); + } +} +// else: fall through to existing single-agent logic +``` + +Add `"group_complete"` and `"group_partial"` as recognized status values. + +### Phase 2 Voting/Selection Rounds + +The most complex part. When `all_done` is true in the sync loop and the step type is `multi_model` with `share_answers: true`: + +1. **Single Judge mode** (`voting_mode == SingleJudge`): + - Launch ONE new agent (first delegator) with a voting prompt that includes all collected responses + - Use `classifier_json_schema` to generate a schema for `{ "vote": , "reasoning": "" }` + - When this agent completes, call `apply_votes()` on the aggregated output + - Then write the artifact and advance + +2. **Multi Voter mode** (`voting_mode == MultiVoter`): + - Transition group phase to `Voting` + - Launch N new agents (one per original delegator) with voting prompts + - Each votes via structured output + - When all N voting agents complete, tally and `apply_votes()` + +For v1, implement `SingleJudge` only. `MultiVoter` can be a follow-up. + +For `multi_prompt` with `selection_strategy`: + - Launch one agent with selection_prompt + all variation outputs + - Agent returns `{ "selected_index": N }` + - Call `apply_selection()` on the aggregated output + +--- + +## Phase 5: Collection Examples & Remaining Items + +### 5a. Add `if/then` conditionals to JSON schema + +In `src/docs_gen/issuetype_json_schema.rs`, in `generate()` (after line 48 where schema metadata is added), add post-processing: + +```rust +// Add if/then conditionals for step type validation +if let Some(defs) = schema_value.get("$defs").or(schema_value.get("definitions")) { + if defs.get("StepSchema").is_some() { + let step_types = [ + ("classifier", "classifier_config"), + ("rag", "rag_config"), + ("delegator", "delegator_config"), + ("mcp", "mcp_config"), + ("multi_model", "multi_model_config"), + ("multi_prompt", "multi_prompt_config"), + ("matrixed", "matrixed_config"), + ]; + // Find StepSchema in $defs and add allOf with if/then blocks + // Each: if { properties: { type: { const: X } } } then { required: [X_config] } + } +} +``` + +This is optional — the Rust-side `validate_type_config()` already enforces this at load time. + +### 5b. Re-enable JSON_SCHEMA_ENABLED for classifier steps + +In `src/agents/launcher/llm_command.rs`: +- Line 13: Change `const JSON_SCHEMA_ENABLED: bool = false;` to `true` +- OR: Make it conditional — only enable for classifier steps by checking `step_config.json_schema.is_some()` regardless of the constant + +The safer approach: remove the constant entirely and always pass `--json-schema` when `step_config.json_schema` is `Some`. The original issue was command-line length, but we write schemas to files (not inline), so the path length should be fine. + +### 5c. Regenerate documentation + +```bash +cargo run -- docs --only issuetype-json-schema # Regenerates src/schemas/issuetype_schema.json +cargo run -- docs --only issuetype # Regenerates docs/schemas/issuetype.md +``` + +### 5d. Create example collection with new step types + +Create `src/collections/advanced/` with example issuetypes demonstrating each new step type: + +1. **`REVIEW.json`** — multi-model consensus review: + ```json + { + "key": "REVIEW", + "name": "Multi-Model Review", + "steps": [ + { + "name": "review", + "type": "multi_model", + "prompt": "Review this PR for issues", + "outputs": ["review"], + "multi_model_config": { + "delegators": ["claude-opus", "gemini-pro"], + "voting_strategy": "majority", + "share_answers": true + }, + "next_step": "apply" + }, + { + "name": "apply", + "type": "task", + "prompt": "Apply the winning review: {{ steps.review.winner_response }}", + "outputs": ["code"], + "allowed_tools": ["Read", "Write", "Edit"] + } + ] + } + ``` + +2. **`ASSESS.json`** — classifier + RAG pipeline: + ```json + { + "steps": [ + { "type": "rag", "name": "gather", ... }, + { "type": "classifier", "name": "classify", "classifier_config": { "output_type": "enum", "options": [...] } }, + { "type": "task", "name": "act", "prompt": "Severity is {{ steps.classify.value }}" } + ] + } + ``` + +### 5e. Add `advanced` collection preset + +In `src/config.rs`, add to `CollectionPreset`: +```rust +pub enum CollectionPreset { + Simple, + DevKanban, + DevopsKanban, + Advanced, // NEW + Custom, +} +``` + +With `issue_types()` returning the advanced collection types. + +--- + +## Testing Checklist + +### Unit tests (already passing — 1,666 total): +- [x] `StepTypeTag` serde round-trip for all 8 variants +- [x] All config structs deserialize from JSON +- [x] `validate_type_config()` catches missing configs, invalid options, minimum counts +- [x] `classifier_json_schema()` generates correct schema for all 5 output types +- [x] `prompt_augmentation()` produces correct text for each step type +- [x] `effective_agent()` resolves from type-specific configs with fallback +- [x] `effective_allowed_tools()` resolves from type-specific configs with fallback +- [x] `load_step_outputs()` reads artifacts into Handlebars context +- [x] `render_prompt()` interpolates `{{ steps.X.value }}` correctly +- [x] `aggregate_multi_model()` collects responses and selects winner +- [x] `apply_votes()` updates winner based on vote tallies +- [x] `aggregate_multi_prompt()` collects variations +- [x] `apply_selection()` updates selected response +- [x] `aggregate_matrixed()` builds NxM matrix +- [x] `apply_aggregation()` sets aggregated result +- [x] `MultiAgentGroup` state persistence (serde round-trip via `#[serde(default)]`) +- [x] All existing collection JSONs parse without errors (backward compat) + +### Tests to write for Phase 4d-4f: +- [ ] `launch_multi_model()` creates N agents + 1 group in state +- [ ] `launch_multi_prompt()` creates N agents with different prompts +- [ ] Slot limit check: launching exceeding `max_parallel` fails gracefully +- [ ] Sync: single sub-agent completion → group not done yet +- [ ] Sync: all sub-agents complete → triggers aggregation + advance_step +- [ ] Sync: aggregated output written to `.tickets/steps/{name}.output.json` +- [ ] REST: `complete_step` returns `group_partial` for incomplete groups +- [ ] REST: `complete_step` returns `group_complete` when last agent finishes +- [ ] Voting round (single_judge): launches judge agent, processes vote output +- [ ] State cleanup: `cleanup_finished_groups()` removes completed groups + +### CI validation: +```bash +cargo fmt # Must be clean +cargo clippy -- -D warnings # Must be clean +cargo test # All tests pass +cargo run -- docs # Regenerates all docs without error +``` + +### Manual E2E test: +1. Configure 2+ delegators in operator config +2. Create a ticket with a `multi_model` step referencing those delegators +3. Launch the ticket — verify N tmux sessions spawn +4. Let all agents complete — verify aggregated output artifact appears +5. Verify the next step sees `{{ steps.{name}.winner_response }}` + +--- + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `src/templates/schema.rs` | All type definitions (StepTypeTag, configs, enums) | +| `src/templates/step_type.rs` | Prompt augmentation, effective_agent/tools, aggregation functions | +| `src/steps/manager.rs` | Step output artifact loading into Handlebars context | +| `src/steps/session.rs` | Prompt generation with type augmentation | +| `src/agents/launcher/step_config.rs` | Step config extraction (classifier JSON schema, MCP injection) | +| `src/agents/launcher/mod.rs` | **MODIFY**: Add fan-out launch methods | +| `src/agents/sync.rs` | **MODIFY**: Add group-aware completion detection | +| `src/rest/routes/launch.rs` | **MODIFY**: Add grouped completion handling | +| `src/state.rs` | MultiAgentGroup tracking + helper methods | +| `src/schemas/issuetype_schema.json` | Auto-generated JSON schema (run `cargo run -- docs --only issuetype-json-schema`) | +| `src/docs_gen/issuetype_json_schema.rs` | Schema generator (outputs to `src/schemas/`) | +| `src/agents/launcher/llm_command.rs:13` | `JSON_SCHEMA_ENABLED` constant — set to `true` | + +## Branch + +All work is on branch `issuetype-onboarding-kanban-both`. No commits have been made for this work yet — all changes are unstaged. diff --git a/shared/types.ts b/shared/types.ts index 4a7ec4e..7bf36da 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -174,7 +174,7 @@ pr_number: bigint | null, */ github_repo: string | null, /** - * Current PR status ("open", "approved", "changes_requested", "merged", "closed") + * Current PR status ("open", "approved", "`changes_requested`", "merged", "closed") */ pr_status: string | null, /** @@ -284,7 +284,12 @@ version_check: VersionCheckConfig, /** * Agent delegator configurations for autonomous ticket launching */ -delegators: Array, }; +delegators: Array, +/** + * 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. + */ +model_servers: Array, }; export type AgentsConfig = { max_parallel: number, cores_reserved: number, health_check_interval: bigint, /** @@ -332,7 +337,7 @@ worktrees: string, }; export type UiConfig = { refresh_rate_ms: bigint, completed_history_hours: bigint, summary_max_length: number, panel_names: PanelNamesConfig, }; -export type PanelNamesConfig = { queue: string, agents: string, awaiting: string, completed: string, }; +export type PanelNamesConfig = { status: string, queue: string, in_progress: string, completed: string, }; export type LaunchConfig = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, /** @@ -383,6 +388,10 @@ export type BackstageConfig = { * Whether Backstage integration is enabled */ enabled: boolean, +/** + * Whether to show Backstage in the Connections status section + */ +display: boolean, /** * Port for the Backstage server */ @@ -392,7 +401,7 @@ port: number, */ auto_start: boolean, /** - * Subdirectory within state_path for Backstage installation + * Subdirectory within `state_path` for Backstage installation */ subpath: string, /** @@ -405,7 +414,7 @@ branding_subpath: string, release_url: string, /** * Optional local path to backstage-server binary - * If set, this is used instead of downloading from release_url + * If set, this is used instead of downloading from `release_url` */ local_binary_path: string | null, /** @@ -482,7 +491,15 @@ providers: Array, */ detection_complete: boolean, /** - * Per-tool overrides for skill directories (keyed by tool_name) + * User's preferred default LLM tool (e.g., "claude") + */ +default_tool: string | null, +/** + * User's preferred default model alias (e.g., "opus") + */ +default_model: string | null, +/** + * Per-tool overrides for skill directories (keyed by `tool_name`) */ skill_directory_overrides: { [key in string]?: SkillDirectoriesOverride }, }; @@ -512,7 +529,7 @@ version_ok: boolean, */ model_aliases: Array, /** - * Command template with {{model}}, {{session_id}}, {{prompt_file}} placeholders + * Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders */ command_template: string, /** @@ -600,13 +617,19 @@ model: string, */ display_name: string | null, /** - * Arbitrary model properties (e.g., reasoning_effort, sandbox) + * Arbitrary model properties (e.g., `reasoning_effort`, sandbox) */ model_properties: { [key in string]?: string }, /** * Optional launch configuration */ -launch_config: DelegatorLaunchConfig | null, }; +launch_config: DelegatorLaunchConfig | null, +/** + * Name of a declared `ModelServer` (from `Config.model_servers`). + * `None` means use the `llm_tool`'s implicit vendor default + * (claude → anthropic-api, codex → openai-api, gemini → google-api). + */ +model_server: string | null, }; export type DelegatorLaunchConfig = { /** @@ -620,14 +643,34 @@ permission_mode: string | null, /** * Additional CLI flags */ -flags: Array, }; +flags: Array, +/** + * Override global `git.use_worktrees` per-delegator (None = use global setting) + */ +use_worktrees: boolean | null, +/** + * Whether to create a git branch for the ticket (None = default behavior) + */ +create_branch: boolean | null, +/** + * Run in docker container (None = use global `launch.docker.enabled`) + */ +docker: boolean | null, +/** + * Prompt text to prepend before the generated step prompt + */ +prompt_prefix: string | null, +/** + * Prompt text to append after the generated step prompt + */ +prompt_suffix: string | null, }; export type CollectionPreset = "simple" | "dev_kanban" | "devops_kanban" | "custom"; export type TemplatesConfig = { /** * Named preset for issue type collection - * Options: simple, dev_kanban, devops_kanban, custom + * Options: simple, `dev_kanban`, `devops_kanban`, custom */ preset: CollectionPreset, /** @@ -671,9 +714,13 @@ export type State = { paused: boolean, agents: Array, completed: Arr */ project_llm_stats: { [key in string]?: ProjectLlmStats }, /** - * Per-project issue type collection preferences (project_name -> collection_name) + * Per-project issue type collection preferences (`project_name` -> `collection_name`) */ -project_collection_prefs: { [key in string]?: string }, }; +project_collection_prefs: { [key in string]?: string }, +/** + * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) + */ +multi_agent_groups: Array, }; 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, /** @@ -685,17 +732,17 @@ session_name: string | null, */ session_wrapper: string | null, /** - * cmux window reference ID + * Session window reference ID (top-level grouping: cmux window, tmux session, etc.) */ -cmux_window_ref: string | null, +session_window_ref: string | null, /** - * cmux workspace reference ID + * Session context reference ID (mid-level: cmux workspace, tmux window, etc.) */ -cmux_workspace_ref: string | null, +session_context_ref: string | null, /** - * cmux surface reference ID + * Session pane reference ID (leaf-level: cmux surface, tmux pane, etc.) */ -cmux_surface_ref: string | null, +session_pane_ref: string | null, /** * Hash of the last captured pane content (for change detection) */ @@ -725,7 +772,7 @@ pr_number: bigint | null, */ github_repo: string | null, /** - * Last known PR status ("open", "approved", "changes_requested", "merged", "closed") + * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") */ pr_status: string | null, /** @@ -745,8 +792,8 @@ llm_model: string | null, */ launch_mode: string | null, /** - * Review state for awaiting_input agents - * Values: "pending_plan", "pending_visual", "pending_pr_creation", "pending_pr_merge" + * Review state for `awaiting_input` agents + * Values: "`pending_plan`", "`pending_visual`", "`pending_pr_creation`", "`pending_pr_merge`" */ review_state: string | null, /** @@ -845,6 +892,10 @@ display_name: string | null, * Arbitrary model properties */ model_properties: { [key in string]?: string }, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, /** * Optional launch configuration */ @@ -881,6 +932,10 @@ display_name: string | null, * Arbitrary model properties */ model_properties: { [key in string]?: string }, +/** + * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + */ +model_server: string | null, /** * Optional launch configuration */ @@ -898,7 +953,27 @@ permission_mode: string | null, /** * Additional CLI flags */ -flags: Array, }; +flags: Array, +/** + * Override global `git.use_worktrees` (None = use global setting) + */ +use_worktrees: boolean | null, +/** + * Whether to create a git branch for the ticket (None = default behavior) + */ +create_branch: boolean | null, +/** + * Run in docker container (None = use global `launch.docker.enabled`) + */ +docker: boolean | null, +/** + * Prompt text to prepend before the generated step prompt + */ +prompt_prefix: string | null, +/** + * Prompt text to append after the generated step prompt + */ +prompt_suffix: string | null, }; export type LlmTask = { /** @@ -991,6 +1066,10 @@ export type JiraDescription = { content: Array | null, }; export type JiraIssueTypeRef = { +/** + * Issue type ID (e.g., "10001") + */ +id: string | null, /** * Issue type name (e.g., "Bug", "Story", "Task") */ @@ -1166,7 +1245,11 @@ export type VsCodeModelOption = "sonnet" | "opus" | "haiku"; export type VsCodeLaunchOptions = { /** - * Model to use (sonnet, opus, haiku) + * Named delegator to use (takes precedence over model) + */ +delegator: string | null, +/** + * Model to use (sonnet, opus, haiku) — fallback when no delegator */ model: VsCodeModelOption, /** @@ -1174,7 +1257,7 @@ model: VsCodeModelOption, */ yoloMode: boolean, /** - * Resume from existing session (uses session_id from ticket) + * Resume from existing session (uses `session_id` from ticket) */ resumeSession: boolean, }; diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index 22613f9..ceed4fa 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -149,10 +149,11 @@ impl AgentSwitcher { step: &StepSchema, config: &Config, ) -> Option { - let agent_name = step.agent.as_ref()?; + // Use effective_agent which accounts for step type configs + let agent_name = crate::templates::step_type::effective_agent(step)?; // Look up agent name in config.delegators - let delegator = config.delegators.iter().find(|d| &d.name == agent_name)?; + let delegator = config.delegators.iter().find(|d| d.name == agent_name)?; // Compare to current tool/model if delegator.llm_tool == current_tool && delegator.model == current_model { @@ -298,6 +299,7 @@ mod tests { StepSchema { name: "build".to_string(), display_name: None, + step_type: crate::templates::schema::StepTypeTag::Task, outputs: vec![], prompt: "Build it".to_string(), allowed_tools: vec![], @@ -312,6 +314,13 @@ mod tests { json_schema: None, json_schema_file: None, artifact_patterns: vec![], + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, } } @@ -329,6 +338,7 @@ mod tests { model: model.to_string(), display_name: None, model_properties: HashMap::default(), + model_server: None, launch_config: None, } } diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index cba929b..f217552 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -3,7 +3,10 @@ //! Used by both the REST API launch endpoint and the TUI auto-launch path. use crate::agents::LaunchOptions; -use crate::config::{Config, Delegator, DelegatorLaunchConfig, LlmProvider}; +use crate::config::{ + implicit_model_server_for_tool, Config, Delegator, DelegatorLaunchConfig, LlmProvider, + ModelServer, +}; /// Issuetype/step agent context for delegator resolution during launch. /// @@ -18,15 +21,39 @@ pub struct AgentContext { /// Error type for delegator resolution failures. #[derive(Debug, thiserror::Error)] +#[allow(clippy::enum_variant_names)] // All failures are "unknown reference to X"; prefix is semantic. pub enum ResolutionError { #[error("Unknown delegator '{0}'")] UnknownDelegator(String), #[error("Unknown provider '{0}'")] UnknownProvider(String), + #[error("Unknown model_server '{0}'")] + UnknownModelServer(String), } -/// Convert a `Delegator` into an `LlmProvider` -fn delegator_to_provider(d: &Delegator) -> LlmProvider { +/// Resolve a delegator's `ModelServer`: named lookup if set, else implicit vendor default. +pub(crate) fn resolve_model_server_for_delegator( + config: &Config, + d: &Delegator, +) -> Result { + match d.model_server.as_deref() { + Some(name) => config + .model_servers + .iter() + .find(|s| s.name == name) + .cloned() + .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string())), + None => Ok(implicit_model_server_for_tool(&d.llm_tool)), + } +} + +/// Convert a `Delegator` into an `LlmProvider`. +/// +/// v1: populates `tool` and `model` only. The delegator's `model_server` is +/// resolved and env vars are expected to be injected into `LlmProvider.env` +/// at spawn time — currently a no-op. TODO(model-servers-v2): thread the +/// resolved `ModelServer` through to `LlmProvider.env` via a per-tool mapping. +pub(crate) fn delegator_to_provider(d: &Delegator) -> LlmProvider { LlmProvider { tool: d.llm_tool.clone(), model: d.model.clone(), @@ -35,7 +62,7 @@ fn delegator_to_provider(d: &Delegator) -> LlmProvider { } /// Apply a delegator's launch config to launch options -fn apply_delegator_launch_config( +pub(crate) fn apply_delegator_launch_config( options: &mut LaunchOptions, launch_config: &Option, ) { @@ -223,6 +250,7 @@ mod tests { model: model.to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: None, } } @@ -235,6 +263,53 @@ mod tests { assert!(!options.yolo_mode); } + #[test] + fn test_resolve_model_server_implicit_for_claude() { + let config = Config::default(); + let d = make_delegator("claude-opus", "claude", "opus"); + let server = resolve_model_server_for_delegator(&config, &d).unwrap(); + assert_eq!(server.name, "anthropic-api"); + assert_eq!(server.kind, "anthropic-api"); + } + + #[test] + fn test_resolve_model_server_implicit_for_codex() { + let config = Config::default(); + let d = make_delegator("codex-gpt", "codex", "gpt-4o"); + let server = resolve_model_server_for_delegator(&config, &d).unwrap(); + assert_eq!(server.name, "openai-api"); + } + + #[test] + fn test_resolve_model_server_named_lookup() { + let mut config = Config::default(); + config.model_servers.push(crate::config::ModelServer { + name: "ollama-local".to_string(), + kind: "ollama".to_string(), + base_url: Some("http://localhost:11434".to_string()), + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + }); + + let mut d = make_delegator("codex-local-qwen", "codex", "qwen2.5-coder"); + d.model_server = Some("ollama-local".to_string()); + + let server = resolve_model_server_for_delegator(&config, &d).unwrap(); + assert_eq!(server.name, "ollama-local"); + assert_eq!(server.kind, "ollama"); + assert_eq!(server.base_url.as_deref(), Some("http://localhost:11434")); + } + + #[test] + fn test_resolve_model_server_unknown_name_errors() { + let config = Config::default(); + let mut d = make_delegator("d", "claude", "opus"); + d.model_server = Some("nonexistent".to_string()); + let err = resolve_model_server_for_delegator(&config, &d).unwrap_err(); + assert!(matches!(err, ResolutionError::UnknownModelServer(_))); + } + #[test] fn test_resolve_single_delegator_is_default() { let mut config = Config::default(); @@ -336,6 +411,7 @@ mod tests { model: "opus".to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: Some(DelegatorLaunchConfig { yolo: true, permission_mode: None, diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index 6d1e9d2..aa8880b 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -85,8 +85,12 @@ pub fn launch_in_cmux_with_options( cmux.check_in_cmux() .map_err(|e| anyhow::anyhow!("Not running inside cmux: {e}"))?; - // Create session name from ticket ID - let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + // Create session name from ticket ID, with suffix for multi-agent fan-out + let base = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + let session_name = match &options.session_suffix { + Some(sfx) => format!("{base}-{}", sanitize_session_name(sfx)), + None => base, + }; // Resolve placement policy let (window_ref, _new_window) = resolve_placement(cmux, config.sessions.cmux.placement)?; diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 833581a..15da4de 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -183,12 +183,9 @@ impl Launcher { if let Some(template) = ticket.template_schema() { for step in &template.steps { - if let Some(ref agent_name) = step.agent { - if let Some(delegator) = self - .config - .delegators - .iter() - .find(|d| &d.name == agent_name) + if let Some(agent_name) = crate::templates::step_type::effective_agent(step) { + if let Some(delegator) = + self.config.delegators.iter().find(|d| d.name == agent_name) { if !tools.contains(&delegator.llm_tool) { tools.push(delegator.llm_tool.clone()); @@ -269,10 +266,49 @@ impl Launcher { let working_dir_str = working_dir.to_string_lossy().to_string(); - // Generate the initial prompt for the agent + // Dispatch multi-agent step types before single-agent launch. + // Worktree/skills are already set up above and shared across sub-agents. + if let Some(ref step) = ticket.current_step_schema() { + match step.step_type { + crate::templates::schema::StepTypeTag::MultiModel => { + return self + .launch_multi_model(&ticket, step, &working_dir_str, &options) + .await; + } + crate::templates::schema::StepTypeTag::MultiPrompt => { + return self + .launch_multi_prompt(&ticket, step, &working_dir_str, &options) + .await; + } + crate::templates::schema::StepTypeTag::Matrixed => { + return self + .launch_matrixed(&ticket, step, &working_dir_str, &options) + .await; + } + _ => {} + } + } + + // Single-agent path: generate prompt and launch one sub-agent. let initial_prompt = generate_prompt(&self.config, &ticket); let initial_prompt = apply_prompt_wrapping(initial_prompt, &options); + let (agent_id, _session) = self + .launch_one_sub_agent(&ticket, &working_dir_str, &initial_prompt, &options) + .await?; + Ok(agent_id) + } + + /// Dispatch a single sub-agent launch: wrapper dispatch, state registration, + /// worktree-path persistence, step recording, and start-up notification. + /// Returns `(agent_id, session_name)`. + async fn launch_one_sub_agent( + &self, + ticket: &Ticket, + working_dir_str: &str, + initial_prompt: &str, + options: &LaunchOptions, + ) -> Result<(String, String)> { // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -282,10 +318,10 @@ impl Launcher { let result = launch_in_cmux_with_options( &self.config, cmux, - &ticket, - &working_dir_str, - &initial_prompt, - &options, + ticket, + working_dir_str, + initial_prompt, + options, )?; ( result.session_name, @@ -299,10 +335,10 @@ impl Launcher { let result = launch_in_zellij_with_options( &self.config, zellij, - &ticket, - &working_dir_str, - &initial_prompt, - &options, + ticket, + working_dir_str, + initial_prompt, + options, )?; (result.session_name, "zellij", None) } else { @@ -310,10 +346,10 @@ impl Launcher { let name = launch_in_tmux_with_options( &self.config, &self.tmux, - &ticket, - &working_dir_str, - &initial_prompt, - &options, + ticket, + working_dir_str, + initial_prompt, + options, )?; (name, "tmux", None) }; @@ -399,7 +435,273 @@ impl Launcher { )?; } - Ok(agent_id) + Ok((agent_id, session_name)) + } + + /// Build per-sub-agent launch options from a base and a delegator name. + /// + /// Copies the base options, overrides `provider`/`delegator_name` from the + /// delegator, and applies the delegator's `launch_config`. Session suffix + /// is set to the `variant_key` so parallel sub-agents don't collide. + fn sub_agent_options( + &self, + base: &LaunchOptions, + delegator_name: &str, + variant_key: &str, + ) -> Result { + let delegator = self + .config + .delegators + .iter() + .find(|d| d.name == delegator_name) + .ok_or_else(|| { + anyhow::anyhow!("Delegator '{delegator_name}' not found in config.delegators") + })?; + + let mut opts = base.clone(); + opts.provider = Some(crate::agents::delegator_resolution::delegator_to_provider( + delegator, + )); + opts.delegator_name = Some(delegator.name.clone()); + crate::agents::delegator_resolution::apply_delegator_launch_config( + &mut opts, + &delegator.launch_config, + ); + opts.session_suffix = Some(variant_key.to_string()); + Ok(opts) + } + + /// Render a prompt template with the ticket's handlebars context. + fn render_variant_prompt( + &self, + template: &str, + ticket: &Ticket, + working_dir_str: &str, + ) -> Result { + let interpolator = self::interpolation::PromptInterpolator::new(); + let ctx = interpolator.build_context(&self.config, ticket, working_dir_str)?; + interpolator.render(template, &ctx) + } + + /// Compute the budget for new sub-agent launches based on `max_parallel`. + fn available_slots(&self) -> Result { + let state = State::load(&self.config)?; + let running = state.running_agents().len(); + let cap = self.config.effective_max_agents(); + Ok(cap.saturating_sub(running)) + } + + /// Fan out a `multi_model` step: N delegators, same prompt for all. + /// + /// Launches up to `available_slots()` sub-agents immediately; any that + /// don't fit are stored in `pending_launches` and drip-launched by the + /// sync loop as slots free up. Returns the `agent_id` of the first + /// sub-agent launched (or the `group_id` if nothing launched). + async fn launch_multi_model( + &self, + ticket: &Ticket, + step: &crate::templates::schema::StepSchema, + working_dir_str: &str, + base_options: &LaunchOptions, + ) -> Result { + let cfg = step.multi_model_config.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "multi_model step '{}' is missing multi_model_config", + step.name + ) + })?; + + let base_prompt = generate_prompt(&self.config, ticket); + let base_prompt = apply_prompt_wrapping(base_prompt, base_options); + + let pending: Vec = cfg + .delegators + .iter() + .map(|d| crate::state::PendingSubAgent { + delegator_name: d.clone(), + prompt: base_prompt.clone(), + variant_key: d.clone(), + }) + .collect(); + + self.launch_group( + ticket, + step, + working_dir_str, + base_options, + "multi_model", + pending, + ) + .await + } + + /// Fan out a `multi_prompt` step: N prompt variations, one delegator. + async fn launch_multi_prompt( + &self, + ticket: &Ticket, + step: &crate::templates::schema::StepSchema, + working_dir_str: &str, + base_options: &LaunchOptions, + ) -> Result { + let cfg = step.multi_prompt_config.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "multi_prompt step '{}' is missing multi_prompt_config", + step.name + ) + })?; + + // Resolve the delegator for all variations + let delegator_name = cfg + .agent + .clone() + .or_else(|| { + crate::templates::step_type::effective_agent(step) + .map(std::string::ToString::to_string) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "multi_prompt step '{}' has no agent configured \ + (set multi_prompt_config.agent or step.agent)", + step.name + ) + })?; + + let mut pending = Vec::with_capacity(cfg.prompt_variations.len()); + for (i, tpl) in cfg.prompt_variations.iter().enumerate() { + let rendered = self.render_variant_prompt(tpl, ticket, working_dir_str)?; + let full_prompt = apply_prompt_wrapping(rendered, base_options); + pending.push(crate::state::PendingSubAgent { + delegator_name: delegator_name.clone(), + prompt: full_prompt, + variant_key: i.to_string(), + }); + } + + self.launch_group( + ticket, + step, + working_dir_str, + base_options, + "multi_prompt", + pending, + ) + .await + } + + /// Fan out a `matrixed` step: N delegators x M prompt variations. + async fn launch_matrixed( + &self, + ticket: &Ticket, + step: &crate::templates::schema::StepSchema, + working_dir_str: &str, + base_options: &LaunchOptions, + ) -> Result { + let cfg = step.matrixed_config.as_ref().ok_or_else(|| { + anyhow::anyhow!("matrixed step '{}' is missing matrixed_config", step.name) + })?; + + let mut pending = Vec::with_capacity(cfg.delegators.len() * cfg.prompt_variations.len()); + for delegator in &cfg.delegators { + for (j, tpl) in cfg.prompt_variations.iter().enumerate() { + let rendered = self.render_variant_prompt(tpl, ticket, working_dir_str)?; + let full_prompt = apply_prompt_wrapping(rendered, base_options); + pending.push(crate::state::PendingSubAgent { + delegator_name: delegator.clone(), + prompt: full_prompt, + variant_key: format!("{delegator}:{j}"), + }); + } + } + + self.launch_group( + ticket, + step, + working_dir_str, + base_options, + "matrixed", + pending, + ) + .await + } + + /// Shared fan-out implementation: create the group, launch as many + /// sub-agents as slots allow, queue the rest. + async fn launch_group( + &self, + ticket: &Ticket, + step: &crate::templates::schema::StepSchema, + working_dir_str: &str, + base_options: &LaunchOptions, + step_type_name: &str, + pending: Vec, + ) -> Result { + if pending.is_empty() { + anyhow::bail!( + "multi-agent step '{}' produced zero sub-agents — check config", + step.name + ); + } + + // Create the group with everything in pending_launches. + let group_id = { + let mut state = State::load(&self.config)?; + state.create_multi_agent_group(&ticket.id, &step.name, step_type_name, pending)? + }; + + // Drip-launch up to available_slots() sub-agents right now. + self.launch_pending_sub_agents(ticket, &group_id, working_dir_str, base_options) + .await?; + + // Return the first launched agent_id (or group_id if nothing launched yet). + let state = State::load(&self.config)?; + let group = state + .multi_agent_groups + .iter() + .find(|g| g.group_id == group_id) + .ok_or_else(|| anyhow::anyhow!("group '{group_id}' not found after creation"))?; + Ok(group + .agent_ids + .first() + .cloned() + .unwrap_or_else(|| group_id.clone())) + } + + /// Launch as many pending sub-agents as current slot budget allows. + /// + /// Called during initial fan-out AND by the sync loop when slots free up. + pub async fn launch_pending_sub_agents( + &self, + ticket: &Ticket, + group_id: &str, + working_dir_str: &str, + base_options: &LaunchOptions, + ) -> Result<()> { + loop { + let budget = self.available_slots()?; + if budget == 0 { + break; + } + let next = { + let state = State::load(&self.config)?; + state.next_pending_for_group(group_id) + }; + let Some(next) = next else { + break; + }; + + let variant_key = next.variant_key.clone(); + let delegator_name = next.delegator_name.clone(); + let prompt = next.prompt.clone(); + + let sub_opts = self.sub_agent_options(base_options, &delegator_name, &variant_key)?; + let (agent_id, _session) = self + .launch_one_sub_agent(ticket, working_dir_str, &prompt, &sub_opts) + .await?; + + let mut state = State::load(&self.config)?; + state.mark_launched(group_id, &variant_key, &agent_id)?; + } + Ok(()) } /// Prepare a launch without executing it diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index df99645..ea0ddc9 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -25,6 +25,10 @@ pub struct LaunchOptions { pub prompt_prefix: Option, /// Prompt text to append after the generated step prompt pub prompt_suffix: Option, + /// Suffix appended to the generated session name to differentiate + /// multiple sub-agents launched for the same ticket (multi-agent steps). + /// When `None`, session name is the usual `{prefix}{sanitized-ticket-id}`. + pub session_suffix: Option, } impl LaunchOptions { diff --git a/src/agents/launcher/step_config.rs b/src/agents/launcher/step_config.rs index 3c5b4b7..b5c5b86 100644 --- a/src/agents/launcher/step_config.rs +++ b/src/agents/launcher/step_config.rs @@ -8,8 +8,9 @@ use anyhow::{Context, Result}; use crate::config::Config; use crate::permissions::{ProjectPermissions, ProviderCliArgs, StepPermissions, ToolPattern}; use crate::queue::Ticket; +use crate::templates::step_type; use crate::templates::{ - schema::{PermissionMode, TemplateSchema}, + schema::{PermissionMode, StepTypeTag, TemplateSchema}, TemplateType, }; @@ -41,21 +42,76 @@ pub fn get_step_config(ticket: &Ticket) -> Result { if let Some(step) = schema.get_step(&step_name) { let mut permissions = step.permissions.clone().unwrap_or_default(); + // Use effective allowed_tools (type-specific configs may override) + let effective_tools = step_type::effective_allowed_tools(step); + // Bridge: Convert allowed_tools to permissions.tools.allow if not explicitly set - if permissions.tools.allow.is_empty() && !step.allowed_tools.is_empty() { - permissions.tools.allow = step - .allowed_tools + if permissions.tools.allow.is_empty() && !effective_tools.is_empty() { + permissions.tools.allow = effective_tools .iter() - .filter(|t| *t != "*") // Skip wildcard (allows all tools) + .filter(|t| t.as_str() != "*") // Skip wildcard (allows all tools) .map(ToolPattern::new) .collect(); } + // Derive JSON schema from classifier config if not explicitly set + let json_schema = if step.json_schema.is_some() { + step.json_schema.clone() + } else if step.step_type == StepTypeTag::Classifier { + step.classifier_config + .as_ref() + .map(step_type::classifier_json_schema) + } else { + None + }; + + // Inject MCP server permissions from mcp_config + if let Some(ref mcp_cfg) = step.mcp_config { + for tool_ref in &mcp_cfg.required_tools { + if !permissions.mcp_servers.enable.contains(&tool_ref.server) { + permissions.mcp_servers.enable.push(tool_ref.server.clone()); + } + } + for tool_ref in &mcp_cfg.optional_tools { + if !permissions.mcp_servers.enable.contains(&tool_ref.server) { + permissions.mcp_servers.enable.push(tool_ref.server.clone()); + } + } + } + + // Inject permissions from delegator config + if let Some(ref del_cfg) = step.delegator_config { + if let Some(ref del_perms) = del_cfg.permissions { + // Merge delegator permissions additively + permissions + .tools + .allow + .extend(del_perms.tools.allow.clone()); + permissions.tools.deny.extend(del_perms.tools.deny.clone()); + permissions + .directories + .allow + .extend(del_perms.directories.allow.clone()); + permissions + .directories + .deny + .extend(del_perms.directories.deny.clone()); + permissions + .mcp_servers + .enable + .extend(del_perms.mcp_servers.enable.clone()); + permissions + .mcp_servers + .disable + .extend(del_perms.mcp_servers.disable.clone()); + } + } + return Ok(StepConfig { permissions, cli_args: step.cli_args.clone().unwrap_or_default(), permission_mode: step.permission_mode.clone(), - json_schema: step.json_schema.clone(), + json_schema, json_schema_file: step.json_schema_file.clone(), }); } diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 2e069b4..a9ce5bf 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -1446,3 +1446,207 @@ fn test_launch_yolo_flags_per_tool() { "Claude yolo should use --dangerously-skip-permissions, got: {script_content}" ); } + +// ======================================== +// Multi-agent fan-out tests +// ======================================== + +use crate::config::Delegator; +use crate::state::{PendingSubAgent, State}; + +fn add_delegators(config: &mut Config, names: &[&str]) { + for name in names { + config.delegators.push(Delegator { + name: (*name).to_string(), + llm_tool: "claude".to_string(), + model: "sonnet".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + model_server: None, + launch_config: None, + }); + } +} + +#[tokio::test] +async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_parallel = 10; + config.agents.cores_reserved = 0; + add_delegators(&mut config, &["claude-opus", "gemini-pro"]); + + let mock = Arc::new(MockTmuxClient::new()); + let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let ticket = make_test_ticket("test-project"); + + // Seed a group with 2 pending sub-agents + let group_id = { + let mut state = State::load(&config).unwrap(); + state + .create_multi_agent_group( + &ticket.id, + "review", + "multi_model", + vec![ + PendingSubAgent { + delegator_name: "claude-opus".to_string(), + prompt: "do the thing".to_string(), + variant_key: "claude-opus".to_string(), + }, + PendingSubAgent { + delegator_name: "gemini-pro".to_string(), + prompt: "do the thing".to_string(), + variant_key: "gemini-pro".to_string(), + }, + ], + ) + .unwrap() + }; + + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + + launcher + .launch_pending_sub_agents(&ticket, &group_id, &project_path, &LaunchOptions::default()) + .await + .unwrap(); + + // Both sub-agents launched + let state = State::load(&config).unwrap(); + let group = state + .multi_agent_groups + .iter() + .find(|g| g.group_id == group_id) + .unwrap(); + assert_eq!(group.agent_ids.len(), 2); + assert!(group.pending_launches.is_empty()); + assert_eq!(group.expected_total, 2); + assert_eq!(group.agent_variant_keys.len(), 2); + + // Distinct tmux sessions created (op-{id}-{variant}) + let base = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + let s1 = format!("{base}-{}", sanitize_session_name("claude-opus")); + let s2 = format!("{base}-{}", sanitize_session_name("gemini-pro")); + assert!( + mock.get_session_working_dir(&s1).is_some(), + "session {s1} should exist" + ); + assert!( + mock.get_session_working_dir(&s2).is_some(), + "session {s2} should exist" + ); +} + +#[tokio::test] +async fn test_launch_pending_sub_agents_respects_slot_budget() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + // Budget of exactly 1 slot + config.agents.max_parallel = 1; + config.agents.cores_reserved = 0; + add_delegators(&mut config, &["claude-opus", "gemini-pro"]); + + let mock = Arc::new(MockTmuxClient::new()); + let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let ticket = make_test_ticket("test-project"); + + let group_id = { + let mut state = State::load(&config).unwrap(); + state + .create_multi_agent_group( + &ticket.id, + "review", + "multi_model", + vec![ + PendingSubAgent { + delegator_name: "claude-opus".to_string(), + prompt: "p".to_string(), + variant_key: "claude-opus".to_string(), + }, + PendingSubAgent { + delegator_name: "gemini-pro".to_string(), + prompt: "p".to_string(), + variant_key: "gemini-pro".to_string(), + }, + ], + ) + .unwrap() + }; + + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + + launcher + .launch_pending_sub_agents(&ticket, &group_id, &project_path, &LaunchOptions::default()) + .await + .unwrap(); + + let state = State::load(&config).unwrap(); + let group = state + .multi_agent_groups + .iter() + .find(|g| g.group_id == group_id) + .unwrap(); + assert_eq!(group.agent_ids.len(), 1, "only 1 slot, 1 agent launched"); + assert_eq!( + group.pending_launches.len(), + 1, + "1 sub-agent should stay pending" + ); + // The first pending (claude-opus) should have been launched; gemini-pro is still pending + assert_eq!(group.pending_launches[0].variant_key, "gemini-pro"); +} + +#[tokio::test] +async fn test_launch_pending_sub_agents_errors_on_unknown_delegator() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_parallel = 10; + // Intentionally do NOT add any delegators. + + let mock = Arc::new(MockTmuxClient::new()); + let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let ticket = make_test_ticket("test-project"); + + let group_id = { + let mut state = State::load(&config).unwrap(); + state + .create_multi_agent_group( + &ticket.id, + "review", + "multi_model", + vec![PendingSubAgent { + delegator_name: "does-not-exist".to_string(), + prompt: "p".to_string(), + variant_key: "does-not-exist".to_string(), + }], + ) + .unwrap() + }; + + let project_path = temp_dir + .path() + .join("projects") + .join("test-project") + .to_string_lossy() + .to_string(); + + let err = launcher + .launch_pending_sub_agents(&ticket, &group_id, &project_path, &LaunchOptions::default()) + .await + .unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("does-not-exist"), + "error should mention missing delegator name, got: {msg}" + ); +} diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index ea767af..d3170eb 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -29,8 +29,14 @@ pub fn launch_in_tmux_with_options( initial_prompt: &str, options: &LaunchOptions, ) -> Result { - // Create session name from ticket ID (sanitize for tmux) - let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + // Create session name from ticket ID (sanitize for tmux). + // For multi-agent fan-out, append the session_suffix to distinguish + // parallel sub-agents on the same ticket. + let base = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); + let session_name = match &options.session_suffix { + Some(sfx) => format!("{base}-{}", sanitize_session_name(sfx)), + None => base, + }; // Check if session already exists match tmux.session_exists(&session_name) { diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index f1e7925..3c2a439 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -47,12 +47,18 @@ pub fn launch_in_zellij_with_options( .check_in_zellij() .map_err(|e| anyhow::anyhow!("Not running inside zellij: {e}"))?; - // Create session name from ticket ID with project for scannable Zellij tab bar - let session_name = format!( + // Create session name from ticket ID with project for scannable Zellij tab bar. + // For multi-agent fan-out, append the session_suffix so parallel sub-agents + // of the same ticket don't collide. + let base = format!( "op:{}:{}", sanitize_session_name(&ticket.project), sanitize_session_name(&ticket.id) ); + let session_name = match &options.session_suffix { + Some(sfx) => format!("{base}:{}", sanitize_session_name(sfx)), + None => base, + }; // Tab name = session name (1:1 mapping) let tab_name = session_name.clone(); diff --git a/src/agents/sync.rs b/src/agents/sync.rs index efa0430..aef4492 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -109,7 +109,23 @@ impl TicketSessionSync { let tickets = queue.list_in_progress()?; for mut ticket in tickets { - // Find the corresponding agent + // If this ticket has an active multi-agent group, route sub-agent + // completions through group-aware aggregation instead of the + // per-agent single-agent path. + if state.get_group_for_ticket(&ticket.id).is_some() { + if let Err(e) = + self.sync_multi_agent_ticket(&mut ticket, state, health_result, &mut result) + { + result.errors.push(format!( + "Failed to sync multi-agent ticket {}: {e}", + ticket.id + )); + } + result.synced += 1; + continue; + } + + // Find the corresponding agent (single-agent path) 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(); @@ -329,6 +345,185 @@ impl TicketSessionSync { Ok(result) } + /// 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. + fn sync_multi_agent_ticket( + &mut self, + ticket: &mut Ticket, + state: &mut State, + health_result: &HealthCheckResult, + result: &mut SyncResult, + ) -> Result<()> { + use crate::state::MultiAgentPhase; + use crate::steps::manager::StepManager; + use crate::templates::step_type; + + // Snapshot the group so we can iterate without holding a borrow on state. + let group = state + .get_group_for_ticket(&ticket.id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("group vanished while syncing ticket {}", ticket.id))?; + + let group_id = group.group_id.clone(); + let step_name = group.step_name.clone(); + + // Only process sub-agents while the group is still in fan-out phase. + if group.phase != MultiAgentPhase::FanOut { + return Ok(()); + } + + // Process each launched sub-agent's health-check action. + let agent_ids = group.agent_ids.clone(); + for agent_id in &agent_ids { + let session_name = state + .agents + .iter() + .find(|a| &a.id == agent_id) + .and_then(|a| a.session_name.clone()) + .unwrap_or_default(); + + let action = self.determine_action(ticket, &session_name, health_result); + match action { + SyncAction::StepCompleted => { + // Read this sub-agent's output file (keyed by the + // agent_id the REST handler used when writing it). + let output = StepManager::read_agent_step_output(ticket, &step_name, agent_id); + let all_done = state.record_agent_output(agent_id, output)?; + + // Mark this sub-agent as completing so we stop polling it. + state.update_agent_status( + agent_id, + "completing", + Some("sub-agent complete".to_string()), + )?; + let _ = self.tmux.reset_silence_flag(&session_name); + + if all_done { + // Re-fetch the now-fully-populated group to aggregate. + let finished = + state + .get_group_for_ticket(&ticket.id) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!("group {group_id} missing after all_done") + })?; + + // Load the step schema to get the config for aggregation. + let step_schema = ticket.current_step_schema(); + let aggregated = match group.step_type.as_str() { + "multi_model" => step_schema + .as_ref() + .and_then(|s| s.multi_model_config.as_ref()) + .map_or(serde_json::Value::Null, |cfg| { + step_type::aggregate_multi_model( + &finished.individual_outputs, + cfg, + ) + }), + "multi_prompt" => step_schema + .as_ref() + .and_then(|s| s.multi_prompt_config.as_ref()) + .map_or(serde_json::Value::Null, |cfg| { + step_type::aggregate_multi_prompt( + &finished.individual_outputs, + cfg, + ) + }), + "matrixed" => step_schema + .as_ref() + .and_then(|s| s.matrixed_config.as_ref()) + .map_or(serde_json::Value::Null, |cfg| { + step_type::aggregate_matrixed( + &finished.individual_outputs, + cfg, + &step_name, + ) + }), + other => { + tracing::warn!( + step_type = other, + "unknown multi-agent step_type, skipping aggregation" + ); + serde_json::Value::Null + } + }; + + // Persist the aggregated artifact for the next step to read. + StepManager::write_step_output_artifact(ticket, &step_name, &aggregated)?; + + // Mark the group complete with the aggregated result. + state.complete_group(&group_id, aggregated)?; + + // Advance the ticket's step exactly once for the group. + let step_display = ticket.current_step_display_name(); + match ticket.advance_step() { + Ok(StepAdvanceResult::Advanced { step, .. }) => { + if let Err(e) = ticket.append_history(&format!( + "- **{}** - Multi-agent step \"{}\" completed, advancing to \"{}\"", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), + step_display, + step, + )) { + result.errors.push(format!( + "Failed to add history for {}: {e}", + ticket.id + )); + } + tracing::info!( + ticket_id = %ticket.id, + step = %step_display, + next = %step, + "Multi-agent step aggregated, advanced" + ); + } + Ok(StepAdvanceResult::FinalStep) => { + tracing::info!( + ticket_id = %ticket.id, + step = %step_display, + "Multi-agent final step completed" + ); + } + Err(e) => { + result + .errors + .push(format!("Failed to advance step for {}: {e}", ticket.id)); + } + } + + // Remove all sub-agent records now that the group is done. + for aid in &agent_ids { + state.remove_agent(aid)?; + } + state.cleanup_finished_groups()?; + + result.completed.push(ticket.id.clone()); + // Other sub-agents (if any) were already completing; + // we've recorded the aggregation, exit the loop. + break; + } + } + SyncAction::TimedOut | SyncAction::Hung => { + tracing::warn!( + ticket_id = %ticket.id, + agent_id = %agent_id, + action = ?action, + "Multi-agent sub-agent stuck (will not advance step)" + ); + } + SyncAction::UpdatedStatus(new_status) => { + state.update_agent_status(agent_id, &new_status, None)?; + } + // Awaiting / resumed / no-change: ignore at group level for v1. + SyncAction::NoChange + | SyncAction::MovedToAwaiting + | SyncAction::ResumedFromAwaiting => {} + } + } + + Ok(()) + } + /// Determine what sync action to take for a ticket based on health check results fn determine_action( &self, diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index 7c68163..eb3f4b7 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -1562,6 +1562,45 @@ fn content_assignees(content: &RawContent) -> &[RawAssignee] { assignees.map(|a| a.nodes.as_slice()).unwrap_or(&[]) } +#[async_trait] +impl super::onboarding::KanbanOnboarding for GithubProjectsProvider { + fn provider_kind(&self) -> super::KanbanProviderType { + super::KanbanProviderType::Github + } + + async fn validate_onboarding(&self) -> Result { + let details = self.validate_detailed().await?; + let prefetched: Vec = details + .projects + .iter() + .map(|p| super::onboarding::DiscoveredProject { + workspace_key: details.user_login.clone(), + project_key: p.node_id.clone(), + project_display_name: format!("{}/{} ({})", p.owner_login, p.title, p.number), + provider_url: None, + provider_native_id: Some(p.node_id.clone()), + }) + .collect(); + Ok(super::onboarding::ValidatedWorkspace { + provider_kind: super::KanbanProviderType::Github, + workspace_key: details.user_login, + workspace_display_name: "github.com".to_string(), + sync_user_id: details.user_id, + sync_user_display_name: String::new(), + api_key_env: details.resolved_env_var, + prefetched_projects: Some(prefetched), + extra: super::onboarding::WorkspaceExtra::Github, + }) + } + + async fn discover_projects( + &self, + workspace: &super::onboarding::ValidatedWorkspace, + ) -> Result, ApiError> { + Ok(workspace.prefetched_projects.clone().unwrap_or_default()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/providers/kanban/jira.rs b/src/api/providers/kanban/jira.rs index b8e9937..08069f8 100644 --- a/src/api/providers/kanban/jira.rs +++ b/src/api/providers/kanban/jira.rs @@ -736,6 +736,49 @@ impl KanbanProvider for JiraProvider { } } +#[async_trait] +impl super::onboarding::KanbanOnboarding for JiraProvider { + fn provider_kind(&self) -> super::KanbanProviderType { + super::KanbanProviderType::Jira + } + + async fn validate_onboarding(&self) -> Result { + let details = self.validate_detailed().await?; + Ok(super::onboarding::ValidatedWorkspace { + provider_kind: super::KanbanProviderType::Jira, + workspace_key: self.domain.clone(), + workspace_display_name: self.domain.clone(), + sync_user_id: details.account_id, + sync_user_display_name: details.display_name, + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + prefetched_projects: None, + extra: super::onboarding::WorkspaceExtra::Jira { + email: self.email.clone(), + }, + }) + } + + async fn discover_projects( + &self, + workspace: &super::onboarding::ValidatedWorkspace, + ) -> Result, ApiError> { + let projects = self.list_projects().await?; + Ok(projects + .into_iter() + .map(|p| super::onboarding::DiscoveredProject { + workspace_key: workspace.workspace_key.clone(), + project_key: p.key, + project_display_name: p.name, + provider_url: Some(format!( + "https://{}/browse/{}", + workspace.workspace_key, p.id + )), + provider_native_id: Some(p.id), + }) + .collect()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/providers/kanban/linear.rs b/src/api/providers/kanban/linear.rs index ae3a478..31e89e8 100644 --- a/src/api/providers/kanban/linear.rs +++ b/src/api/providers/kanban/linear.rs @@ -30,6 +30,7 @@ pub struct LinearValidationDetails { pub user_id: String, pub user_name: String, pub org_name: String, + pub url_key: String, pub teams: Vec, } @@ -242,7 +243,6 @@ impl LinearProvider { #[serde(default)] name: String, #[serde(default, rename = "urlKey")] - #[allow(dead_code)] url_key: String, } @@ -263,6 +263,7 @@ impl LinearProvider { user_id: resp.viewer.id, user_name: resp.viewer.name, org_name: resp.organization.name, + url_key: resp.organization.url_key, teams: resp .teams .nodes @@ -984,6 +985,45 @@ impl KanbanProvider for LinearProvider { } } +#[async_trait] +impl super::onboarding::KanbanOnboarding for LinearProvider { + fn provider_kind(&self) -> super::KanbanProviderType { + super::KanbanProviderType::Linear + } + + async fn validate_onboarding(&self) -> Result { + let details = self.validate_detailed().await?; + let prefetched: Vec = details + .teams + .iter() + .map(|t| super::onboarding::DiscoveredProject { + workspace_key: details.url_key.clone(), + project_key: t.key.clone(), + project_display_name: t.name.clone(), + provider_url: None, + provider_native_id: Some(t.id.clone()), + }) + .collect(); + Ok(super::onboarding::ValidatedWorkspace { + provider_kind: super::KanbanProviderType::Linear, + workspace_key: details.url_key, + workspace_display_name: details.org_name, + sync_user_id: details.user_id, + sync_user_display_name: details.user_name, + api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), + prefetched_projects: Some(prefetched), + extra: super::onboarding::WorkspaceExtra::Linear, + }) + } + + async fn discover_projects( + &self, + workspace: &super::onboarding::ValidatedWorkspace, + ) -> Result, ApiError> { + Ok(workspace.prefetched_projects.clone().unwrap_or_default()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index 1ac1e36..5cc7122 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -7,10 +7,12 @@ mod github_projects; mod jira; mod linear; +pub mod onboarding; pub use github_projects::{GithubProjectInfo, GithubProjectsProvider, GithubValidationDetails}; pub use jira::{JiraProvider, JiraValidationDetails}; pub use linear::{LinearProvider, LinearTeamInfo, LinearValidationDetails}; +pub use onboarding::{DiscoveredProject, KanbanOnboarding, ValidatedWorkspace, WorkspaceExtra}; // Re-export Jira API response types for schema/binding generation pub use jira::{ diff --git a/src/api/providers/kanban/onboarding.rs b/src/api/providers/kanban/onboarding.rs new file mode 100644 index 0000000..1e5f3a7 --- /dev/null +++ b/src/api/providers/kanban/onboarding.rs @@ -0,0 +1,178 @@ +//! Provider-neutral onboarding types and `KanbanOnboarding` sibling trait. +//! +//! These types are Rust-internal only (no `Serialize`/`Deserialize`/`TS` derives) +//! and live alongside the existing `KanbanProvider` trait without modifying it. + +use async_trait::async_trait; + +use super::KanbanProviderType; +use crate::api::error::ApiError; + +/// Validated workspace returned by `KanbanOnboarding::validate_onboarding`. +/// +/// Contains everything the onboarding flow needs to write config and display +/// confirmation without a second round-trip to the provider. +#[derive(Debug, Clone)] +pub struct ValidatedWorkspace { + /// Which provider family this workspace belongs to. + pub provider_kind: KanbanProviderType, + /// Canonical workspace key: Jira domain, Linear `url_key`, GitHub owner login. + pub workspace_key: String, + /// Human-readable workspace name for display. + pub workspace_display_name: String, + /// Provider-specific user ID to sync issues for. + pub sync_user_id: String, + /// Human-readable name of the sync user. + pub sync_user_display_name: String, + /// Environment variable name that holds the API key/token. + pub api_key_env: String, + /// Projects discovered during validation (if available in a single round-trip). + pub prefetched_projects: Option>, + /// Provider-specific extra data needed for config upsert. + pub extra: WorkspaceExtra, +} + +/// Provider-specific extra data carried in a `ValidatedWorkspace`. +#[derive(Debug, Clone)] +pub enum WorkspaceExtra { + /// Jira requires the user's email for Basic Auth config. + Jira { email: String }, + /// Linear needs no extra data beyond what's in `ValidatedWorkspace`. + Linear, + /// GitHub Projects needs no extra data beyond what's in `ValidatedWorkspace`. + Github, +} + +/// A project discovered from a kanban provider. +#[derive(Debug, Clone)] +pub struct DiscoveredProject { + /// Workspace key this project belongs to (matches `ValidatedWorkspace.workspace_key`). + pub workspace_key: String, + /// Provider-specific project key (Jira project key, Linear team key, GitHub node ID). + pub project_key: String, + /// Human-readable project name for display. + pub project_display_name: String, + /// URL to the project in the provider's web UI (if available). + pub provider_url: Option, + /// Provider-native ID (e.g., Jira project ID, Linear team ID, GitHub node ID). + pub provider_native_id: Option, +} + +/// Sibling trait for onboarding flows — does NOT replace `KanbanProvider`. +/// +/// Provides a uniform interface across Jira, Linear, and GitHub Projects +/// for credential validation and project discovery during onboarding. +#[async_trait] +pub trait KanbanOnboarding: Send + Sync { + /// Which provider family this implementation covers. + fn provider_kind(&self) -> KanbanProviderType; + + /// Validate credentials and return a workspace summary. + /// + /// A single round-trip to the provider API that confirms the API key + /// works and returns user + workspace + (optionally) project data. + async fn validate_onboarding(&self) -> Result; + + /// Discover projects available in the workspace. + /// + /// For providers that prefetch projects during validation (Linear, GitHub), + /// this returns the cached list. For Jira, this makes a separate API call. + async fn discover_projects( + &self, + workspace: &ValidatedWorkspace, + ) -> Result, ApiError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validated_workspace_jira_extra() { + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Jira, + workspace_key: "acme.atlassian.net".to_string(), + workspace_display_name: "Acme Corp".to_string(), + sync_user_id: "acct-123".to_string(), + sync_user_display_name: "Alice".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Jira { + email: "alice@acme.com".to_string(), + }, + }; + + assert_eq!(ws.provider_kind, KanbanProviderType::Jira); + assert_eq!(ws.workspace_key, "acme.atlassian.net"); + assert_eq!(ws.sync_user_id, "acct-123"); + + match &ws.extra { + WorkspaceExtra::Jira { email } => assert_eq!(email, "alice@acme.com"), + _ => panic!("expected Jira extra"), + } + } + + #[test] + fn test_validated_workspace_linear_extra() { + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Linear, + workspace_key: "acme".to_string(), + workspace_display_name: "Acme Inc".to_string(), + sync_user_id: "user-uuid-1".to_string(), + sync_user_display_name: "Bob".to_string(), + api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), + prefetched_projects: Some(vec![DiscoveredProject { + workspace_key: "acme".to_string(), + project_key: "ENG".to_string(), + project_display_name: "Engineering".to_string(), + provider_url: None, + provider_native_id: Some("team-id-1".to_string()), + }]), + extra: WorkspaceExtra::Linear, + }; + + assert_eq!(ws.provider_kind, KanbanProviderType::Linear); + assert_eq!(ws.workspace_key, "acme"); + assert!(ws.prefetched_projects.is_some()); + assert_eq!(ws.prefetched_projects.as_ref().unwrap().len(), 1); + + match &ws.extra { + WorkspaceExtra::Linear => {} // ok + _ => panic!("expected Linear extra"), + } + } + + #[test] + fn test_discovered_project_fields() { + let project = DiscoveredProject { + workspace_key: "my-org".to_string(), + project_key: "PVT_abc".to_string(), + project_display_name: "My Board".to_string(), + provider_url: Some("https://github.com/orgs/my-org/projects/1".to_string()), + provider_native_id: Some("PVT_abc".to_string()), + }; + + assert_eq!(project.workspace_key, "my-org"); + assert_eq!(project.project_key, "PVT_abc"); + assert_eq!(project.project_display_name, "My Board"); + assert_eq!( + project.provider_url, + Some("https://github.com/orgs/my-org/projects/1".to_string()) + ); + assert_eq!(project.provider_native_id, Some("PVT_abc".to_string())); + } + + #[test] + fn test_discovered_project_minimal() { + let project = DiscoveredProject { + workspace_key: "acme.atlassian.net".to_string(), + project_key: "PROJ".to_string(), + project_display_name: "Project".to_string(), + provider_url: None, + provider_native_id: None, + }; + + assert!(project.provider_url.is_none()); + assert!(project.provider_native_id.is_none()); + } +} diff --git a/src/backstage/analyzer.rs b/src/backstage/analyzer.rs index b845294..1069356 100644 --- a/src/backstage/analyzer.rs +++ b/src/backstage/analyzer.rs @@ -33,6 +33,7 @@ //! let json = serde_json::to_string_pretty(&analysis)?; //! ``` +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; @@ -41,7 +42,7 @@ use std::path::Path; /// /// This is the top-level structure that conforms to `project_analysis.schema.json`. /// Claude fills this structure during the ASSESS issuetype's analyze step. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct ProjectAnalysis { /// Project directory name pub project_name: String, @@ -104,7 +105,7 @@ pub struct ProjectAnalysis { /// /// Foundation tier projects are pure infrastructure with no application-level code. /// Noncurrent tier projects are low-importance repos where detailed analysis is skipped. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct KindAssessment { /// Primary detected Kind key (e.g., "microservice", "ui-frontend") pub primary_kind: String, @@ -124,7 +125,7 @@ pub struct KindAssessment { } /// Alternative Kind candidate with confidence score. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct KindCandidate { /// Kind key pub kind: String, @@ -140,7 +141,7 @@ pub struct KindCandidate { /// /// Supports both known languages (rust, typescript, python, etc.) and /// unknown/emerging languages via free-form string. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct LanguageDetection { /// Language identifier (e.g., "rust", "typescript", "python") pub language: String, @@ -164,7 +165,7 @@ pub struct LanguageDetection { /// Framework/library detection result. /// /// Supports both known frameworks and unknown/custom frameworks. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct FrameworkDetection { /// Framework identifier (e.g., "axum", "react", "django") pub framework: String, @@ -187,7 +188,7 @@ pub struct FrameworkDetection { } /// Framework categories for classification. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum FrameworkCategory { /// Web frameworks (Axum, Express, Django, etc.) @@ -217,7 +218,7 @@ pub enum FrameworkCategory { /// Database detection result. /// /// Supports both known databases and unknown/custom databases. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct DatabaseDetection { /// Database identifier (e.g., "postgres", "mongodb", "redis") pub database: String, @@ -240,7 +241,7 @@ pub struct DatabaseDetection { } /// Database categories for classification. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum DatabaseCategory { /// SQL databases (`PostgreSQL`, `MySQL`, `SQLite`) @@ -262,7 +263,7 @@ pub enum DatabaseCategory { } /// Docker configuration detection. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct DockerDetection { /// Whether Dockerfile exists pub has_dockerfile: bool, @@ -281,7 +282,7 @@ pub struct DockerDetection { } /// Docker base image information. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct DockerImage { /// Image name (e.g., "rust", "node", "postgres") pub image: String, @@ -296,7 +297,7 @@ pub struct DockerImage { } /// Port detection result. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct PortDetection { /// Port type category pub port_type: PortType, @@ -317,7 +318,7 @@ pub struct PortDetection { } /// Port type categories. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PortType { /// HTTP server port (typically 80, 8080, 3000) @@ -343,7 +344,7 @@ pub enum PortType { } /// Test framework detection result. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct TestFrameworkDetection { /// Framework identifier (e.g., "`cargo_test`", "jest", "pytest") pub framework: String, @@ -362,7 +363,7 @@ pub struct TestFrameworkDetection { } /// Test categories for classification. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum TestCategory { /// Unit tests @@ -382,7 +383,7 @@ pub enum TestCategory { /// Evidence supporting a detection. /// /// Provides explainability for why something was detected. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct Evidence { /// Type of evidence pub evidence_type: EvidenceType, @@ -405,7 +406,7 @@ pub struct Evidence { } /// Types of evidence for detections. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum EvidenceType { /// File exists at expected path @@ -425,7 +426,7 @@ pub enum EvidenceType { } /// File statistics providing context for the analysis. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct FileStats { /// Total number of files analyzed pub total_files: usize, @@ -444,7 +445,7 @@ pub struct FileStats { /// /// These commands are detected from package.json scripts, Makefile, Cargo.toml, /// or other project configuration files. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] pub struct Commands { /// Command to start the application (e.g., "cargo run", "npm start") #[serde(default, skip_serializing_if = "Option::is_none")] @@ -476,7 +477,7 @@ pub struct Commands { } /// Purpose categories for entry points. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum EntryPointPurpose { /// Main binary entry point (e.g., src/main.rs, index.js) @@ -497,7 +498,7 @@ pub enum EntryPointPurpose { /// /// Entry points help AI agents understand where to start when exploring /// or modifying specific aspects of the project. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct EntryPoint { /// Relative path from project root pub file: String, @@ -509,7 +510,7 @@ pub struct EntryPoint { /// An environment variable used by the project. /// /// Detected from .env.example, docker-compose.yml, config files, or code. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct EnvVar { /// Environment variable name (e.g., "`DATABASE_URL`") pub name: String, diff --git a/src/config.rs b/src/config.rs index 749b9ea..70c3914 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,10 @@ pub struct Config { /// Agent delegator configurations for autonomous ticket launching #[serde(default)] pub delegators: Vec, + /// 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. + #[serde(default)] + pub model_servers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -916,6 +920,63 @@ pub struct Delegator { /// Optional launch configuration #[serde(default)] pub launch_config: Option, + /// Name of a declared `ModelServer` (from `Config.model_servers`). + /// `None` means use the `llm_tool`'s implicit vendor default + /// (claude → anthropic-api, codex → openai-api, gemini → google-api). + #[serde(default)] + pub model_server: Option, +} + +/// A named host that serves models via an inference API. +/// +/// Model servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI +/// (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint +/// (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). +/// +/// Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are +/// returned by [`implicit_model_server_for_tool`] and do not need to be declared +/// in config. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServer { + /// Unique name (e.g., "ollama-local", "vllm-gpu1") + pub name: String, + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint (e.g., `http://localhost:11434`). + /// `None` for implicit vendor servers means use the SDK default. + #[serde(default)] + pub base_url: Option, + /// Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + #[serde(default)] + pub api_key_env: Option, + /// Additional environment variables set when spawning agents that use this server + #[serde(default)] + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, +} + +/// Returns the implicit builtin `ModelServer` associated with a given `llm_tool`. +/// +/// Used when a `Delegator` has no explicit `model_server`. Unknown tools +/// fall back to an `"openai-api"` server so arbitrary future tools still resolve. +pub fn implicit_model_server_for_tool(tool: &str) -> ModelServer { + let (name, kind) = match tool { + "claude" => ("anthropic-api", "anthropic-api"), + "codex" => ("openai-api", "openai-api"), + "gemini" => ("google-api", "google-api"), + _ => ("openai-api", "openai-api"), + }; + ModelServer { + name: name.to_string(), + kind: kind.to_string(), + base_url: None, + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + } } /// Launch configuration for a delegator @@ -1313,6 +1374,40 @@ impl KanbanConfig { }, ); } + + /// Provider-neutral upsert dispatcher. + /// + /// Delegates to the provider-specific upsert method based on the + /// `WorkspaceExtra` variant in the validated workspace. + #[allow(dead_code)] // Will be used by onboarding service in Phase 1b + pub fn upsert_project( + &mut self, + workspace: &crate::api::providers::kanban::ValidatedWorkspace, + project: &crate::api::providers::kanban::DiscoveredProject, + ) { + use crate::api::providers::kanban::WorkspaceExtra; + match &workspace.extra { + WorkspaceExtra::Jira { email } => self.upsert_jira_project( + &workspace.workspace_key, + email, + &workspace.api_key_env, + &project.project_key, + &workspace.sync_user_id, + ), + WorkspaceExtra::Linear => self.upsert_linear_project( + &workspace.workspace_key, + &workspace.api_key_env, + &project.project_key, + &workspace.sync_user_id, + ), + WorkspaceExtra::Github => self.upsert_github_project( + &workspace.workspace_key, + &workspace.api_key_env, + &project.project_key, + &workspace.sync_user_id, + ), + } + } } /// Per-project/team sync configuration for a kanban provider @@ -1708,6 +1803,7 @@ impl Default for Config { kanban: KanbanConfig::default(), version_check: VersionCheckConfig::default(), delegators: Vec::new(), + model_servers: Vec::new(), } } } @@ -1750,6 +1846,7 @@ mod tests { flags: vec!["--verbose".to_string()], ..Default::default() }), + model_server: None, }; let json = serde_json::to_string(&delegator).unwrap(); @@ -1758,6 +1855,92 @@ mod tests { assert_eq!(parsed.llm_tool, "claude"); assert_eq!(parsed.model, "opus"); assert!(parsed.launch_config.unwrap().yolo); + assert!(parsed.model_server.is_none()); + } + + #[test] + fn test_model_server_toml_roundtrip() { + let toml_str = r#" + name = "ollama-local" + kind = "ollama" + base_url = "http://localhost:11434" + display_name = "Ollama (local)" + "#; + let server: ModelServer = toml::from_str(toml_str).unwrap(); + assert_eq!(server.name, "ollama-local"); + assert_eq!(server.kind, "ollama"); + assert_eq!(server.base_url.as_deref(), Some("http://localhost:11434")); + assert_eq!(server.display_name.as_deref(), Some("Ollama (local)")); + assert!(server.extra_env.is_empty()); + assert!(server.api_key_env.is_none()); + } + + #[test] + fn test_delegator_with_model_server_ref_roundtrip() { + let toml_str = r#" + name = "codex-local-qwen" + llm_tool = "codex" + model = "qwen2.5-coder" + model_server = "ollama-local" + "#; + let d: Delegator = toml::from_str(toml_str).unwrap(); + assert_eq!(d.name, "codex-local-qwen"); + assert_eq!(d.model_server.as_deref(), Some("ollama-local")); + } + + #[test] + fn test_delegator_without_model_server_field_still_parses() { + let toml_str = r#" + name = "claude-opus-auto" + llm_tool = "claude" + model = "opus" + "#; + let d: Delegator = toml::from_str(toml_str).unwrap(); + assert_eq!(d.name, "claude-opus-auto"); + assert!(d.model_server.is_none()); + } + + #[test] + fn test_implicit_model_server_for_known_tools() { + assert_eq!( + implicit_model_server_for_tool("claude").kind, + "anthropic-api" + ); + assert_eq!(implicit_model_server_for_tool("codex").kind, "openai-api"); + assert_eq!(implicit_model_server_for_tool("gemini").kind, "google-api"); + assert_eq!(implicit_model_server_for_tool("unknown").kind, "openai-api"); + } + + #[test] + fn test_config_without_model_servers_field_still_parses() { + let toml_str = r#" + [agents] + max_parallel = 1 + cores_reserved = 0 + health_check_interval = 5 + [notifications] + enabled = false + [queue] + auto_assign = true + priority_order = [] + poll_interval_ms = 1000 + [paths] + tickets = ".tickets" + projects = "." + state = ".tickets/operator" + worktrees = ".worktrees" + [ui] + refresh_rate_ms = 100 + completed_history_hours = 1 + summary_max_length = 40 + [launch] + confirm_autonomous = false + confirm_paired = false + launch_delay_ms = 0 + [templates] + "#; + let cfg: Config = toml::from_str(toml_str).unwrap(); + assert!(cfg.model_servers.is_empty()); } #[test] @@ -2068,4 +2251,118 @@ mod tests { "OPERATOR_JIRA_SECOND_API_KEY" ); } + + #[test] + fn test_upsert_project_jira() { + use crate::api::providers::kanban::{ + DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, + }; + + let mut kanban = KanbanConfig::default(); + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Jira, + workspace_key: "acme.atlassian.net".to_string(), + workspace_display_name: "Acme Corp".to_string(), + sync_user_id: "acct-123".to_string(), + sync_user_display_name: "Alice".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Jira { + email: "alice@acme.com".to_string(), + }, + }; + let project = DiscoveredProject { + workspace_key: "acme.atlassian.net".to_string(), + project_key: "PROJ".to_string(), + project_display_name: "My Project".to_string(), + provider_url: None, + provider_native_id: None, + }; + + kanban.upsert_project(&ws, &project); + + let entry = kanban + .jira + .get("acme.atlassian.net") + .expect("workspace should be created"); + assert!(entry.enabled); + assert_eq!(entry.email, "alice@acme.com"); + assert_eq!(entry.api_key_env, "OPERATOR_JIRA_API_KEY"); + let proj = entry.projects.get("PROJ").expect("project should exist"); + assert_eq!(proj.sync_user_id, "acct-123"); + } + + #[test] + fn test_upsert_project_linear() { + use crate::api::providers::kanban::{ + DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, + }; + + let mut kanban = KanbanConfig::default(); + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Linear, + workspace_key: "acme".to_string(), + workspace_display_name: "Acme Inc".to_string(), + sync_user_id: "user-uuid-1".to_string(), + sync_user_display_name: "Bob".to_string(), + api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Linear, + }; + let project = DiscoveredProject { + workspace_key: "acme".to_string(), + project_key: "ENG".to_string(), + project_display_name: "Engineering".to_string(), + provider_url: None, + provider_native_id: None, + }; + + kanban.upsert_project(&ws, &project); + + let entry = kanban + .linear + .get("acme") + .expect("workspace should be created"); + assert!(entry.enabled); + assert_eq!(entry.api_key_env, "OPERATOR_LINEAR_API_KEY"); + let proj = entry.projects.get("ENG").expect("project should exist"); + assert_eq!(proj.sync_user_id, "user-uuid-1"); + } + + #[test] + fn test_upsert_project_github() { + use crate::api::providers::kanban::{ + DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, + }; + + let mut kanban = KanbanConfig::default(); + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Github, + workspace_key: "my-org".to_string(), + workspace_display_name: "github.com".to_string(), + sync_user_id: "12345678".to_string(), + sync_user_display_name: "octocat".to_string(), + api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Github, + }; + let project = DiscoveredProject { + workspace_key: "my-org".to_string(), + project_key: "PVT_abc".to_string(), + project_display_name: "My Board".to_string(), + provider_url: None, + provider_native_id: None, + }; + + kanban.upsert_project(&ws, &project); + + let entry = kanban + .github + .get("my-org") + .expect("workspace should be created"); + assert!(entry.enabled); + assert_eq!(entry.api_key_env, "OPERATOR_GITHUB_TOKEN"); + let proj = entry.projects.get("PVT_abc").expect("project should exist"); + assert_eq!(proj.sync_user_id, "12345678"); + } } diff --git a/src/docs_gen/issuetype.rs b/src/docs_gen/issuetype.rs index 2f24912..99a8092 100644 --- a/src/docs_gen/issuetype.rs +++ b/src/docs_gen/issuetype.rs @@ -1,13 +1,15 @@ //! Documentation generator for the issuetype schema. +//! +//! Generates human-readable markdown documentation from the `TemplateSchema` type +//! via schemars, making Rust the single source of truth. use super::markdown::{bold, bullet_list, heading, inline_code, table}; use super::{format_header, DocGenerator}; +use crate::templates::schema::TemplateSchema; use anyhow::Result; +use schemars::schema_for; use serde_json::Value; -/// Schema JSON embedded at compile time -const ISSUETYPE_SCHEMA: &str = include_str!("../schemas/issuetype_schema.json"); - /// Generates documentation from `issuetype_schema.json` pub struct IssuetypeSchemaDocGenerator; @@ -17,7 +19,7 @@ impl DocGenerator for IssuetypeSchemaDocGenerator { } fn source(&self) -> &'static str { - "src/schemas/issuetype_schema.json" + "src/templates/schema.rs (TemplateSchema)" } fn output_path(&self) -> &'static str { @@ -25,7 +27,8 @@ impl DocGenerator for IssuetypeSchemaDocGenerator { } fn generate(&self) -> Result { - let schema: Value = serde_json::from_str(ISSUETYPE_SCHEMA)?; + let root_schema = schema_for!(TemplateSchema); + let schema: Value = serde_json::to_value(&root_schema)?; let mut output = format_header("Issue Type Schema", self.source()); // Title and description @@ -171,7 +174,11 @@ impl IssuetypeSchemaDocGenerator { fn generate_definitions_section(&self, schema: &Value) -> String { let mut output = String::new(); - if let Some(definitions) = schema.get("definitions").and_then(|d| d.as_object()) { + if let Some(definitions) = schema + .get("$defs") + .or_else(|| schema.get("definitions")) + .and_then(|d| d.as_object()) + { for (name, def) in definitions { output.push_str(&heading(3, &format!("Definition: {name}"))); @@ -269,7 +276,7 @@ mod tests { // Should have the auto-generated header assert!(result.contains("AUTO-GENERATED FROM")); - assert!(result.contains("issuetype_schema.json")); + assert!(result.contains("TemplateSchema")); // Should have the main heading assert!(result.contains("# Issue Type Schema")); @@ -284,14 +291,12 @@ mod tests { // Should have definitions section assert!(result.contains("## Definitions")); - assert!(result.contains("### Definition: field")); - assert!(result.contains("### Definition: step")); } #[test] - fn test_schema_parses_successfully() { - let schema: Value = serde_json::from_str(ISSUETYPE_SCHEMA).unwrap(); - assert!(schema.get("properties").is_some()); - assert!(schema.get("definitions").is_some()); + fn test_schema_generates_successfully() { + let schema = schema_for!(TemplateSchema); + let schema_value = serde_json::to_value(&schema).unwrap(); + assert!(schema_value.get("properties").is_some()); } } diff --git a/src/docs_gen/issuetype_json_schema.rs b/src/docs_gen/issuetype_json_schema.rs new file mode 100644 index 0000000..50ad516 --- /dev/null +++ b/src/docs_gen/issuetype_json_schema.rs @@ -0,0 +1,133 @@ +//! JSON Schema generator for the issuetype template types. +//! +//! Generates `docs/schemas/issuetype_template.json` from the Rust `TemplateSchema` struct +//! via schemars, making Rust the single source of truth for the issuetype file format. + +use super::DocGenerator; +use crate::templates::schema::TemplateSchema; +use anyhow::Result; +use schemars::schema_for; + +/// Generates JSON Schema from the `TemplateSchema` Rust type +pub struct IssuetypeJsonSchemaDocGenerator; + +impl DocGenerator for IssuetypeJsonSchemaDocGenerator { + fn name(&self) -> &'static str { + "issuetype-json-schema" + } + + fn source(&self) -> &'static str { + "src/templates/schema.rs (TemplateSchema)" + } + + fn output_path(&self) -> &'static str { + "../src/schemas/issuetype_schema.json" + } + + fn generate(&self) -> Result { + let schema = schema_for!(TemplateSchema); + let mut schema_value = serde_json::to_value(&schema)?; + + // Add metadata to match the hand-written schema conventions + if let Some(obj) = schema_value.as_object_mut() { + obj.insert( + "$schema".to_string(), + serde_json::Value::String("http://json-schema.org/draft-07/schema#".to_string()), + ); + obj.insert( + "$id".to_string(), + serde_json::Value::String( + "https://gbqr.us/operator/issuetype-template.schema.json".to_string(), + ), + ); + obj.insert( + "$comment".to_string(), + serde_json::Value::String( + "AUTO-GENERATED FROM src/templates/schema.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only issuetype-json-schema".to_string(), + ), + ); + } + + let json = serde_json::to_string_pretty(&schema_value)?; + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_produces_valid_json_schema() { + let generator = IssuetypeJsonSchemaDocGenerator; + let result = generator.generate().unwrap(); + + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Should have schema metadata + assert_eq!( + schema.get("$schema").and_then(|s| s.as_str()), + Some("http://json-schema.org/draft-07/schema#") + ); + assert!(schema.get("$id").is_some()); + + // Should have properties for TemplateSchema fields + let properties = schema.get("properties").expect("should have properties"); + assert!(properties.get("key").is_some(), "missing 'key' property"); + assert!(properties.get("name").is_some(), "missing 'name' property"); + assert!( + properties.get("description").is_some(), + "missing 'description' property" + ); + assert!(properties.get("mode").is_some(), "missing 'mode' property"); + assert!( + properties.get("glyph").is_some(), + "missing 'glyph' property" + ); + assert!( + properties.get("fields").is_some(), + "missing 'fields' property" + ); + assert!( + properties.get("steps").is_some(), + "missing 'steps' property" + ); + } + + #[test] + fn test_required_fields() { + let generator = IssuetypeJsonSchemaDocGenerator; + let result = generator.generate().unwrap(); + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // key, name, description, mode, glyph, fields, steps should be required + let required = schema + .get("required") + .and_then(|r| r.as_array()) + .expect("should have required array"); + + let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(required_strs.contains(&"key"), "key should be required"); + assert!(required_strs.contains(&"name"), "name should be required"); + assert!(required_strs.contains(&"mode"), "mode should be required"); + assert!(required_strs.contains(&"glyph"), "glyph should be required"); + assert!( + required_strs.contains(&"fields"), + "fields should be required" + ); + assert!(required_strs.contains(&"steps"), "steps should be required"); + } + + #[test] + fn test_schema_has_definitions() { + let generator = IssuetypeJsonSchemaDocGenerator; + let result = generator.generate().unwrap(); + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Should have $defs or definitions for sub-types + assert!( + schema.get("$defs").is_some() || schema.get("definitions").is_some(), + "Schema should have definitions for sub-types like StepSchema, FieldSchema" + ); + } +} diff --git a/src/docs_gen/mod.rs b/src/docs_gen/mod.rs index 4c4f9f8..4cf68a4 100644 --- a/src/docs_gen/mod.rs +++ b/src/docs_gen/mod.rs @@ -17,11 +17,14 @@ pub mod cli; pub mod config; pub mod config_schema; pub mod issuetype; +pub mod issuetype_json_schema; pub mod jira_api; pub mod llm_tools; pub mod markdown; pub mod metadata; pub mod openapi; +pub mod operator_output_schema; +pub mod project_analysis_schema; pub mod schema_index; pub mod shortcuts; pub mod startup; @@ -92,6 +95,9 @@ pub fn generate_all(docs_dir: &Path) -> Result<()> { Box::new(state_schema::StateSchemaDocGenerator), Box::new(schema_index::SchemaIndexDocGenerator), Box::new(jira_api::JiraApiDocGenerator), + Box::new(operator_output_schema::OperatorOutputSchemaDocGenerator), + Box::new(issuetype_json_schema::IssuetypeJsonSchemaDocGenerator), + Box::new(project_analysis_schema::ProjectAnalysisSchemaDocGenerator), ]; for generator in generators { diff --git a/src/docs_gen/operator_output_schema.rs b/src/docs_gen/operator_output_schema.rs new file mode 100644 index 0000000..52acb24 --- /dev/null +++ b/src/docs_gen/operator_output_schema.rs @@ -0,0 +1,99 @@ +//! JSON Schema generator for the `OperatorOutput` type. +//! +//! Generates `docs/schemas/operator_output.json` from the Rust `OperatorOutput` struct +//! via schemars, making Rust the single source of truth. + +use super::DocGenerator; +use crate::rest::dto::OperatorOutput; +use anyhow::Result; +use schemars::schema_for; + +/// Generates JSON Schema from the `OperatorOutput` Rust type +pub struct OperatorOutputSchemaDocGenerator; + +impl DocGenerator for OperatorOutputSchemaDocGenerator { + fn name(&self) -> &'static str { + "operator-output-schema" + } + + fn source(&self) -> &'static str { + "src/rest/dto.rs (OperatorOutput)" + } + + fn output_path(&self) -> &'static str { + "schemas/operator_output.json" + } + + fn generate(&self) -> Result { + let schema = schema_for!(OperatorOutput); + let mut schema_value = serde_json::to_value(&schema)?; + + // Add metadata to match the hand-written schema + if let Some(obj) = schema_value.as_object_mut() { + obj.insert( + "$schema".to_string(), + serde_json::Value::String("http://json-schema.org/draft-07/schema#".to_string()), + ); + obj.insert( + "$id".to_string(), + serde_json::Value::String( + "https://operator.dev/schemas/operator_output.schema.json".to_string(), + ), + ); + obj.insert( + "$comment".to_string(), + serde_json::Value::String( + "AUTO-GENERATED FROM src/rest/dto.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only operator-output-schema".to_string(), + ), + ); + } + + let json = serde_json::to_string_pretty(&schema_value)?; + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_produces_valid_json_schema() { + let generator = OperatorOutputSchemaDocGenerator; + let result = generator.generate().unwrap(); + + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Should have schema metadata + assert_eq!( + schema.get("$schema").and_then(|s| s.as_str()), + Some("http://json-schema.org/draft-07/schema#") + ); + assert!(schema.get("$id").is_some()); + + // Should have properties for OperatorOutput fields + let properties = schema.get("properties").expect("should have properties"); + assert!(properties.get("status").is_some()); + assert!(properties.get("exit_signal").is_some()); + assert!(properties.get("confidence").is_some()); + assert!(properties.get("summary").is_some()); + assert!(properties.get("blockers").is_some()); + } + + #[test] + fn test_required_fields() { + let generator = OperatorOutputSchemaDocGenerator; + let result = generator.generate().unwrap(); + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // status and exit_signal should be required + let required = schema + .get("required") + .and_then(|r| r.as_array()) + .expect("should have required array"); + + let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(required_strs.contains(&"status")); + assert!(required_strs.contains(&"exit_signal")); + } +} diff --git a/src/docs_gen/project_analysis_schema.rs b/src/docs_gen/project_analysis_schema.rs new file mode 100644 index 0000000..3cddd1e --- /dev/null +++ b/src/docs_gen/project_analysis_schema.rs @@ -0,0 +1,115 @@ +//! JSON Schema generator for the `ProjectAnalysis` type. +//! +//! Generates `docs/schemas/project_analysis.json` from the Rust `ProjectAnalysis` struct +//! via schemars, making Rust the single source of truth for structured output. + +use super::DocGenerator; +use crate::backstage::analyzer::ProjectAnalysis; +use anyhow::Result; +use schemars::schema_for; + +/// Generates JSON Schema from the `ProjectAnalysis` Rust type +pub struct ProjectAnalysisSchemaDocGenerator; + +impl DocGenerator for ProjectAnalysisSchemaDocGenerator { + fn name(&self) -> &'static str { + "project-analysis-schema" + } + + fn source(&self) -> &'static str { + "src/backstage/analyzer.rs (ProjectAnalysis)" + } + + fn output_path(&self) -> &'static str { + "schemas/project_analysis.json" + } + + fn generate(&self) -> Result { + let schema = schema_for!(ProjectAnalysis); + let mut schema_value = serde_json::to_value(&schema)?; + + // Add metadata to match the hand-written schema conventions + if let Some(obj) = schema_value.as_object_mut() { + obj.insert( + "$schema".to_string(), + serde_json::Value::String("http://json-schema.org/draft-07/schema#".to_string()), + ); + obj.insert( + "$id".to_string(), + serde_json::Value::String( + "https://gbqr.us/operator/project-analysis.schema.json".to_string(), + ), + ); + obj.insert( + "$comment".to_string(), + serde_json::Value::String( + "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema".to_string(), + ), + ); + } + + let json = serde_json::to_string_pretty(&schema_value)?; + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_produces_valid_json_schema() { + let generator = ProjectAnalysisSchemaDocGenerator; + let result = generator.generate().unwrap(); + + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Should have schema metadata + assert_eq!( + schema.get("$schema").and_then(|s| s.as_str()), + Some("http://json-schema.org/draft-07/schema#") + ); + assert!(schema.get("$id").is_some()); + + // Should have properties for ProjectAnalysis fields + let properties = schema.get("properties").expect("should have properties"); + assert!(properties.get("project_name").is_some()); + assert!(properties.get("project_path").is_some()); + assert!(properties.get("languages").is_some()); + assert!(properties.get("frameworks").is_some()); + assert!(properties.get("databases").is_some()); + assert!(properties.get("docker").is_some()); + assert!(properties.get("testing").is_some()); + assert!(properties.get("commands").is_some()); + } + + #[test] + fn test_required_fields() { + let generator = ProjectAnalysisSchemaDocGenerator; + let result = generator.generate().unwrap(); + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + let required = schema + .get("required") + .and_then(|r| r.as_array()) + .expect("should have required array"); + + let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(required_strs.contains(&"project_name")); + assert!(required_strs.contains(&"kind_assessment")); + assert!(required_strs.contains(&"languages")); + assert!(required_strs.contains(&"frameworks")); + } + + #[test] + fn test_schema_has_definitions() { + let generator = ProjectAnalysisSchemaDocGenerator; + let result = generator.generate().unwrap(); + let schema: serde_json::Value = serde_json::from_str(&result).unwrap(); + + assert!( + schema.get("$defs").is_some() || schema.get("definitions").is_some(), + "Schema should have definitions for sub-types" + ); + } +} diff --git a/src/issuetypes/schema.rs b/src/issuetypes/schema.rs index 99a08b5..4d3900c 100644 --- a/src/issuetypes/schema.rs +++ b/src/issuetypes/schema.rs @@ -1,5 +1,6 @@ //! Schema definitions for dynamic issue types +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::templates::schema::{ @@ -7,7 +8,7 @@ use crate::templates::schema::{ }; /// Source of an issue type definition -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum IssueTypeSource { /// Built-in to operator binary @@ -26,7 +27,7 @@ pub enum IssueTypeSource { } /// An issue type definition (dynamic version of `TemplateSchema`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct IssueType { /// Unique issuetype key (e.g., FEAT, FIX, STORY, BUG) pub key: String, @@ -156,6 +157,7 @@ impl IssueType { steps: vec![StepSchema { name: "execute".to_string(), display_name: Some("Execute".to_string()), + step_type: crate::templates::schema::StepTypeTag::Task, outputs: vec![], prompt: "Execute this task according to the description.".to_string(), allowed_tools: vec!["*".to_string()], @@ -170,6 +172,13 @@ impl IssueType { json_schema_file: None, artifact_patterns: vec![], agent: None, + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, }], agent_prompt: None, agent: None, @@ -325,6 +334,7 @@ mod tests { steps: vec![StepSchema { name: "execute".to_string(), display_name: Some("Execute".to_string()), + step_type: crate::templates::schema::StepTypeTag::Task, outputs: vec![], prompt: "Do the task".to_string(), allowed_tools: vec!["*".to_string()], @@ -339,6 +349,13 @@ mod tests { json_schema_file: None, artifact_patterns: vec![], agent: None, + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, }], agent_prompt: None, agent: None, diff --git a/src/lib.rs b/src/lib.rs index 058f831..3e6fc02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ mod pr_config; mod projects; mod services; mod startup; +mod steps; mod templates; pub mod version; diff --git a/src/main.rs b/src/main.rs index c20e5ce..627c3e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,6 +144,24 @@ enum Commands { /// Skip confirmation prompt #[arg(short = 'y', long)] yes: bool, + + /// Use a named delegator from config (mutually exclusive with --llm-tool/--model/--model-server) + #[arg(long)] + delegator: Option, + + /// LLM tool override: claude, codex, gemini + #[arg(long = "llm-tool")] + llm_tool: Option, + + /// Model override (e.g., opus, gpt-4o, qwen2.5-coder) + #[arg(long)] + model: Option, + + /// 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. + #[arg(long = "model-server")] + model_server: Option, }, /// List active agents @@ -263,8 +281,26 @@ async fn main() -> Result<()> { Some(Commands::Queue { all }) => { cmd_queue(&config, all).await?; } - Some(Commands::Launch { ticket, yes }) => { - cmd_launch(&config, ticket, yes).await?; + Some(Commands::Launch { + ticket, + yes, + delegator, + llm_tool, + model, + model_server, + }) => { + cmd_launch( + &config, + ticket, + yes, + LaunchOverrides { + delegator, + llm_tool, + model, + model_server, + }, + ) + .await?; } Some(Commands::Agents { verbose }) => { cmd_agents(&config, verbose).await?; @@ -381,7 +417,51 @@ async fn cmd_queue(config: &Config, all: bool) -> Result<()> { Ok(()) } -async fn cmd_launch(config: &Config, ticket: Option, skip_confirm: bool) -> Result<()> { +/// Ad-hoc launch overrides parsed from CLI flags. +/// +/// v1: flags parse and validate; env-var injection for `model_server` ships in v2. +#[derive(Debug, Default)] +struct LaunchOverrides { + delegator: Option, + llm_tool: Option, + model: Option, + model_server: Option, +} + +async fn cmd_launch( + config: &Config, + ticket: Option, + skip_confirm: bool, + overrides: LaunchOverrides, +) -> Result<()> { + // Validate CLI overrides up front so bad input doesn't get swallowed later. + // Named model_server must exist among declared servers or implicit builtins. + if let Some(ref name) = overrides.model_server { + let declared = config.model_servers.iter().any(|s| &s.name == name); + let implicit = ["claude", "codex", "gemini"] + .iter() + .any(|t| &config::implicit_model_server_for_tool(t).name == name); + if !declared && !implicit { + anyhow::bail!( + "Unknown model-server '{name}'. Declare it under [[model_servers]] in your config." + ); + } + } + // --delegator and ad-hoc (--llm-tool / --model / --model-server) are mutually exclusive. + let has_adhoc = overrides.llm_tool.is_some() + || overrides.model.is_some() + || overrides.model_server.is_some(); + if overrides.delegator.is_some() && has_adhoc { + anyhow::bail!( + "--delegator is mutually exclusive with --llm-tool / --model / --model-server" + ); + } + + // TODO(model-servers-v2): thread `overrides` through resolve_launch_options() so the chosen + // model_server's env vars (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, etc.) are exported before the + // agent CLI spawns. v1 parses and validates; resolution path is unchanged. + let _ = &overrides; + // Check tmux availability before launching if let Err(err) = check_tmux_available() { print_tmux_error(&err); @@ -590,8 +670,9 @@ async fn cmd_create( fn cmd_docs(_config: &Config, output: Option, only: Option) -> Result<()> { use docs_gen::{ - cli, config, config_schema, issuetype, jira_api, metadata, openapi, schema_index, - shortcuts, startup, state_schema, taxonomy, DocGenerator, + cli, config, config_schema, issuetype, issuetype_json_schema, jira_api, metadata, openapi, + operator_output_schema, project_analysis_schema, schema_index, shortcuts, startup, + state_schema, taxonomy, DocGenerator, }; use std::path::PathBuf; @@ -641,9 +722,24 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Some("jira-api") => { vec![Box::new(jira_api::JiraApiDocGenerator)] } + Some("operator-output-schema") => { + vec![Box::new( + operator_output_schema::OperatorOutputSchemaDocGenerator, + )] + } + Some("issuetype-json-schema") => { + vec![Box::new( + issuetype_json_schema::IssuetypeJsonSchemaDocGenerator, + )] + } + Some("project-analysis-schema") => { + vec![Box::new( + project_analysis_schema::ProjectAnalysisSchemaDocGenerator, + )] + } Some(other) => { println!( - "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api" + "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema" ); return Ok(()); } @@ -662,6 +758,9 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Box::new(state_schema::StateSchemaDocGenerator), Box::new(schema_index::SchemaIndexDocGenerator), Box::new(jira_api::JiraApiDocGenerator), + Box::new(operator_output_schema::OperatorOutputSchemaDocGenerator), + Box::new(issuetype_json_schema::IssuetypeJsonSchemaDocGenerator), + Box::new(project_analysis_schema::ProjectAnalysisSchemaDocGenerator), ] } }; diff --git a/src/permissions/mod.rs b/src/permissions/mod.rs index 858a058..fc1ad3a 100644 --- a/src/permissions/mod.rs +++ b/src/permissions/mod.rs @@ -48,11 +48,12 @@ pub use codex::CodexTranslator; pub use gemini::GeminiTranslator; pub use translator::TranslatorManager; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Provider-agnostic tool pattern -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] pub struct ToolPattern { /// Tool name: Read, Write, Edit, Bash, Glob, Grep, `WebFetch`, etc. pub tool: String, @@ -80,7 +81,7 @@ impl ToolPattern { } /// Tool-level permissions (allow/deny lists) -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct ToolPermissions { /// Tools/patterns to allow #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -91,7 +92,7 @@ pub struct ToolPermissions { } /// Directory-level permissions -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct DirectoryPermissions { /// Additional directories to allow access to (glob patterns) #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -102,7 +103,7 @@ pub struct DirectoryPermissions { } /// MCP server permissions (server-level enable/disable only) -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct McpServerPermissions { /// MCP servers to enable for this step #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -113,7 +114,7 @@ pub struct McpServerPermissions { } /// Per-provider custom configuration flags -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct CustomFlags { /// Claude-specific configuration flags #[serde(default, skip_serializing_if = "HashMap::is_empty")] @@ -127,7 +128,7 @@ pub struct CustomFlags { } /// Complete permission set for a step (as defined in issuetype schema) -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct StepPermissions { /// Tool-level allow/deny lists #[serde(default, skip_serializing_if = "is_default")] @@ -144,7 +145,7 @@ pub struct StepPermissions { } /// Arbitrary CLI arguments per provider -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct ProviderCliArgs { /// CLI arguments for Claude #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index fe226c6..0ae8be0 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -339,11 +339,13 @@ impl Ticket { let current_step = self.current_step_schema(); if let Some(step) = current_step { if let Some(next_name) = step.next_step { - // Look up next step's agent override + // Look up next step's effective agent (accounts for step type configs) let switch_agent = self .template_schema() .and_then(|t| t.get_step(&next_name).cloned()) - .and_then(|s| s.agent); + .and_then(|s| { + crate::templates::step_type::effective_agent(&s).map(String::from) + }); self.update_field("step", &next_name)?; return Ok(StepAdvanceResult::Advanced { diff --git a/src/rest/dto.rs b/src/rest/dto.rs index 5ad1437..dbf384e 100644 --- a/src/rest/dto.rs +++ b/src/rest/dto.rs @@ -365,6 +365,7 @@ impl From for StepSchema { Self { name: s.name, display_name: s.display_name, + step_type: crate::templates::schema::StepTypeTag::Task, prompt: s.prompt, outputs: s .outputs @@ -403,6 +404,13 @@ impl From for StepSchema { json_schema_file: None, artifact_patterns: vec![], agent: None, + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, } } } @@ -1311,6 +1319,9 @@ pub struct DelegatorResponse { pub display_name: Option, /// Arbitrary model properties pub model_properties: std::collections::HashMap, + /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_server: Option, /// Optional launch configuration #[serde(skip_serializing_if = "Option::is_none")] pub launch_config: Option, @@ -1332,6 +1343,9 @@ pub struct CreateDelegatorRequest { /// Arbitrary model properties #[serde(default)] pub model_properties: std::collections::HashMap, + /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + #[serde(default)] + pub model_server: Option, /// Optional launch configuration #[serde(default)] pub launch_config: Option, @@ -1399,11 +1413,73 @@ pub struct CreateDelegatorFromToolRequest { /// Optional display name for UI #[serde(default)] pub display_name: Option, + /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + #[serde(default)] + pub model_server: Option, /// Optional launch configuration #[serde(default)] pub launch_config: Option, } +// ============================================================================= +// Model Server DTOs +// ============================================================================= + +/// Response for a single model server +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServerResponse { + /// Unique name (e.g., "ollama-local") + pub name: String, + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint (e.g., `http://localhost:11434`) + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_env: Option, + /// Additional environment variables set when spawning agents that use this server + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Whether this is a user-declared server (true) or an implicit builtin (false) + pub user_declared: bool, +} + +/// Response listing all model servers (declared + implicit builtins) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServersResponse { + /// List of model servers + pub servers: Vec, + /// Total count + pub total: usize, +} + +/// Request to create a new model server +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateModelServerRequest { + /// Unique name for this model server + pub name: String, + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint + #[serde(default)] + pub base_url: Option, + /// Name of an env var providing the API key + #[serde(default)] + pub api_key_env: Option, + /// Additional environment variables + #[serde(default)] + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, +} + // ============================================================================= // LLM Tools DTOs // ============================================================================= diff --git a/src/rest/mod.rs b/src/rest/mod.rs index e50aa3b..a45b48e 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -159,6 +159,17 @@ pub fn build_router(state: ApiState) -> Router { "/api/v1/delegators/:name", delete(routes::delegators::delete), ) + // Model server endpoints + .route("/api/v1/model-servers", get(routes::model_servers::list)) + .route("/api/v1/model-servers", post(routes::model_servers::create)) + .route( + "/api/v1/model-servers/:name", + get(routes::model_servers::get_one), + ) + .route( + "/api/v1/model-servers/:name", + delete(routes::model_servers::delete), + ) // MCP endpoints .route( "/api/v1/mcp/descriptor", diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 5523c0a..80ff438 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -5,10 +5,11 @@ use utoipa::OpenApi; use crate::mcp::descriptor::McpDescriptorResponse; use crate::rest::dto::{ CollectionResponse, CreateDelegatorFromToolRequest, CreateDelegatorRequest, CreateFieldRequest, - CreateIssueTypeRequest, CreateStepRequest, DefaultLlmResponse, DelegatorLaunchConfigDto, - DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, IssueTypeResponse, - IssueTypeSummary, LaunchTicketRequest, LaunchTicketResponse, SetDefaultLlmRequest, SkillEntry, - SkillsResponse, StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + CreateIssueTypeRequest, CreateModelServerRequest, CreateStepRequest, DefaultLlmResponse, + DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, + IssueTypeResponse, IssueTypeSummary, LaunchTicketRequest, LaunchTicketResponse, + ModelServerResponse, ModelServersResponse, SetDefaultLlmRequest, SkillEntry, SkillsResponse, + StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, }; use crate::rest::error::ErrorResponse; @@ -59,6 +60,11 @@ use crate::rest::error::ErrorResponse; crate::rest::routes::llm_tools::list, crate::rest::routes::llm_tools::get_default, crate::rest::routes::llm_tools::set_default, + // Model server endpoints + crate::rest::routes::model_servers::list, + crate::rest::routes::model_servers::get_one, + crate::rest::routes::model_servers::create, + crate::rest::routes::model_servers::delete, // MCP endpoints crate::mcp::descriptor::descriptor, ), @@ -90,6 +96,10 @@ use crate::rest::error::ErrorResponse; CreateDelegatorRequest, CreateDelegatorFromToolRequest, DelegatorLaunchConfigDto, + // Model server types + ModelServerResponse, + ModelServersResponse, + CreateModelServerRequest, // LLM tools types SetDefaultLlmRequest, DefaultLlmResponse, @@ -105,6 +115,7 @@ use crate::rest::error::ErrorResponse; (name = "Launch", description = "Ticket launch operations"), (name = "Skills", description = "Skill discovery across LLM tools"), (name = "Delegators", description = "Agent delegator CRUD operations"), + (name = "ModelServers", description = "Model server (ollama, openai-compat, etc.) CRUD operations"), (name = "MCP", description = "Model Context Protocol integration"), ) )] diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 830a6b8..cfe17d7 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -92,6 +92,7 @@ pub async fn create( model: req.model, display_name: req.display_name, model_properties: req.model_properties, + model_server: req.model_server, launch_config: req.launch_config.map(dto_to_launch_config), }; @@ -177,6 +178,7 @@ fn delegator_to_response(d: &Delegator) -> DelegatorResponse { model: d.model.clone(), display_name: d.display_name.clone(), model_properties: d.model_properties.clone(), + model_server: d.model_server.clone(), launch_config: d.launch_config.as_ref().map(launch_config_to_dto), } } @@ -234,6 +236,7 @@ pub async fn create_from_tool( model, display_name: req.display_name, model_properties: std::collections::HashMap::new(), + model_server: req.model_server.clone(), launch_config: req.launch_config.map(dto_to_launch_config), }; @@ -277,6 +280,7 @@ pub async fn update( model: req.model, display_name: req.display_name, model_properties: req.model_properties, + model_server: req.model_server, launch_config: req.launch_config.map(dto_to_launch_config), }; @@ -317,6 +321,7 @@ mod tests { model: "opus".to_string(), display_name: Some("Test".to_string()), model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: None, }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); @@ -344,6 +349,7 @@ mod tests { model: "gpt-4o".to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: Some(DelegatorLaunchConfig { yolo: true, permission_mode: None, @@ -370,6 +376,7 @@ mod tests { model: "opus".to_string(), display_name: Some("Full Config".to_string()), model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: Some(DelegatorLaunchConfig { yolo: true, permission_mode: Some("accept-edits".to_string()), @@ -408,6 +415,7 @@ mod tests { model: None, name: None, display_name: None, + model_server: None, launch_config: None, }; diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 30f2e2e..5c413dd 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -18,6 +18,100 @@ use crate::rest::dto::{ use crate::rest::error::ApiError; use crate::rest::state::ApiState; +/// If the sub-agent identified by `request.session_id` (or by ticket fallback) +/// belongs to a multi-agent group, write its individual output artifact to +/// `{worktree}/.tickets/steps/{step_name}/{agent_id}.json` and return a +/// `group_partial` / `group_complete` response. Returns `Ok(None)` when this +/// is a normal single-agent completion and the caller should fall through to +/// existing logic. +fn handle_multi_agent_completion( + state: &ApiState, + ticket: &crate::queue::Ticket, + step_name: &str, + request: &StepCompleteRequest, +) -> Result, ApiError> { + let mut app_state = crate::state::State::load(&state.config) + .map_err(|e| ApiError::InternalError(e.to_string()))?; + + // Resolve the sub-agent: prefer session-id lookup, fall back to ticket. + let agent_id = request + .session_id + .as_deref() + .and_then(|sid| app_state.agent_by_session(sid)) + .or_else(|| app_state.agent_by_ticket(&ticket.id)) + .map(|a| a.id.clone()); + + let Some(agent_id) = agent_id else { + return Ok(None); + }; + + // If this agent is not in a group, fall through. + if app_state.get_group_for_agent(&agent_id).is_none() { + return Ok(None); + } + + // Build the per-sub-agent output payload from the POSTed OperatorOutput. + let output_payload = request + .output + .as_ref() + .map(|o| serde_json::to_value(o).unwrap_or(serde_json::Value::Null)) + .unwrap_or(serde_json::Value::Null); + + // Persist the per-sub-agent file — the sync loop picks it up. + crate::steps::manager::StepManager::write_agent_step_output( + ticket, + step_name, + &agent_id, + &output_payload, + ) + .map_err(|e| ApiError::InternalError(format!("write sub-agent output: {e}")))?; + + // Preview whether this was the final sub-agent for the group. The actual + // all-done decision is made by the sync loop when it calls record_agent_output. + let all_done = app_state + .get_group_for_agent(&agent_id) + .map(|g| g.individual_outputs.len() + 1 >= g.expected_total) + .unwrap_or(false); + + // Mark the sub-agent as completing so the sync loop stops polling. + let _ = app_state.update_agent_status( + &agent_id, + "completing", + Some("sub-agent complete".to_string()), + ); + + // Build a minimal response — the group aggregation/advancement happens + // in the sync loop, not here. + let (previous_summary, previous_recommendation, cumulative_files_modified, cumulative_errors) = + request.output.as_ref().map_or((None, None, 0, 0), |o| { + ( + o.summary.clone(), + o.recommendation.clone(), + o.files_modified.unwrap_or(0), + o.error_count.unwrap_or(0), + ) + }); + + Ok(Some(StepCompleteResponse { + status: if all_done { + "group_complete".to_string() + } else { + "group_partial".to_string() + }, + next_step: None, + auto_proceed: false, + next_command: None, + output_valid: request.output.is_some(), + should_iterate: false, + iteration_count: 1, + circuit_state: "closed".to_string(), + previous_summary, + previous_recommendation, + cumulative_files_modified, + cumulative_errors, + })) +} + /// Convert `PreparedLaunch` to `LaunchTicketResponse` fn prepared_launch_to_response(prepared: PreparedLaunch) -> LaunchTicketResponse { LaunchTicketResponse { @@ -200,6 +294,13 @@ pub async fn complete_step( )) })?; + // Multi-agent branch: if the calling sub-agent belongs to a group, + // write its individual output file and return a group_* status. + // The sync loop owns aggregation, advancement, and artifact writing. + if let Some(response) = handle_multi_agent_completion(&state, &ticket, &step_name, &request)? { + return Ok(Json(response)); + } + // Determine status based on exit code and validation let status = if request.exit_code != 0 { "failed".to_string() @@ -381,6 +482,7 @@ mod tests { model: "opus".to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: Some(crate::config::DelegatorLaunchConfig { yolo: true, permission_mode: Some("accept-edits".to_string()), @@ -427,6 +529,7 @@ mod tests { model: "sonnet".to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: Some(crate::config::DelegatorLaunchConfig::default()), }); let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); @@ -470,6 +573,7 @@ mod tests { model: model.to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: None, } } @@ -591,6 +695,7 @@ mod tests { model: "o3".to_string(), display_name: None, model_properties: std::collections::HashMap::new(), + model_server: None, launch_config: Some(crate::config::DelegatorLaunchConfig { yolo: true, permission_mode: None, @@ -616,4 +721,243 @@ mod tests { assert_eq!(options.prompt_prefix.as_deref(), Some("BEGIN")); assert_eq!(options.prompt_suffix.as_deref(), Some("END")); } + + // ─── Multi-agent grouped completion tests ─────────────────────────── + + use crate::queue::Ticket; + use crate::rest::dto::OperatorOutput; + use crate::state::{PendingSubAgent, State}; + use tempfile::TempDir; + + fn make_state_with_temp(temp_dir: &TempDir) -> ApiState { + let state_path = temp_dir.path().join("state"); + std::fs::create_dir_all(&state_path).unwrap(); + let mut config = Config::default(); + config.paths.state = state_path.to_string_lossy().to_string(); + ApiState::new(config, temp_dir.path().to_path_buf()) + } + + fn make_multi_agent_ticket(temp_dir: &TempDir) -> Ticket { + let worktree = temp_dir.path().join("worktree"); + std::fs::create_dir_all(&worktree).unwrap(); + Ticket { + filename: "multi.md".to_string(), + filepath: worktree.join("multi.md").to_string_lossy().to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: "TASK".to_string(), + project: "test".to_string(), + id: "TASK-555".to_string(), + summary: "Multi-agent ticket".to_string(), + priority: "P2-medium".to_string(), + status: "running".to_string(), + step: "review".to_string(), + content: "# test".to_string(), + sessions: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: Some(worktree.to_string_lossy().to_string()), + branch: None, + external_id: None, + external_url: None, + external_provider: None, + } + } + + fn make_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: 10, + output_sample: None, + output: Some(OperatorOutput { + status: "complete".to_string(), + exit_signal: true, + summary: Some("done".to_string()), + ..Default::default() + }), + } + } + + #[test] + fn test_handle_multi_agent_completion_returns_none_when_no_group() { + let temp_dir = TempDir::new().unwrap(); + let api_state = make_state_with_temp(&temp_dir); + let ticket = make_multi_agent_ticket(&temp_dir); + + // Fresh state — no groups, no agents. + let req = StepCompleteRequest { + exit_code: 0, + output_valid: true, + output_schema_errors: None, + session_id: None, + duration_secs: 0, + output_sample: None, + output: None, + }; + + let response = handle_multi_agent_completion(&api_state, &ticket, "review", &req).unwrap(); + assert!( + response.is_none(), + "no group → fall through to single-agent path" + ); + } + + #[test] + fn test_handle_multi_agent_completion_partial_writes_file_and_returns_group_partial() { + let temp_dir = TempDir::new().unwrap(); + let api_state = make_state_with_temp(&temp_dir); + let ticket = make_multi_agent_ticket(&temp_dir); + + // Build a group with 2 expected sub-agents; launch one (mark_launched). + let (agent_id, session_name) = { + let mut state = State::load(&api_state.config).unwrap(); + let group_id = state + .create_multi_agent_group( + &ticket.id, + "review", + "multi_model", + vec![ + PendingSubAgent { + delegator_name: "d1".to_string(), + prompt: "p".to_string(), + variant_key: "d1".to_string(), + }, + PendingSubAgent { + delegator_name: "d2".to_string(), + prompt: "p".to_string(), + variant_key: "d2".to_string(), + }, + ], + ) + .unwrap(); + + // Add one agent, record its session id, and mark it launched. + let agent_id = state + .add_agent_with_options( + ticket.id.clone(), + ticket.ticket_type.clone(), + ticket.project.clone(), + false, + Some("claude".to_string()), + Some("default".to_string()), + ) + .unwrap(); + let session_name = "op-TASK-555-d1".to_string(); + state + .update_agent_session(&agent_id, &session_name) + .unwrap(); + state.mark_launched(&group_id, "d1", &agent_id).unwrap(); + (agent_id, session_name) + }; + + let req = make_complete_request(&session_name); + let response = handle_multi_agent_completion(&api_state, &ticket, "review", &req) + .unwrap() + .expect("group member → returns Some"); + + assert_eq!(response.status, "group_partial"); + assert!(!response.auto_proceed); + assert!(response.next_step.is_none()); + + // Per-sub-agent file written at the expected path + let expected = temp_dir + .path() + .join("worktree") + .join(".tickets") + .join("steps") + .join("review") + .join(format!("{agent_id}.json")); + assert!( + expected.exists(), + "sub-agent output file should exist at {expected:?}" + ); + } + + #[test] + fn test_handle_multi_agent_completion_final_returns_group_complete() { + let temp_dir = TempDir::new().unwrap(); + let api_state = make_state_with_temp(&temp_dir); + let ticket = make_multi_agent_ticket(&temp_dir); + + // 2 sub-agents, both launched; the FIRST has already recorded its output. + let (second_agent_id, session_name) = { + let mut state = State::load(&api_state.config).unwrap(); + let group_id = state + .create_multi_agent_group( + &ticket.id, + "review", + "multi_model", + vec![ + PendingSubAgent { + delegator_name: "d1".to_string(), + prompt: "p".to_string(), + variant_key: "d1".to_string(), + }, + PendingSubAgent { + delegator_name: "d2".to_string(), + prompt: "p".to_string(), + variant_key: "d2".to_string(), + }, + ], + ) + .unwrap(); + + let a1 = state + .add_agent_with_options( + ticket.id.clone(), + ticket.ticket_type.clone(), + ticket.project.clone(), + false, + Some("claude".to_string()), + Some("default".to_string()), + ) + .unwrap(); + state.update_agent_session(&a1, "op-TASK-555-d1").unwrap(); + state.mark_launched(&group_id, "d1", &a1).unwrap(); + // Simulate first sub-agent already recorded (as if sync had processed it) + state + .record_agent_output(&a1, serde_json::json!({"summary": "first"})) + .unwrap(); + + let a2 = state + .add_agent_with_options( + ticket.id.clone(), + ticket.ticket_type.clone(), + ticket.project.clone(), + false, + Some("claude".to_string()), + Some("default".to_string()), + ) + .unwrap(); + let session_name = "op-TASK-555-d2".to_string(); + state.update_agent_session(&a2, &session_name).unwrap(); + state.mark_launched(&group_id, "d2", &a2).unwrap(); + (a2, session_name) + }; + + let req = make_complete_request(&session_name); + let response = handle_multi_agent_completion(&api_state, &ticket, "review", &req) + .unwrap() + .expect("group member → returns Some"); + + assert_eq!( + response.status, "group_complete", + "last sub-agent should return group_complete" + ); + assert!( + !response.auto_proceed, + "sync loop handles advancement, not REST" + ); + + // Our sub-agent's file is written + let expected = temp_dir + .path() + .join("worktree") + .join(".tickets") + .join("steps") + .join("review") + .join(format!("{second_agent_id}.json")); + assert!(expected.exists()); + } } diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index fa6fe8c..1e922b8 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -9,6 +9,7 @@ pub mod kanban; pub mod kanban_onboarding; pub mod launch; pub mod llm_tools; +pub mod model_servers; pub mod projects; pub mod queue; pub mod skills; diff --git a/src/rest/routes/model_servers.rs b/src/rest/routes/model_servers.rs new file mode 100644 index 0000000..d53d739 --- /dev/null +++ b/src/rest/routes/model_servers.rs @@ -0,0 +1,252 @@ +//! Model server CRUD endpoints. +//! +//! A model server is a named host that serves models via an inference API +//! (ollama, lmstudio, vllm, any OpenAI-compatible endpoint). Implicit builtin +//! servers (`anthropic-api`, `openai-api`, `google-api`) are returned on list +//! but cannot be created, updated, or deleted. + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::config::{implicit_model_server_for_tool, Config, ModelServer}; +use crate::rest::dto::{CreateModelServerRequest, ModelServerResponse, ModelServersResponse}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; + +const IMPLICIT_TOOL_NAMES: &[&str] = &["claude", "codex", "gemini"]; + +fn server_to_response(s: &ModelServer, user_declared: bool) -> ModelServerResponse { + ModelServerResponse { + name: s.name.clone(), + kind: s.kind.clone(), + base_url: s.base_url.clone(), + api_key_env: s.api_key_env.clone(), + extra_env: s.extra_env.clone(), + display_name: s.display_name.clone(), + user_declared, + } +} + +/// List all model servers (user-declared + implicit builtins) +#[utoipa::path( + get, + path = "/api/v1/model-servers", + tag = "ModelServers", + responses( + (status = 200, description = "List of model servers", body = ModelServersResponse) + ) +)] +pub async fn list(State(state): State) -> Json { + let mut servers: Vec = state + .config + .model_servers + .iter() + .map(|s| server_to_response(s, true)) + .collect(); + + for tool in IMPLICIT_TOOL_NAMES { + let implicit = implicit_model_server_for_tool(tool); + if !servers.iter().any(|s| s.name == implicit.name) { + servers.push(server_to_response(&implicit, false)); + } + } + + let total = servers.len(); + Json(ModelServersResponse { servers, total }) +} + +/// Get a single model server by name +#[utoipa::path( + get, + path = "/api/v1/model-servers/{name}", + tag = "ModelServers", + params( + ("name" = String, Path, description = "Model server name") + ), + responses( + (status = 200, description = "Model server details", body = ModelServerResponse), + (status = 404, description = "Model server not found") + ) +)] +pub async fn get_one( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + if let Some(server) = state.config.model_servers.iter().find(|s| s.name == name) { + return Ok(Json(server_to_response(server, true))); + } + for tool in IMPLICIT_TOOL_NAMES { + let implicit = implicit_model_server_for_tool(tool); + if implicit.name == name { + return Ok(Json(server_to_response(&implicit, false))); + } + } + Err(ApiError::NotFound(format!( + "Model server '{name}' not found" + ))) +} + +/// Create a new model server +#[utoipa::path( + post, + path = "/api/v1/model-servers", + tag = "ModelServers", + request_body = CreateModelServerRequest, + responses( + (status = 200, description = "Model server created", body = ModelServerResponse), + (status = 409, description = "Model server already exists") + ) +)] +pub async fn create( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if state + .config + .model_servers + .iter() + .any(|s| s.name == req.name) + { + return Err(ApiError::Conflict(format!( + "Model server '{}' already exists", + req.name + ))); + } + if IMPLICIT_TOOL_NAMES + .iter() + .any(|t| implicit_model_server_for_tool(t).name == req.name) + { + return Err(ApiError::Conflict(format!( + "'{}' is a reserved implicit builtin name", + req.name + ))); + } + + let server = ModelServer { + name: req.name, + kind: req.kind, + base_url: req.base_url, + api_key_env: req.api_key_env, + extra_env: req.extra_env, + display_name: req.display_name, + }; + + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.model_servers.push(server.clone()); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(server_to_response(&server, true))) +} + +/// Delete a user-declared model server by name +/// +/// Implicit builtin servers cannot be deleted. +#[utoipa::path( + delete, + path = "/api/v1/model-servers/{name}", + tag = "ModelServers", + params( + ("name" = String, Path, description = "Model server name") + ), + responses( + (status = 200, description = "Model server deleted", body = ModelServerResponse), + (status = 404, description = "Model server not found"), + (status = 409, description = "Cannot delete implicit builtin server") + ) +)] +pub async fn delete( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + if IMPLICIT_TOOL_NAMES + .iter() + .any(|t| implicit_model_server_for_tool(t).name == name) + { + return Err(ApiError::Conflict(format!( + "'{name}' is an implicit builtin and cannot be deleted" + ))); + } + + let server = state + .config + .model_servers + .iter() + .find(|s| s.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))? + .clone(); + + let response = server_to_response(&server, true); + + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.model_servers.retain(|s| s.name != name); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[tokio::test] + async fn test_list_returns_builtins_when_no_user_servers() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms")); + let resp = list(State(state)).await; + + // At minimum, one implicit server for each known tool. + assert!(resp.total >= IMPLICIT_TOOL_NAMES.len()); + assert!(resp.servers.iter().any(|s| s.name == "anthropic-api")); + assert!(resp.servers.iter().any(|s| s.name == "openai-api")); + assert!(resp.servers.iter().any(|s| s.name == "google-api")); + assert!(resp.servers.iter().all(|s| !s.user_declared)); + } + + #[tokio::test] + async fn test_list_includes_user_declared_servers() { + let mut config = Config::default(); + config.model_servers.push(ModelServer { + name: "ollama-local".to_string(), + kind: "ollama".to_string(), + base_url: Some("http://localhost:11434".to_string()), + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: Some("Ollama (local)".to_string()), + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-user")); + let resp = list(State(state)).await; + + let ollama = resp + .servers + .iter() + .find(|s| s.name == "ollama-local") + .expect("ollama-local should appear"); + assert!(ollama.user_declared); + assert_eq!(ollama.kind, "ollama"); + } + + #[tokio::test] + async fn test_get_one_returns_implicit_builtin() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-get")); + let resp = get_one(State(state), Path("openai-api".to_string())).await; + let server = resp.expect("implicit openai-api should resolve").0; + assert_eq!(server.name, "openai-api"); + assert!(!server.user_declared); + } + + #[tokio::test] + async fn test_get_one_404_on_unknown() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-404")); + let resp = get_one(State(state), Path("nope".to_string())).await; + assert!(resp.is_err()); + } +} diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index 4b122a5..7ae8635 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1,409 +1,1285 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://gbqr.us/operator/issuetype-template.schema.json", - "title": "Issuetype Template Schema", - "description": "Schema for validating operator issuetype template configurations", + "title": "TemplateSchema", + "description": "Schema definition for an issuetype template", "type": "object", - "required": ["key", "name", "description", "mode", "glyph", "fields", "steps"], - "additionalProperties": false, "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation" - }, "key": { - "type": "string", - "pattern": "^[A-Z]+$", "description": "Unique issuetype key (e.g., FEAT, FIX, SPIKE, INV, TASK)", - "examples": ["FEAT", "FIX", "SPIKE", "INV", "TASK"] + "type": "string" }, "name": { - "type": "string", - "minLength": 3, - "description": "Human-readable name of the issuetype, eg. bug, feature, task, chore, spike, etc." + "description": "Display name of the template type", + "type": "string" }, "description": { - "type": "string", - "description": "Description of what this issuetype is for" + "description": "Brief description of when to use this template", + "type": "string" }, "mode": { - "type": "string", - "enum": ["autonomous", "paired"], - "description": "Whether this issuetype work runs autonomously or requires human pairing", - "default": "paired" + "description": "Whether this issuetype runs autonomously or requires human pairing", + "$ref": "#/$defs/ExecutionMode" }, "glyph": { - "type": "string", - "minLength": 1, - "maxLength": 4, - "description": "Icon/glyph character displayed in the UI for this issuetype (e.g., '*', '#', '!', '?', '>')", - "examples": ["*", "#", "!", "?", ">"] + "description": "Glyph character displayed in UI for this issuetype", + "type": "string" }, "color": { - "type": "string", - "enum": ["white", "blue", "cyan", "green", "yellow", "magenta", "red", "black"], - "default": "white", - "description": "Optional color for the glyph in TUI display" + "description": "Optional color for glyph display in TUI", + "type": [ + "string", + "null" + ], + "default": null }, "project_required": { - "type": "boolean", "description": "Whether a project must be specified for this issuetype", + "type": "boolean", "default": true }, "fields": { + "description": "Field definitions for this template", "type": "array", - "description": "Field definitions for the ticket form", "items": { - "$ref": "#/definitions/field" - }, - "minItems": 1 + "$ref": "#/$defs/FieldSchema" + } }, "steps": { - "type": "array", "description": "Lifecycle steps for completing this ticket type", + "type": "array", "items": { - "$ref": "#/definitions/step" - }, - "minItems": 1 + "$ref": "#/$defs/StepSchema" + } }, "prompt": { - "type": "string", - "description": "Issue prompt to apply to work creation." + "description": "Optional prompt for work launching (interpolated with handlebars)", + "type": [ + "string", + "null" + ], + "default": null }, - "agent_creation_prompt": { - "type": "string", - "description": "Optional prompt for generating an operator agent for this issuetype via 'claude -p' for this prompt. Should instruct Claude to output ONLY the agent system prompt. If omitted, no operator agent will be generated for this issuetype." + "agent_prompt": { + "description": "Prompt for generating this issue type's operator agent via `claude -p`", + "type": [ + "string", + "null" + ], + "default": null }, "agent": { - "type": "string", - "description": "Default delegator name for this issuetype (overridden by step-level agent)" + "description": "Default delegator name for this issuetype (overridden by step.agent)", + "type": [ + "string", + "null" + ], + "default": null } }, - "definitions": { - "field": { + "required": [ + "key", + "name", + "description", + "mode", + "glyph", + "fields", + "steps" + ], + "$defs": { + "ExecutionMode": { + "description": "Execution mode for an issuetype", + "oneOf": [ + { + "description": "Runs without human interaction", + "type": "string", + "const": "autonomous" + }, + { + "description": "Requires human pairing/interaction", + "type": "string", + "const": "paired" + } + ] + }, + "FieldSchema": { + "description": "Schema definition for a single field in a template", "type": "object", - "required": ["name", "description", "type"], - "additionalProperties": false, "properties": { "name": { - "type": "string", - "pattern": "^[a-z_]+$", - "description": "Field identifier (lowercase with underscores)" + "description": "Field identifier (matches handlebar variable name)", + "type": "string" }, "description": { - "type": "string", - "description": "Human-readable description of the field" + "description": "Help text for the field", + "type": "string" }, "type": { - "type": "string", - "enum": ["string", "text", "enum", "date", "boolean", "integer"], - "description": "Field data type" + "description": "Type of the field", + "$ref": "#/$defs/FieldType" }, "required": { + "description": "Whether this field must be filled", "type": "boolean", - "default": false, - "description": "Whether this field must be filled" + "default": false }, "default": { - "type": ["string", "boolean", "integer", "null"], - "description": "Default value for the field. Required if field is required (except for 'id' field)" - }, - "min": { - "type": "integer", - "description": "Minimum value for integer fields" - }, - "max": { - "type": "integer", - "description": "Maximum value for integer fields" + "description": "Default value if any", + "type": [ + "string", + "null" + ], + "default": null }, "auto": { - "type": "string", - "enum": ["id", "date", "branch", "status"], - "description": "Auto-generation strategy for this field" + "description": "Auto-generation strategy for this field", + "anyOf": [ + { + "$ref": "#/$defs/AutoGenStrategy" + }, + { + "type": "null" + } + ], + "default": null }, "options": { + "description": "Options for enum fields", "type": "array", "items": { "type": "string" }, - "description": "Available options for enum fields" + "default": [] }, "placeholder": { - "type": "string", - "description": "Placeholder text shown in empty field" + "description": "Placeholder text shown in template", + "type": [ + "string", + "null" + ], + "default": null }, "max_length": { - "type": "integer", - "minimum": 1, - "description": "Maximum character length for string/text fields" + "description": "Maximum length for string fields", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": null }, "display_order": { - "type": "integer", - "minimum": 0, - "description": "Order in which to display this field in the form" + "description": "Display order in form (lower = first)", + "type": [ + "integer", + "null" + ], + "format": "int32", + "default": null }, "user_editable": { + "description": "Whether the user can edit this field (false for auto-generated)", "type": "boolean", - "default": true, - "description": "Whether the user can edit this field (false for auto-generated)" + "default": true } }, - "allOf": [ + "required": [ + "name", + "description", + "type" + ] + }, + "FieldType": { + "description": "Types of fields supported in template schemas", + "oneOf": [ { - "if": { - "properties": { - "type": { "const": "enum" } - } - }, - "then": { - "required": ["options"] - } + "description": "Single-line text input", + "type": "string", + "const": "string" }, { - "if": { - "properties": { - "required": { "const": true }, - "auto": false - }, - "not": { - "properties": { - "name": { "const": "id" } - } - } - }, - "then": { - "required": ["default"] - } + "description": "Selection from predefined options", + "type": "string", + "const": "enum" + }, + { + "description": "True/false checkbox", + "type": "string", + "const": "bool" + }, + { + "description": "Date field (YYYY-MM-DD format)", + "type": "string", + "const": "date" + }, + { + "description": "Multi-line text input", + "type": "string", + "const": "text" + }, + { + "description": "Integer number input", + "type": "string", + "const": "integer" + } + ] + }, + "AutoGenStrategy": { + "description": "Auto-generation strategies for fields", + "oneOf": [ + { + "description": "Generate ID from timestamp (e.g., FEAT-1234)", + "type": "string", + "const": "id" + }, + { + "description": "Generate current date (YYYY-MM-DD)", + "type": "string", + "const": "date" + }, + { + "description": "Generate branch name from type and summary", + "type": "string", + "const": "branch" + }, + { + "description": "Set initial status", + "type": "string", + "const": "status" } ] }, - "step": { + "StepSchema": { + "description": "Schema definition for a lifecycle step", "type": "object", - "required": ["name", "outputs", "prompt", "allowed_tools"], - "additionalProperties": false, "properties": { "name": { - "type": "string", "description": "Step identifier (lowercase)", - "pattern": "^[a-z_]+$" + "type": "string" }, "display_name": { - "type": "string", - "description": "Human-readable step name" + "description": "Human-readable step name", + "type": [ + "string", + "null" + ], + "default": null + }, + "type": { + "description": "Step type discriminator (defaults to \"task\" for backward compatibility)", + "$ref": "#/$defs/StepTypeTag", + "default": "task" }, "outputs": { + "description": "Types of outputs this step produces", "type": "array", "items": { - "type": "string", - "enum": ["plan", "code", "test", "pr", "ticket", "review", "report", "documentation"] - }, - "description": "Types of outputs this step produces" + "$ref": "#/$defs/StepOutput" + } }, "prompt": { - "type": "string", - "description": "Initial prompt template for the Claude agent at this step" - }, - "allowed_tools": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Claude Code tools allowed in this step (e.g., 'Read', 'Write', 'Bash')" + "description": "Initial prompt template for the Claude agent", + "type": "string" }, "review_type": { - "type": "string", - "enum": ["none", "plan", "visual", "pr"], - "default": "none", - "description": "Type of review required: none (auto-proceed), plan (approve plan), visual (browser check), pr (GitHub PR review)" + "description": "Type of review required for this step (none, plan, visual, pr)", + "$ref": "#/$defs/ReviewType", + "default": "none" }, "visual_config": { - "type": "object", - "description": "Configuration for visual review (required when review_type is 'visual')", - "properties": { - "url": { - "type": "string", - "description": "URL to open for visual check (supports handlebars templates)" - }, - "startup_command": { - "type": "string", - "description": "Optional command to start dev server before opening browser" + "description": "Configuration for visual review (required when `review_type` is \"visual\")", + "anyOf": [ + { + "$ref": "#/$defs/VisualReviewConfig" }, - "startup_timeout_secs": { - "type": "integer", - "default": 30, - "description": "Timeout in seconds for server startup" + { + "type": "null" } - }, - "required": ["url"] + ], + "default": null }, "on_reject": { - "type": "object", "description": "What to do if step output is rejected", - "properties": { - "goto_step": { - "type": "string", - "description": "Step name to return to on rejection" + "anyOf": [ + { + "$ref": "#/$defs/OnReject" }, - "prompt": { - "type": "string", - "description": "Prompt to use when restarting after rejection" + { + "type": "null" } - } + ], + "default": null }, "next_step": { - "type": ["string", "null"], - "description": "Name of the next step (null for final step)" + "description": "Name of the next step (None for final step)", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Claude Code tools allowed in this step", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "agent": { + "description": "Optional agent (delegator) name for this step (overrides ticket's default agent)", + "type": [ + "string", + "null" + ], + "default": null }, "permissions": { - "$ref": "#/definitions/stepPermissions", - "description": "Provider-agnostic permissions for this step, merged additively with project settings" + "description": "Provider-agnostic permissions for this step", + "anyOf": [ + { + "$ref": "#/$defs/StepPermissions" + }, + { + "type": "null" + } + ], + "default": null }, "cli_args": { - "$ref": "#/definitions/providerCliArgs", - "description": "Arbitrary CLI arguments per provider" + "description": "Arbitrary CLI arguments per provider", + "anyOf": [ + { + "$ref": "#/$defs/ProviderCliArgs" + }, + { + "type": "null" + } + ], + "default": null }, "permission_mode": { - "type": "string", - "enum": ["default", "plan", "acceptEdits", "delegate"], - "default": "default", - "description": "Preferred LLM permission mode for this step. Only applies to providers that support it (e.g., Claude). No-op for unsupported providers." + "description": "Preferred LLM permission mode for this step", + "$ref": "#/$defs/PermissionMode", + "default": "default" }, "jsonSchema": { - "type": "object", - "description": "Inline JSON schema for structured output. Claude-specific: sets --json-schema flag. Takes precedence over jsonSchemaFile if both are defined." + "description": "Inline JSON schema for structured output (Claude-specific)", + "default": null }, "jsonSchemaFile": { - "type": "string", - "description": "Path to a local JSON schema file for structured output, relative to the project root. Claude-specific: sets --json-schema flag." + "description": "Path to JSON schema file for structured output (Claude-specific)", + "type": [ + "string", + "null" + ], + "default": null }, "artifact_patterns": { + "description": "File glob patterns in the worktree that signal this step is complete", "type": "array", - "items": { "type": "string" }, - "description": "File glob patterns in the worktree that signal this step is complete (e.g., '.tickets/plans/*.md')" + "items": { + "type": "string" + }, + "default": [] }, - "agent": { + "classifier_config": { + "description": "Configuration for classifier steps (required when type=classifier)", + "anyOf": [ + { + "$ref": "#/$defs/ClassifierConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "rag_config": { + "description": "Configuration for RAG steps (required when type=rag)", + "anyOf": [ + { + "$ref": "#/$defs/RagConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "delegator_config": { + "description": "Configuration for delegator steps (required when type=delegator)", + "anyOf": [ + { + "$ref": "#/$defs/DelegatorStepConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "mcp_config": { + "description": "Configuration for MCP steps (required when type=mcp)", + "anyOf": [ + { + "$ref": "#/$defs/McpStepConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "multi_model_config": { + "description": "Configuration for multi-model steps (required when `type=multi_model`)", + "anyOf": [ + { + "$ref": "#/$defs/MultiModelConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "multi_prompt_config": { + "description": "Configuration for multi-prompt steps (required when `type=multi_prompt`)", + "anyOf": [ + { + "$ref": "#/$defs/MultiPromptConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "matrixed_config": { + "description": "Configuration for matrixed steps (required when type=matrixed)", + "anyOf": [ + { + "$ref": "#/$defs/MatrixedConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "name", + "outputs", + "prompt" + ] + }, + "StepTypeTag": { + "description": "Discriminator tag for step types", + "oneOf": [ + { + "description": "Default pass-through task step", + "type": "string", + "const": "task" + }, + { + "description": "Structured typed output (boolean, number, string, enum)", + "type": "string", + "const": "classifier" + }, + { + "description": "Context-augmented prompting with retrieved sources", + "type": "string", + "const": "rag" + }, + { + "description": "Runs with a specific delegator and prompt flavor", "type": "string", - "description": "Delegator name override for this step" + "const": "delegator" + }, + { + "description": "Ensures specific MCP tools are available", + "type": "string", + "const": "mcp" + }, + { + "description": "Fan-out to N delegators, then aggregate via voting", + "type": "string", + "const": "multi_model" + }, + { + "description": "N prompt variations with one model, then select best", + "type": "string", + "const": "multi_prompt" + }, + { + "description": "N x M delegators x prompt variations", + "type": "string", + "const": "matrixed" } - } + ] + }, + "StepOutput": { + "description": "Types of outputs a step can produce", + "oneOf": [ + { + "description": "Implementation plan", + "type": "string", + "const": "plan" + }, + { + "description": "Source code changes", + "type": "string", + "const": "code" + }, + { + "description": "Test code/results", + "type": "string", + "const": "test" + }, + { + "description": "Pull request", + "type": "string", + "const": "pr" + }, + { + "description": "New ticket(s)", + "type": "string", + "const": "ticket" + }, + { + "description": "Review output", + "type": "string", + "const": "review" + }, + { + "description": "Investigation/research report", + "type": "string", + "const": "report" + }, + { + "description": "Documentation", + "type": "string", + "const": "documentation" + } + ] + }, + "ReviewType": { + "description": "Type of review required for a step", + "oneOf": [ + { + "description": "No review required - proceed automatically", + "type": "string", + "const": "none" + }, + { + "description": "Review the plan/output before proceeding", + "type": "string", + "const": "plan" + }, + { + "description": "Visual confirmation via browser", + "type": "string", + "const": "visual" + }, + { + "description": "Git interface PR review workflow", + "type": "string", + "const": "pr" + } + ] + }, + "VisualReviewConfig": { + "description": "Configuration for visual review steps", + "type": "object", + "properties": { + "url": { + "description": "URL to open for visual check (supports handlebars templates)", + "type": "string" + }, + "startup_command": { + "description": "Optional startup command (e.g., dev server) to run before opening browser", + "type": [ + "string", + "null" + ], + "default": null + }, + "startup_timeout_secs": { + "description": "Timeout in seconds for server startup (default: 30)", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0, + "default": null + } + }, + "required": [ + "url" + ] + }, + "OnReject": { + "description": "Action to take when a step is rejected", + "type": "object", + "properties": { + "goto_step": { + "description": "Step name to return to on rejection", + "type": "string" + }, + "prompt": { + "description": "Prompt to use when restarting after rejection", + "type": "string" + } + }, + "required": [ + "goto_step", + "prompt" + ] }, - "stepPermissions": { + "StepPermissions": { + "description": "Complete permission set for a step (as defined in issuetype schema)", "type": "object", - "description": "Provider-agnostic permissions for a step", - "additionalProperties": false, "properties": { "tools": { - "type": "object", "description": "Tool-level allow/deny lists", - "additionalProperties": false, - "properties": { - "allow": { - "type": "array", - "items": { "$ref": "#/definitions/toolPattern" }, - "description": "Tools/patterns to allow (additive to project settings)" - }, - "deny": { - "type": "array", - "items": { "$ref": "#/definitions/toolPattern" }, - "description": "Tools/patterns to deny (additive to project settings)" - } - } + "$ref": "#/$defs/ToolPermissions" }, "directories": { - "type": "object", "description": "Directory-level allow/deny lists", - "additionalProperties": false, - "properties": { - "allow": { - "type": "array", - "items": { "type": "string" }, - "description": "Additional directories to allow access to (glob patterns)" - }, - "deny": { - "type": "array", - "items": { "type": "string" }, - "description": "Directories to deny access to (glob patterns)" - } - } + "$ref": "#/$defs/DirectoryPermissions" }, "mcp_servers": { - "type": "object", "description": "MCP server enable/disable configuration", - "additionalProperties": false, - "properties": { - "enable": { - "type": "array", - "items": { "type": "string" }, - "description": "MCP servers to enable for this step" - }, - "disable": { - "type": "array", - "items": { "type": "string" }, - "description": "MCP servers to disable for this step" - } - } + "$ref": "#/$defs/McpServerPermissions" }, "custom_flags": { - "type": "object", "description": "Per-provider custom configuration flags", - "additionalProperties": false, - "properties": { - "claude": { - "type": "object", - "additionalProperties": true, - "description": "Claude-specific configuration flags" - }, - "gemini": { - "type": "object", - "additionalProperties": true, - "description": "Gemini-specific configuration flags" - }, - "codex": { - "type": "object", - "additionalProperties": true, - "description": "Codex-specific configuration flags" - } + "$ref": "#/$defs/CustomFlags" + } + } + }, + "ToolPermissions": { + "description": "Tool-level permissions (allow/deny lists)", + "type": "object", + "properties": { + "allow": { + "description": "Tools/patterns to allow", + "type": "array", + "items": { + "$ref": "#/$defs/ToolPattern" + } + }, + "deny": { + "description": "Tools/patterns to deny", + "type": "array", + "items": { + "$ref": "#/$defs/ToolPattern" } } } }, - "toolPattern": { + "ToolPattern": { + "description": "Provider-agnostic tool pattern", "type": "object", - "description": "Provider-agnostic tool permission pattern", - "required": ["tool"], - "additionalProperties": false, "properties": { "tool": { - "type": "string", - "description": "Tool name: Read, Write, Edit, Bash, Glob, Grep, WebFetch, etc." + "description": "Tool name: Read, Write, Edit, Bash, Glob, Grep, `WebFetch`, etc.", + "type": "string" }, "pattern": { - "type": "string", - "description": "Optional pattern for tool arguments (e.g., 'cargo test:*' for Bash)" + "description": "Optional pattern for tool arguments (e.g., \"cargo test:*\" for Bash)", + "type": [ + "string", + "null" + ] } - } + }, + "required": [ + "tool" + ] }, - "providerCliArgs": { + "DirectoryPermissions": { + "description": "Directory-level permissions", "type": "object", - "description": "Arbitrary CLI arguments per provider", - "additionalProperties": false, "properties": { - "claude": { + "allow": { + "description": "Additional directories to allow access to (glob patterns)", "type": "array", - "items": { "type": "string" }, - "description": "CLI arguments for Claude" + "items": { + "type": "string" + } + }, + "deny": { + "description": "Directories to deny access to (glob patterns)", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "McpServerPermissions": { + "description": "MCP server permissions (server-level enable/disable only)", + "type": "object", + "properties": { + "enable": { + "description": "MCP servers to enable for this step", + "type": "array", + "items": { + "type": "string" + } + }, + "disable": { + "description": "MCP servers to disable for this step", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CustomFlags": { + "description": "Per-provider custom configuration flags", + "type": "object", + "properties": { + "claude": { + "description": "Claude-specific configuration flags", + "type": "object", + "additionalProperties": true + }, + "gemini": { + "description": "Gemini-specific configuration flags", + "type": "object", + "additionalProperties": true + }, + "codex": { + "description": "Codex-specific configuration flags", + "type": "object", + "additionalProperties": true + } + } + }, + "ProviderCliArgs": { + "description": "Arbitrary CLI arguments per provider", + "type": "object", + "properties": { + "claude": { + "description": "CLI arguments for Claude", + "type": "array", + "items": { + "type": "string" + } }, "gemini": { + "description": "CLI arguments for Gemini", "type": "array", - "items": { "type": "string" }, - "description": "CLI arguments for Gemini" + "items": { + "type": "string" + } }, "codex": { + "description": "CLI arguments for Codex", "type": "array", - "items": { "type": "string" }, - "description": "CLI arguments for Codex" + "items": { + "type": "string" + } } } + }, + "PermissionMode": { + "description": "Permission mode for LLM interaction", + "oneOf": [ + { + "description": "Default permission mode - standard interactive behavior", + "type": "string", + "const": "default" + }, + { + "description": "Plan mode - read-only exploration before implementation", + "type": "string", + "const": "plan" + }, + { + "description": "Accept edits mode - auto-approve file edits", + "type": "string", + "const": "acceptEdits" + }, + { + "description": "Delegate mode - task delegation with DAG management", + "type": "string", + "const": "delegate" + } + ] + }, + "ClassifierConfig": { + "description": "Configuration for classifier steps that return structured typed output", + "type": "object", + "properties": { + "output_type": { + "description": "What type of answer the classifier returns", + "$ref": "#/$defs/ClassifierOutputType" + }, + "options": { + "description": "For enum type: the allowed options", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "default": null + }, + "max_length": { + "description": "For `short_string`: max character length (default 255)", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": null + }, + "agent": { + "description": "Agent/delegator to use (overrides issuetype default)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "output_type" + ] + }, + "ClassifierOutputType": { + "description": "Output types for classifier steps", + "oneOf": [ + { + "description": "true/false answer", + "type": "string", + "const": "boolean" + }, + { + "description": "Numeric answer (integer or float)", + "type": "string", + "const": "number" + }, + { + "description": "Short string < 255 chars", + "type": "string", + "const": "short_string" + }, + { + "description": "Longer arbitrary-length text", + "type": "string", + "const": "big_text" + }, + { + "description": "One of a fixed set of options", + "type": "string", + "const": "enum" + } + ] + }, + "RagConfig": { + "description": "Configuration for RAG (retrieval-augmented generation) steps", + "type": "object", + "properties": { + "sources": { + "description": "Context sources to retrieve before running the prompt", + "type": "array", + "items": { + "$ref": "#/$defs/RagSource" + } + }, + "max_context_tokens": { + "description": "Maximum tokens of context to inject (default: 50000)", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": null + }, + "agent": { + "description": "Agent/delegator to use", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Tools allowed for the agent", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "sources" + ] + }, + "RagSource": { + "description": "A source of context for RAG steps", + "oneOf": [ + { + "description": "Match files by glob pattern", + "type": "object", + "properties": { + "pattern": { + "description": "Glob pattern relative to project root", + "type": "string" + }, + "type": { + "type": "string", + "const": "glob" + } + }, + "required": [ + "type", + "pattern" + ] + }, + { + "description": "Single file path", + "type": "object", + "properties": { + "path": { + "description": "File path relative to project root", + "type": "string" + }, + "type": { + "type": "string", + "const": "file" + } + }, + "required": [ + "type", + "path" + ] + }, + { + "description": "Retrieve via MCP server tool", + "type": "object", + "properties": { + "server": { + "description": "MCP server name", + "type": "string" + }, + "tool": { + "description": "Tool name on the MCP server", + "type": "string" + }, + "query": { + "description": "Optional query template (Handlebars)", + "type": [ + "string", + "null" + ], + "default": null + }, + "type": { + "type": "string", + "const": "mcp" + } + }, + "required": [ + "type", + "server", + "tool" + ] + } + ] + }, + "DelegatorStepConfig": { + "description": "Configuration for delegator steps that run with a specific model+flavor", + "type": "object", + "properties": { + "delegator": { + "description": "Named delegator reference (from config.delegators)", + "type": "string" + }, + "prompt_flavor": { + "description": "Additional prompt flavor text prepended to the step prompt", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Tools allowed", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "permissions": { + "description": "Permissions", + "anyOf": [ + { + "$ref": "#/$defs/StepPermissions" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "delegator" + ] + }, + "McpStepConfig": { + "description": "Configuration for MCP steps that require specific MCP tools", + "type": "object", + "properties": { + "required_tools": { + "description": "MCP tools that MUST be available (step fails if missing)", + "type": "array", + "items": { + "$ref": "#/$defs/McpToolRef" + } + }, + "optional_tools": { + "description": "MCP tools that SHOULD be available (warning if missing)", + "type": "array", + "items": { + "$ref": "#/$defs/McpToolRef" + }, + "default": [] + }, + "agent": { + "description": "Agent/delegator to use", + "type": [ + "string", + "null" + ], + "default": null + }, + "allowed_tools": { + "description": "Tools allowed (in addition to MCP tools)", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "required_tools" + ] + }, + "McpToolRef": { + "description": "Reference to a specific MCP server tool", + "type": "object", + "properties": { + "server": { + "description": "MCP server name", + "type": "string" + }, + "tool": { + "description": "Specific tool name (None = all tools from this server)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "server" + ] + }, + "MultiModelConfig": { + "description": "Configuration for multi-model delegation steps (fan-out + vote)", + "type": "object", + "properties": { + "delegators": { + "description": "Named delegator references (from config.delegators), minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "voting_strategy": { + "description": "How to aggregate/select the final answer", + "$ref": "#/$defs/VotingStrategy" + }, + "share_answers": { + "description": "Whether to share all answers with all models in the voting round", + "type": "boolean", + "default": true + }, + "voting_prompt": { + "description": "Prompt for the voting round (Handlebars, receives {{ answers }} array)", + "type": [ + "string", + "null" + ], + "default": null + }, + "voting_mode": { + "description": "How the voting round executes", + "$ref": "#/$defs/VotingMode", + "default": "single_judge" + } + }, + "required": [ + "delegators", + "voting_strategy" + ] + }, + "VotingStrategy": { + "description": "Voting strategy for multi-model steps", + "oneOf": [ + { + "description": "Simple majority vote", + "type": "string", + "const": "majority" + }, + { + "description": "Ranked choice voting", + "type": "string", + "const": "ranked" + }, + { + "description": "Unanimous required (falls back to longest answer if no consensus)", + "type": "string", + "const": "unanimous" + } + ] + }, + "VotingMode": { + "description": "How the voting round is executed in multi-model steps", + "oneOf": [ + { + "description": "One agent reviews all answers and picks winner (uses 1 slot)", + "type": "string", + "const": "single_judge" + }, + { + "description": "All original delegators re-run with shared answers, each votes (uses N slots)", + "type": "string", + "const": "multi_voter" + } + ] + }, + "MultiPromptConfig": { + "description": "Configuration for multi-prompt interrogation steps (N variations, select best)", + "type": "object", + "properties": { + "prompt_variations": { + "description": "Prompt variations (Handlebars templates), minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "selection_strategy": { + "description": "How to select the best result", + "$ref": "#/$defs/SelectionStrategy" + }, + "agent": { + "description": "Agent/delegator to use for all variations", + "type": [ + "string", + "null" + ], + "default": null + }, + "selection_prompt": { + "description": "Prompt for the selection/review round", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "prompt_variations", + "selection_strategy" + ] + }, + "SelectionStrategy": { + "description": "Selection strategy for multi-prompt steps", + "oneOf": [ + { + "description": "Model reviews all outputs and picks the best", + "type": "string", + "const": "model_choice" + }, + { + "description": "Model scores each and highest wins", + "type": "string", + "const": "scored" + } + ] + }, + "MatrixedConfig": { + "description": "Configuration for matrixed work output steps (N x M delegators x prompts)", + "type": "object", + "properties": { + "delegators": { + "description": "Named delegator references (N), minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "prompt_variations": { + "description": "Prompt variations (M) — Handlebars templates, minimum 2", + "type": "array", + "items": { + "type": "string" + } + }, + "output_format": { + "description": "How to organize/present the N x M output", + "$ref": "#/$defs/MatrixedOutputFormat" + }, + "aggregation_prompt": { + "description": "Optional aggregation prompt (receives the full matrix of results)", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "delegators", + "prompt_variations", + "output_format" + ] + }, + "MatrixedOutputFormat": { + "description": "Output format for matrixed steps", + "oneOf": [ + { + "description": "Each cell's output in `temp_dir/{delegator}/{prompt_index}/`", + "type": "string", + "const": "directory" + }, + { + "description": "Structured N x M JSON matrix in step output artifact", + "type": "string", + "const": "structured" + } + ] } - } -} + }, + "$id": "https://gbqr.us/operator/issuetype-template.schema.json", + "$comment": "AUTO-GENERATED FROM src/templates/schema.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only issuetype-json-schema" +} \ No newline at end of file diff --git a/src/services/kanban_issuetype_service.rs b/src/services/kanban_issuetype_service.rs index a0a9d2c..f842747 100644 --- a/src/services/kanban_issuetype_service.rs +++ b/src/services/kanban_issuetype_service.rs @@ -214,6 +214,46 @@ impl KanbanIssueTypeService { } } +// ─── Mapping-Status Utilities ─────────────────────────────────────────────── + +/// Per-issue-type mapping status for display. +#[derive(Debug, Clone)] +pub struct IssueTypeMappingStatus { + /// The kanban issue type from the provider. + pub issue_type: KanbanIssueType, + /// Operator issue type key this maps to, if any (e.g., "TASK", "FEAT", "FIX"). + pub operator_key: Option, +} + +impl IssueTypeMappingStatus { + /// Whether this issue type has been mapped to an operator key. + pub fn is_mapped(&self) -> bool { + self.operator_key.is_some() + } +} + +/// Compute mapping status for a list of issue types against `type_mappings`. +/// +/// For each `KanbanIssueType`, looks up its `id` in the mappings to see if +/// an operator key is assigned. +pub fn compute_mapping_status( + issue_types: &[KanbanIssueType], + type_mappings: &HashMap, +) -> Vec { + issue_types + .iter() + .map(|t| IssueTypeMappingStatus { + issue_type: t.clone(), + operator_key: type_mappings.get(&t.id).cloned(), + }) + .collect() +} + +/// Count unmapped issue types. +pub fn unmapped_count(status: &[IssueTypeMappingStatus]) -> usize { + status.iter().filter(|s| s.operator_key.is_none()).count() +} + #[cfg(test)] mod tests { use super::*; @@ -475,4 +515,78 @@ mod tests { let result = service.resolve_legacy_mapping("Nonexistent", "jira", "PROJ"); assert_eq!(result, None); } + + fn make_kanban_type(id: &str, name: &str) -> KanbanIssueType { + KanbanIssueType { + id: id.to_string(), + name: name.to_string(), + description: None, + icon_url: None, + custom_fields: vec![], + provider: "jira".to_string(), + project: "PROJ".to_string(), + source_kind: "issuetype".to_string(), + synced_at: "2026-04-11T00:00:00Z".to_string(), + } + } + + #[test] + fn test_compute_mapping_status_fully_mapped() { + let types = vec![ + make_kanban_type("10001", "Bug"), + make_kanban_type("10002", "Story"), + ]; + let mut mappings = HashMap::new(); + mappings.insert("10001".to_string(), "FIX".to_string()); + mappings.insert("10002".to_string(), "FEAT".to_string()); + + let status = compute_mapping_status(&types, &mappings); + assert_eq!(status.len(), 2); + assert!(status[0].is_mapped()); + assert_eq!(status[0].operator_key, Some("FIX".to_string())); + assert!(status[1].is_mapped()); + assert_eq!(status[1].operator_key, Some("FEAT".to_string())); + assert_eq!(unmapped_count(&status), 0); + } + + #[test] + fn test_compute_mapping_status_fully_unmapped() { + let types = vec![ + make_kanban_type("10001", "Bug"), + make_kanban_type("10002", "Story"), + ]; + let mappings = HashMap::new(); + + let status = compute_mapping_status(&types, &mappings); + assert_eq!(status.len(), 2); + assert!(!status[0].is_mapped()); + assert!(!status[1].is_mapped()); + assert_eq!(unmapped_count(&status), 2); + } + + #[test] + fn test_compute_mapping_status_mixed() { + let types = vec![ + make_kanban_type("10001", "Bug"), + make_kanban_type("10002", "Story"), + ]; + let mut mappings = HashMap::new(); + mappings.insert("10001".to_string(), "FIX".to_string()); + + let status = compute_mapping_status(&types, &mappings); + assert_eq!(status.len(), 2); + assert!(status[0].is_mapped()); + assert!(!status[1].is_mapped()); + assert_eq!(unmapped_count(&status), 1); + } + + #[test] + fn test_compute_mapping_status_empty() { + let types: Vec = vec![]; + let mappings = HashMap::new(); + + let status = compute_mapping_status(&types, &mappings); + assert!(status.is_empty()); + assert_eq!(unmapped_count(&status), 0); + } } diff --git a/src/state.rs b/src/state.rs index 66a9f07..fd4dba8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -28,6 +28,10 @@ pub struct State { #[serde(default)] pub project_collection_prefs: HashMap, + /// Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) + #[serde(default)] + pub multi_agent_groups: Vec, + #[serde(skip)] #[ts(skip)] state_path: PathBuf, @@ -134,6 +138,69 @@ pub struct OrphanSession { pub attached: bool, } +/// Tracks a group of agents working on a single multi-agent step +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct MultiAgentGroup { + /// Unique group identifier + pub group_id: String, + /// Ticket this group belongs to + pub ticket_id: String, + /// Step name being executed + pub step_name: String, + /// Step type (`multi_model`, `multi_prompt`, `matrixed`) + pub step_type: String, + /// Agent IDs in this group (populated as sub-agents launch) + pub agent_ids: Vec, + /// Current execution phase + pub phase: MultiAgentPhase, + /// Collected outputs from completed sub-agents, keyed by `variant_key` + /// (delegator name for `multi_model`, index for `multi_prompt`, + /// `{delegator}:{prompt_idx}` for `matrixed`). + #[serde(default)] + pub individual_outputs: HashMap, + /// Final aggregated output (set when phase = Complete) + #[serde(default)] + pub aggregated_output: Option, + /// Total sub-agents expected (`agent_ids.len() + pending_launches.len()`). + #[serde(default)] + pub expected_total: usize, + /// Sub-agents that still need launching (waiting for a free slot). + #[serde(default)] + pub pending_launches: Vec, + /// Maps launched `agent_id` to the `variant_key` used as the output key. + #[serde(default)] + pub agent_variant_keys: HashMap, +} + +/// A sub-agent that has been planned but not yet launched (slot queue). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct PendingSubAgent { + /// Delegator (from `config.delegators`) this sub-agent should use. + pub delegator_name: String, + /// Fully-rendered prompt text for this sub-agent. + pub prompt: String, + /// Key under which this sub-agent's output is recorded (see `individual_outputs`). + pub variant_key: String, +} + +/// Execution phase for a multi-agent group +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum MultiAgentPhase { + /// Phase 1: all sub-agents running the initial prompt + #[default] + FanOut, + /// Phase 2: voting/selection round (`multi_model` with `share_answers`) + Voting, + /// All done, aggregated output ready + Complete, + /// One or more sub-agents failed + Failed, +} + impl State { pub fn load(config: &Config) -> Result { let state_path = config.state_path(); @@ -154,6 +221,7 @@ impl State { completed: Vec::new(), project_llm_stats: HashMap::new(), project_collection_prefs: HashMap::new(), + multi_agent_groups: Vec::new(), state_path, }) } @@ -761,6 +829,153 @@ impl State { .map(|(k, v)| (k.as_str(), v.as_str())) .collect() } + + // ── Multi-agent group management ──────────────────────────────── + + /// Create a new multi-agent group with all sub-agents still pending. + /// + /// `expected_total` is taken as `pending.len()`. Sub-agents are moved out of + /// `pending_launches` and into `agent_ids` via `mark_launched` as slots open. + pub fn create_multi_agent_group( + &mut self, + ticket_id: &str, + step_name: &str, + step_type: &str, + pending: Vec, + ) -> Result { + let group_id = Uuid::new_v4().to_string(); + let expected_total = pending.len(); + let group = MultiAgentGroup { + group_id: group_id.clone(), + ticket_id: ticket_id.to_string(), + step_name: step_name.to_string(), + step_type: step_type.to_string(), + agent_ids: Vec::new(), + phase: MultiAgentPhase::FanOut, + individual_outputs: HashMap::new(), + aggregated_output: None, + expected_total, + pending_launches: pending, + agent_variant_keys: HashMap::new(), + }; + self.multi_agent_groups.push(group); + self.save()?; + Ok(group_id) + } + + /// Find the multi-agent group that contains a given agent ID + pub fn get_group_for_agent(&self, agent_id: &str) -> Option<&MultiAgentGroup> { + self.multi_agent_groups + .iter() + .find(|g| g.agent_ids.contains(&agent_id.to_string())) + } + + /// Find the multi-agent group for a ticket's current step + pub fn get_group_for_ticket(&self, ticket_id: &str) -> Option<&MultiAgentGroup> { + self.multi_agent_groups + .iter() + .find(|g| g.ticket_id == ticket_id && g.phase != MultiAgentPhase::Complete) + } + + /// Return the next pending sub-agent for a group (cloned, not removed). + /// + /// Returns `None` if the group has no pending launches or does not exist. + pub fn next_pending_for_group(&self, group_id: &str) -> Option { + self.multi_agent_groups + .iter() + .find(|g| g.group_id == group_id) + .and_then(|g| g.pending_launches.first().cloned()) + } + + /// Remove the pending entry matching `variant_key` and record the new + /// `agent_id` against it. Persists state. + pub fn mark_launched( + &mut self, + group_id: &str, + variant_key: &str, + agent_id: &str, + ) -> Result<()> { + if let Some(group) = self + .multi_agent_groups + .iter_mut() + .find(|g| g.group_id == group_id) + { + if let Some(pos) = group + .pending_launches + .iter() + .position(|p| p.variant_key == variant_key) + { + group.pending_launches.remove(pos); + } + group.agent_ids.push(agent_id.to_string()); + group + .agent_variant_keys + .insert(agent_id.to_string(), variant_key.to_string()); + self.save()?; + } + Ok(()) + } + + /// Record an individual agent's output in its group, keyed by the + /// agent's `variant_key`. Returns `true` when all `expected_total` + /// sub-agents have recorded outputs. + pub fn record_agent_output( + &mut self, + agent_id: &str, + output: serde_json::Value, + ) -> Result { + if let Some(group) = self + .multi_agent_groups + .iter_mut() + .find(|g| g.agent_ids.contains(&agent_id.to_string())) + { + let variant_key = group + .agent_variant_keys + .get(agent_id) + .cloned() + .unwrap_or_else(|| agent_id.to_string()); + group.individual_outputs.insert(variant_key, output); + let all_done = group.individual_outputs.len() >= group.expected_total; + self.save()?; + Ok(all_done) + } else { + Ok(false) + } + } + + /// Update a multi-agent group's phase + pub fn update_group_phase(&mut self, group_id: &str, phase: MultiAgentPhase) -> Result<()> { + if let Some(group) = self + .multi_agent_groups + .iter_mut() + .find(|g| g.group_id == group_id) + { + group.phase = phase; + self.save()?; + } + Ok(()) + } + + /// Set the aggregated output for a group and mark as complete + pub fn complete_group(&mut self, group_id: &str, aggregated: serde_json::Value) -> Result<()> { + if let Some(group) = self + .multi_agent_groups + .iter_mut() + .find(|g| g.group_id == group_id) + { + group.aggregated_output = Some(aggregated); + group.phase = MultiAgentPhase::Complete; + self.save()?; + } + Ok(()) + } + + /// Remove completed/failed groups (cleanup) + pub fn cleanup_finished_groups(&mut self) -> Result<()> { + self.multi_agent_groups + .retain(|g| g.phase != MultiAgentPhase::Complete && g.phase != MultiAgentPhase::Failed); + self.save() + } } #[cfg(test)] @@ -1326,4 +1541,178 @@ mod tests { let result = state.update_agent_tool_and_model("nonexistent-id", "gemini", "pro"); assert!(result.is_ok()); } + + // ─── Multi-agent group tests ──────────────────────────────────────── + + fn pending(variant: &str, delegator: &str) -> PendingSubAgent { + PendingSubAgent { + delegator_name: delegator.to_string(), + prompt: "do the thing".to_string(), + variant_key: variant.to_string(), + } + } + + #[test] + fn test_create_group_sets_expected_total_and_pending() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let gid = state + .create_multi_agent_group( + "FEAT-1", + "review", + "multi_model", + vec![ + pending("claude-opus", "claude-opus"), + pending("gemini-pro", "gemini-pro"), + ], + ) + .unwrap(); + + let group = state + .multi_agent_groups + .iter() + .find(|g| g.group_id == gid) + .unwrap(); + assert_eq!(group.expected_total, 2); + assert_eq!(group.pending_launches.len(), 2); + assert!(group.agent_ids.is_empty()); + assert!(group.agent_variant_keys.is_empty()); + } + + #[test] + fn test_mark_launched_moves_pending_to_agent_ids() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let gid = state + .create_multi_agent_group( + "FEAT-1", + "review", + "multi_model", + vec![ + pending("claude-opus", "claude-opus"), + pending("gemini-pro", "gemini-pro"), + ], + ) + .unwrap(); + + state.mark_launched(&gid, "claude-opus", "agent-A").unwrap(); + + let group = state.get_group_for_agent("agent-A").unwrap(); + assert_eq!(group.agent_ids, vec!["agent-A"]); + assert_eq!(group.pending_launches.len(), 1); + assert_eq!(group.pending_launches[0].variant_key, "gemini-pro"); + assert_eq!( + group.agent_variant_keys.get("agent-A").unwrap(), + "claude-opus" + ); + } + + #[test] + fn test_next_pending_returns_first_then_none_after_all_launched() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let gid = state + .create_multi_agent_group( + "FEAT-1", + "review", + "multi_model", + vec![pending("d1", "d1"), pending("d2", "d2")], + ) + .unwrap(); + + let first = state.next_pending_for_group(&gid).unwrap(); + assert_eq!(first.variant_key, "d1"); + + state.mark_launched(&gid, "d1", "agent-A").unwrap(); + let second = state.next_pending_for_group(&gid).unwrap(); + assert_eq!(second.variant_key, "d2"); + + state.mark_launched(&gid, "d2", "agent-B").unwrap(); + assert!(state.next_pending_for_group(&gid).is_none()); + } + + #[test] + fn test_record_agent_output_keys_by_variant_and_signals_all_done() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let gid = state + .create_multi_agent_group( + "FEAT-1", + "review", + "multi_model", + vec![ + pending("claude-opus", "claude-opus"), + pending("gemini-pro", "gemini-pro"), + ], + ) + .unwrap(); + + state.mark_launched(&gid, "claude-opus", "agent-A").unwrap(); + state.mark_launched(&gid, "gemini-pro", "agent-B").unwrap(); + + let done_after_first = state + .record_agent_output("agent-A", serde_json::json!({"v": 1})) + .unwrap(); + assert!(!done_after_first, "not done after first agent"); + + let done_after_second = state + .record_agent_output("agent-B", serde_json::json!({"v": 2})) + .unwrap(); + assert!(done_after_second, "all done after second agent"); + + let group = state + .multi_agent_groups + .iter() + .find(|g| g.group_id == gid) + .unwrap(); + // Outputs are keyed by variant_key, not agent_id + assert_eq!( + group.individual_outputs.get("claude-opus"), + Some(&serde_json::json!({"v": 1})) + ); + assert_eq!( + group.individual_outputs.get("gemini-pro"), + Some(&serde_json::json!({"v": 2})) + ); + } + + #[test] + fn test_group_roundtrips_through_disk_with_pending_queue() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let gid = state + .create_multi_agent_group( + "FEAT-1", + "review", + "multi_prompt", + vec![pending("0", "claude-opus"), pending("1", "claude-opus")], + ) + .unwrap(); + + state.mark_launched(&gid, "0", "agent-A").unwrap(); + // "1" stays pending + + // Reload and verify shape persists + let reloaded = State::load(&config).unwrap(); + let group = reloaded + .multi_agent_groups + .iter() + .find(|g| g.group_id == gid) + .unwrap(); + assert_eq!(group.expected_total, 2); + assert_eq!(group.agent_ids, vec!["agent-A"]); + assert_eq!(group.pending_launches.len(), 1); + assert_eq!(group.pending_launches[0].variant_key, "1"); + assert_eq!(group.agent_variant_keys.get("agent-A").unwrap(), "0"); + } } diff --git a/src/steps/manager.rs b/src/steps/manager.rs index ec496b0..7050bb3 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -193,10 +193,124 @@ impl StepManager { ); } + // Load step output artifacts into {{ steps.{name}.* }} context + let steps_data = Self::load_step_outputs(ticket); + if !steps_data.is_empty() { + data.insert("steps".to_string(), serde_json::Value::Object(steps_data)); + } + hbs.render_template(template, &serde_json::Value::Object(data)) .context("Failed to render step prompt") } + /// Read a single sub-agent's step output file at + /// `{worktree}/.tickets/steps/{step_name}/{key}.json`. + /// + /// Returns `Value::Null` if the file is missing or can't be parsed; + /// callers should treat that as "sub-agent did not produce output". + pub fn read_agent_step_output( + ticket: &Ticket, + step_name: &str, + key: &str, + ) -> serde_json::Value { + let Some(worktree) = ticket.worktree_path.as_deref() else { + return serde_json::Value::Null; + }; + let path = std::path::PathBuf::from(worktree) + .join(".tickets") + .join("steps") + .join(step_name) + .join(format!("{key}.json")); + match std::fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).unwrap_or(serde_json::Value::Null), + Err(_) => serde_json::Value::Null, + } + } + + /// Write a per-sub-agent output file at + /// `{worktree}/.tickets/steps/{step_name}/{key}.json`. + pub fn write_agent_step_output( + ticket: &Ticket, + step_name: &str, + key: &str, + output: &serde_json::Value, + ) -> anyhow::Result<()> { + let worktree = ticket + .worktree_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("ticket {} has no worktree_path", ticket.id))?; + let dir = std::path::PathBuf::from(worktree) + .join(".tickets") + .join("steps") + .join(step_name); + std::fs::create_dir_all(&dir) + .with_context(|| format!("create_dir_all {}", dir.display()))?; + let path = dir.join(format!("{key}.json")); + let contents = serde_json::to_string_pretty(output)?; + std::fs::write(&path, contents).with_context(|| format!("write {}", path.display()))?; + Ok(()) + } + + /// Write the aggregated step output artifact at + /// `{worktree}/.tickets/steps/{step_name}.output.json`. + /// This is the file `load_step_outputs` reads into `{{ steps.{name}.* }}`. + pub fn write_step_output_artifact( + ticket: &Ticket, + step_name: &str, + output: &serde_json::Value, + ) -> anyhow::Result<()> { + let worktree = ticket + .worktree_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("ticket {} has no worktree_path", ticket.id))?; + let steps_dir = std::path::PathBuf::from(worktree) + .join(".tickets") + .join("steps"); + std::fs::create_dir_all(&steps_dir) + .with_context(|| format!("create_dir_all {}", steps_dir.display()))?; + let path = steps_dir.join(format!("{step_name}.output.json")); + let contents = serde_json::to_string_pretty(output)?; + std::fs::write(&path, contents).with_context(|| format!("write {}", path.display()))?; + Ok(()) + } + + /// Load step output artifact JSON files from `.tickets/steps/` in the ticket's worktree + fn load_step_outputs(ticket: &Ticket) -> serde_json::Map { + let mut steps_map = serde_json::Map::new(); + + let base_path = match &ticket.worktree_path { + Some(p) => std::path::PathBuf::from(p), + None => return steps_map, + }; + + let steps_dir = base_path.join(".tickets").join("steps"); + let entries = match std::fs::read_dir(&steps_dir) { + Ok(e) => e, + Err(_) => return steps_map, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + // Extract step name from "{step_name}.output.json" + let filename = match path.file_stem().and_then(|s| s.to_str()) { + Some(f) => f.to_string(), + None => continue, + }; + let step_name = filename.strip_suffix(".output").unwrap_or(&filename); + + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(value) = serde_json::from_str::(&content) { + steps_map.insert(step_name.to_string(), value); + } + } + } + + steps_map + } + /// Get allowed tools for current step pub fn get_allowed_tools(&self, ticket: &Ticket) -> Vec { self.current_step(ticket) @@ -344,4 +458,176 @@ mod tests { assert!(progress.contains("[code]")); assert!(progress.contains("plan >")); } + + #[test] + fn test_load_step_outputs_no_worktree() { + let ticket = make_test_ticket("FEAT", "plan"); + let outputs = StepManager::load_step_outputs(&ticket); + assert!(outputs.is_empty()); + } + + #[test] + fn test_load_step_outputs_with_artifacts() { + let tmp = tempfile::tempdir().unwrap(); + let steps_dir = tmp.path().join(".tickets").join("steps"); + std::fs::create_dir_all(&steps_dir).unwrap(); + + // Write a classifier output artifact + std::fs::write( + steps_dir.join("classify.output.json"), + r#"{"output_type": "enum", "value": "high"}"#, + ) + .unwrap(); + + // Write a multi-model output artifact + std::fs::write( + steps_dir.join("consensus.output.json"), + r#"{"winner_response": "Use approach A"}"#, + ) + .unwrap(); + + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(tmp.path().to_string_lossy().to_string()); + + let outputs = StepManager::load_step_outputs(&ticket); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs["classify"]["value"], "high"); + assert_eq!(outputs["consensus"]["winner_response"], "Use approach A"); + } + + #[test] + fn test_load_step_outputs_ignores_non_json() { + let tmp = tempfile::tempdir().unwrap(); + let steps_dir = tmp.path().join(".tickets").join("steps"); + std::fs::create_dir_all(&steps_dir).unwrap(); + + std::fs::write(steps_dir.join("notes.txt"), "not json").unwrap(); + std::fs::write(steps_dir.join("valid.output.json"), r#"{"value": true}"#).unwrap(); + + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(tmp.path().to_string_lossy().to_string()); + + let outputs = StepManager::load_step_outputs(&ticket); + assert_eq!(outputs.len(), 1); + assert!(outputs.contains_key("valid")); + } + + #[test] + fn test_render_prompt_with_step_outputs() { + let tmp = tempfile::tempdir().unwrap(); + let steps_dir = tmp.path().join(".tickets").join("steps"); + std::fs::create_dir_all(&steps_dir).unwrap(); + + std::fs::write( + steps_dir.join("classify.output.json"), + r#"{"value": "critical"}"#, + ) + .unwrap(); + + let config = Config::default(); + let manager = StepManager::new(&config); + + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(tmp.path().to_string_lossy().to_string()); + + let result = manager + .render_prompt("Severity is {{ steps.classify.value }}", &ticket, None) + .unwrap(); + + assert_eq!(result, "Severity is critical"); + } + + #[test] + fn test_render_prompt_missing_step_output_is_empty() { + let config = Config::default(); + let manager = StepManager::new(&config); + + let ticket = make_test_ticket("FEAT", "plan"); + let result = manager + .render_prompt("Severity is {{ steps.classify.value }}", &ticket, None) + .unwrap(); + + // strict_mode is false, so missing values render as empty string + assert_eq!(result, "Severity is "); + } + + // ─── Per-sub-agent artifact tests ───────────────────────────────── + + #[test] + fn test_read_agent_step_output_missing_returns_null() { + let tmp = tempfile::tempdir().unwrap(); + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(tmp.path().to_string_lossy().to_string()); + + let out = StepManager::read_agent_step_output(&ticket, "review", "agent-A"); + assert_eq!(out, serde_json::Value::Null); + } + + #[test] + fn test_read_agent_step_output_no_worktree_returns_null() { + let ticket = make_test_ticket("FEAT", "plan"); + let out = StepManager::read_agent_step_output(&ticket, "review", "agent-A"); + assert_eq!(out, serde_json::Value::Null); + } + + #[test] + fn test_write_and_read_agent_step_output_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(tmp.path().to_string_lossy().to_string()); + + let payload = serde_json::json!({ + "summary": "looks good", + "score": 87, + }); + + StepManager::write_agent_step_output(&ticket, "review", "agent-A", &payload).unwrap(); + + // File exists at expected path + let expected = tmp + .path() + .join(".tickets") + .join("steps") + .join("review") + .join("agent-A.json"); + assert!( + expected.exists(), + "output file should exist at {expected:?}" + ); + + let round = StepManager::read_agent_step_output(&ticket, "review", "agent-A"); + assert_eq!(round, payload); + } + + #[test] + fn test_write_step_output_artifact_creates_file_read_by_load_step_outputs() { + let tmp = tempfile::tempdir().unwrap(); + let mut ticket = make_test_ticket("FEAT", "plan"); + ticket.worktree_path = Some(tmp.path().to_string_lossy().to_string()); + + let aggregated = serde_json::json!({ + "type": "multi_model", + "winner_response": "Use approach A", + }); + + StepManager::write_step_output_artifact(&ticket, "consensus", &aggregated).unwrap(); + + // load_step_outputs picks it up under the step name + let outputs = StepManager::load_step_outputs(&ticket); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs["consensus"]["winner_response"], "Use approach A"); + } + + #[test] + fn test_write_agent_step_output_without_worktree_errors() { + let ticket = make_test_ticket("FEAT", "plan"); + let err = StepManager::write_agent_step_output( + &ticket, + "review", + "agent-A", + &serde_json::json!({}), + ) + .unwrap_err(); + assert!(err.to_string().contains("worktree_path")); + } } diff --git a/src/steps/session.rs b/src/steps/session.rs index 87d77b6..d0a9820 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -62,8 +62,12 @@ impl StepSession { .get_step_prompt(ticket, pr_config) .context("Failed to get step prompt")?; - // Build a comprehensive prompt with context - let allowed_tools = step.allowed_tools.join(", "); + // Use effective allowed_tools (type-specific configs may override) + let effective_tools = crate::templates::step_type::effective_allowed_tools(step); + let allowed_tools = effective_tools.join(", "); + + // Get step-type-specific prompt augmentation + let type_augmentation = crate::templates::step_type::prompt_augmentation(step); let prompt = format!( r#"You are working on step "{}" ({}) for ticket {}. @@ -80,6 +84,7 @@ impl StepSession { ## Allowed Tools You may use these tools for this step: {} +{type_augmentation} ## Guidelines - Focus only on completing this specific step @@ -279,6 +284,7 @@ mod tests { StepSchema { name: "plan".to_string(), display_name: Some("Planning".to_string()), + step_type: crate::templates::schema::StepTypeTag::Task, outputs: vec![], prompt: "Create a plan".to_string(), allowed_tools: vec!["Read".to_string(), "Glob".to_string()], @@ -293,6 +299,13 @@ mod tests { json_schema_file: None, artifact_patterns: vec![], agent: None, + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, } } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index f269fa9..13b0a02 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -6,6 +6,7 @@ #![allow(dead_code)] // Registry helper functions used when registry is integrated pub mod schema; +pub mod step_type; use crate::issuetypes::IssueTypeRegistry; use schema::TemplateSchema; diff --git a/src/templates/schema.rs b/src/templates/schema.rs index 5f4f5a4..28334d7 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -2,12 +2,13 @@ //! Schema definitions for issuetype templates +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::permissions::{ProviderCliArgs, StepPermissions}; /// Schema definition for an issuetype template -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct TemplateSchema { /// Unique issuetype key (e.g., FEAT, FIX, SPIKE, INV, TASK) pub key: String, @@ -45,7 +46,7 @@ fn default_true() -> bool { } /// Execution mode for an issuetype -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { /// Runs without human interaction @@ -55,7 +56,7 @@ pub enum ExecutionMode { } /// Schema definition for a single field in a template -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct FieldSchema { /// Field identifier (matches handlebar variable name) pub name: String, @@ -91,7 +92,7 @@ pub struct FieldSchema { } /// Auto-generation strategies for fields -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum AutoGenStrategy { /// Generate ID from timestamp (e.g., FEAT-1234) @@ -105,7 +106,7 @@ pub enum AutoGenStrategy { } /// Types of fields supported in template schemas -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum FieldType { /// Single-line text input @@ -123,19 +124,21 @@ pub enum FieldType { } /// Schema definition for a lifecycle step -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct StepSchema { + // ── Common base (all step types) ──────────────────────────────── /// Step identifier (lowercase) pub name: String, /// Human-readable step name #[serde(default)] pub display_name: Option, + /// Step type discriminator (defaults to "task" for backward compatibility) + #[serde(default = "default_step_type", rename = "type")] + pub step_type: StepTypeTag, /// Types of outputs this step produces pub outputs: Vec, /// Initial prompt template for the Claude agent pub prompt: String, - /// Claude Code tools allowed in this step - pub allowed_tools: Vec, /// Type of review required for this step (none, plan, visual, pr) #[serde(default)] pub review_type: ReviewType, @@ -148,6 +151,14 @@ pub struct StepSchema { /// Name of the next step (None for final step) #[serde(default)] pub next_step: Option, + + // ── Task fields (backward-compat, used when type=task) ────────── + /// Claude Code tools allowed in this step + #[serde(default)] + pub allowed_tools: Vec, + /// Optional agent (delegator) name for this step (overrides ticket's default agent) + #[serde(default)] + pub agent: Option, /// Provider-agnostic permissions for this step #[serde(default)] pub permissions: Option, @@ -157,9 +168,6 @@ pub struct StepSchema { /// Preferred LLM permission mode for this step #[serde(default)] pub permission_mode: PermissionMode, - /// Optional agent (delegator) name for this step (overrides ticket's default agent) - #[serde(default)] - pub agent: Option, /// Inline JSON schema for structured output (Claude-specific) #[serde(default, rename = "jsonSchema")] pub json_schema: Option, @@ -169,10 +177,33 @@ pub struct StepSchema { /// File glob patterns in the worktree that signal this step is complete #[serde(default)] pub artifact_patterns: Vec, + + // ── Type-specific configs ─────────────────────────────────────── + /// Configuration for classifier steps (required when type=classifier) + #[serde(default)] + pub classifier_config: Option, + /// Configuration for RAG steps (required when type=rag) + #[serde(default)] + pub rag_config: Option, + /// Configuration for delegator steps (required when type=delegator) + #[serde(default)] + pub delegator_config: Option, + /// Configuration for MCP steps (required when type=mcp) + #[serde(default)] + pub mcp_config: Option, + /// Configuration for multi-model steps (required when `type=multi_model`) + #[serde(default)] + pub multi_model_config: Option, + /// Configuration for multi-prompt steps (required when `type=multi_prompt`) + #[serde(default)] + pub multi_prompt_config: Option, + /// Configuration for matrixed steps (required when type=matrixed) + #[serde(default)] + pub matrixed_config: Option, } /// Status category for a step -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub enum StepStatus { Todo, Doing, @@ -181,7 +212,7 @@ pub enum StepStatus { } /// Types of outputs a step can produce -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum StepOutput { /// Implementation plan @@ -203,7 +234,7 @@ pub enum StepOutput { } /// Action to take when a step is rejected -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct OnReject { /// Step name to return to on rejection pub goto_step: String, @@ -212,7 +243,7 @@ pub struct OnReject { } /// Permission mode for LLM interaction -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum PermissionMode { /// Default permission mode - standard interactive behavior @@ -227,7 +258,7 @@ pub enum PermissionMode { } /// Type of review required for a step -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum ReviewType { /// No review required - proceed automatically @@ -237,12 +268,12 @@ pub enum ReviewType { Plan, /// Visual confirmation via browser Visual, - /// GitHub PR review workflow + /// Git interface PR review workflow Pr, } /// Configuration for visual review steps -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct VisualReviewConfig { /// URL to open for visual check (supports handlebars templates) pub url: String, @@ -254,6 +285,253 @@ pub struct VisualReviewConfig { pub startup_timeout_secs: Option, } +/// Discriminator tag for step types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum StepTypeTag { + /// Default pass-through task step + #[default] + Task, + /// Structured typed output (boolean, number, string, enum) + Classifier, + /// Context-augmented prompting with retrieved sources + Rag, + /// Runs with a specific delegator and prompt flavor + Delegator, + /// Ensures specific MCP tools are available + Mcp, + /// Fan-out to N delegators, then aggregate via voting + MultiModel, + /// N prompt variations with one model, then select best + MultiPrompt, + /// N x M delegators x prompt variations + Matrixed, +} + +fn default_step_type() -> StepTypeTag { + StepTypeTag::Task +} + +// ── Classifier ────────────────────────────────────────────────────────── + +/// Configuration for classifier steps that return structured typed output +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ClassifierConfig { + /// What type of answer the classifier returns + pub output_type: ClassifierOutputType, + /// For enum type: the allowed options + #[serde(default)] + pub options: Option>, + /// For `short_string`: max character length (default 255) + #[serde(default)] + pub max_length: Option, + /// Agent/delegator to use (overrides issuetype default) + #[serde(default)] + pub agent: Option, +} + +/// Output types for classifier steps +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ClassifierOutputType { + /// true/false answer + Boolean, + /// Numeric answer (integer or float) + Number, + /// Short string < 255 chars + ShortString, + /// Longer arbitrary-length text + BigText, + /// One of a fixed set of options + Enum, +} + +// ── RAG ───────────────────────────────────────────────────────────────── + +/// Configuration for RAG (retrieval-augmented generation) steps +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RagConfig { + /// Context sources to retrieve before running the prompt + pub sources: Vec, + /// Maximum tokens of context to inject (default: 50000) + #[serde(default)] + pub max_context_tokens: Option, + /// Agent/delegator to use + #[serde(default)] + pub agent: Option, + /// Tools allowed for the agent + #[serde(default)] + pub allowed_tools: Vec, +} + +/// A source of context for RAG steps +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RagSource { + /// Match files by glob pattern + Glob { + /// Glob pattern relative to project root + pattern: String, + }, + /// Single file path + File { + /// File path relative to project root + path: String, + }, + /// Retrieve via MCP server tool + Mcp { + /// MCP server name + server: String, + /// Tool name on the MCP server + tool: String, + /// Optional query template (Handlebars) + #[serde(default)] + query: Option, + }, +} + +// ── Delegator Step ────────────────────────────────────────────────────── + +/// Configuration for delegator steps that run with a specific model+flavor +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DelegatorStepConfig { + /// Named delegator reference (from config.delegators) + pub delegator: String, + /// Additional prompt flavor text prepended to the step prompt + #[serde(default)] + pub prompt_flavor: Option, + /// Tools allowed + #[serde(default)] + pub allowed_tools: Vec, + /// Permissions + #[serde(default)] + pub permissions: Option, +} + +// ── MCP Step ──────────────────────────────────────────────────────────── + +/// Configuration for MCP steps that require specific MCP tools +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct McpStepConfig { + /// MCP tools that MUST be available (step fails if missing) + pub required_tools: Vec, + /// MCP tools that SHOULD be available (warning if missing) + #[serde(default)] + pub optional_tools: Vec, + /// Agent/delegator to use + #[serde(default)] + pub agent: Option, + /// Tools allowed (in addition to MCP tools) + #[serde(default)] + pub allowed_tools: Vec, +} + +/// Reference to a specific MCP server tool +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct McpToolRef { + /// MCP server name + pub server: String, + /// Specific tool name (None = all tools from this server) + #[serde(default)] + pub tool: Option, +} + +// ── Multi-Model ───────────────────────────────────────────────────────── + +/// Configuration for multi-model delegation steps (fan-out + vote) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MultiModelConfig { + /// Named delegator references (from config.delegators), minimum 2 + pub delegators: Vec, + /// How to aggregate/select the final answer + pub voting_strategy: VotingStrategy, + /// Whether to share all answers with all models in the voting round + #[serde(default = "default_true")] + pub share_answers: bool, + /// Prompt for the voting round (Handlebars, receives {{ answers }} array) + #[serde(default)] + pub voting_prompt: Option, + /// How the voting round executes + #[serde(default)] + pub voting_mode: VotingMode, +} + +/// Voting strategy for multi-model steps +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum VotingStrategy { + /// Simple majority vote + Majority, + /// Ranked choice voting + Ranked, + /// Unanimous required (falls back to longest answer if no consensus) + Unanimous, +} + +/// How the voting round is executed in multi-model steps +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum VotingMode { + /// One agent reviews all answers and picks winner (uses 1 slot) + #[default] + SingleJudge, + /// All original delegators re-run with shared answers, each votes (uses N slots) + MultiVoter, +} + +// ── Multi-Prompt ──────────────────────────────────────────────────────── + +/// Configuration for multi-prompt interrogation steps (N variations, select best) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MultiPromptConfig { + /// Prompt variations (Handlebars templates), minimum 2 + pub prompt_variations: Vec, + /// How to select the best result + pub selection_strategy: SelectionStrategy, + /// Agent/delegator to use for all variations + #[serde(default)] + pub agent: Option, + /// Prompt for the selection/review round + #[serde(default)] + pub selection_prompt: Option, +} + +/// Selection strategy for multi-prompt steps +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SelectionStrategy { + /// Model reviews all outputs and picks the best + ModelChoice, + /// Model scores each and highest wins + Scored, +} + +// ── Matrixed ──────────────────────────────────────────────────────────── + +/// Configuration for matrixed work output steps (N x M delegators x prompts) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MatrixedConfig { + /// Named delegator references (N), minimum 2 + pub delegators: Vec, + /// Prompt variations (M) — Handlebars templates, minimum 2 + pub prompt_variations: Vec, + /// How to organize/present the N x M output + pub output_format: MatrixedOutputFormat, + /// Optional aggregation prompt (receives the full matrix of results) + #[serde(default)] + pub aggregation_prompt: Option, +} + +/// Output format for matrixed steps +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MatrixedOutputFormat { + /// Each cell's output in `temp_dir/{delegator}/{prompt_index}/` + Directory, + /// Structured N x M JSON matrix in step output artifact + Structured, +} + impl TemplateSchema { /// Parse a template schema from JSON pub fn from_json(json: &str) -> Result { @@ -288,7 +566,7 @@ impl TemplateSchema { } } - // Check step transitions + // Check step transitions and type-specific config let step_names: Vec<&str> = self.steps.iter().map(|s| s.name.as_str()).collect(); for step in &self.steps { if let Some(ref next) = step.next_step { @@ -308,6 +586,9 @@ impl TemplateSchema { )); } } + + // Validate step type config presence and constraints + step.validate_type_config(&mut errors); } if errors.is_empty() { @@ -366,6 +647,122 @@ impl StepSchema { pub fn has_artifact_patterns(&self) -> bool { !self.artifact_patterns.is_empty() } + + /// Validate that the step type config is present and internally consistent + pub fn validate_type_config(&self, errors: &mut Vec) { + match self.step_type { + StepTypeTag::Task => { + // Task steps don't require any specific config + } + StepTypeTag::Classifier => { + if let Some(ref cfg) = self.classifier_config { + // Enum classifiers must have options + if cfg.output_type == ClassifierOutputType::Enum + && cfg.options.as_ref().is_none_or(Vec::is_empty) + { + errors.push(format!( + "Step '{}': classifier with output_type 'enum' must have non-empty options", + self.name + )); + } + } else { + errors.push(format!( + "Step '{}': type 'classifier' requires classifier_config", + self.name + )); + } + } + StepTypeTag::Rag => { + if let Some(ref cfg) = self.rag_config { + if cfg.sources.is_empty() { + errors.push(format!( + "Step '{}': rag_config must have at least one source", + self.name + )); + } + } else { + errors.push(format!( + "Step '{}': type 'rag' requires rag_config", + self.name + )); + } + } + StepTypeTag::Delegator => { + if self.delegator_config.is_none() { + errors.push(format!( + "Step '{}': type 'delegator' requires delegator_config", + self.name + )); + } + } + StepTypeTag::Mcp => { + if let Some(ref cfg) = self.mcp_config { + if cfg.required_tools.is_empty() && cfg.optional_tools.is_empty() { + errors.push(format!( + "Step '{}': mcp_config must have at least one required or optional tool", + self.name + )); + } + } else { + errors.push(format!( + "Step '{}': type 'mcp' requires mcp_config", + self.name + )); + } + } + StepTypeTag::MultiModel => { + if let Some(ref cfg) = self.multi_model_config { + if cfg.delegators.len() < 2 { + errors.push(format!( + "Step '{}': multi_model_config requires at least 2 delegators", + self.name + )); + } + } else { + errors.push(format!( + "Step '{}': type 'multi_model' requires multi_model_config", + self.name + )); + } + } + StepTypeTag::MultiPrompt => { + if let Some(ref cfg) = self.multi_prompt_config { + if cfg.prompt_variations.len() < 2 { + errors.push(format!( + "Step '{}': multi_prompt_config requires at least 2 prompt_variations", + self.name + )); + } + } else { + errors.push(format!( + "Step '{}': type 'multi_prompt' requires multi_prompt_config", + self.name + )); + } + } + StepTypeTag::Matrixed => { + if let Some(ref cfg) = self.matrixed_config { + if cfg.delegators.len() < 2 { + errors.push(format!( + "Step '{}': matrixed_config requires at least 2 delegators", + self.name + )); + } + if cfg.prompt_variations.len() < 2 { + errors.push(format!( + "Step '{}': matrixed_config requires at least 2 prompt_variations", + self.name + )); + } + } else { + errors.push(format!( + "Step '{}': type 'matrixed' requires matrixed_config", + self.name + )); + } + } + } + } } #[cfg(test)] @@ -881,4 +1278,563 @@ mod tests { let schema = TemplateSchema::from_json(json).unwrap(); assert!(schema.steps[0].agent.is_none()); } + + // ── Step type tests ───────────────────────────────────────────── + + #[test] + fn test_step_type_defaults_to_task() { + 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": "execute", + "outputs": ["code"], + "prompt": "Do the thing", + "allowed_tools": ["Read"] + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::Task); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_classifier_step_enum() { + 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": "classify", + "type": "classifier", + "outputs": ["report"], + "prompt": "Classify the severity", + "allowed_tools": [], + "classifier_config": { + "output_type": "enum", + "options": ["critical", "high", "medium", "low"] + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::Classifier); + let cfg = schema.steps[0].classifier_config.as_ref().unwrap(); + assert_eq!(cfg.output_type, ClassifierOutputType::Enum); + assert_eq!(cfg.options.as_ref().unwrap().len(), 4); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_classifier_step_boolean() { + 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", + "type": "classifier", + "outputs": ["report"], + "prompt": "Does this pass the bar?", + "allowed_tools": [], + "classifier_config": { + "output_type": "boolean" + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let cfg = schema.steps[0].classifier_config.as_ref().unwrap(); + assert_eq!(cfg.output_type, ClassifierOutputType::Boolean); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_classifier_enum_missing_options_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": "classify", + "type": "classifier", + "outputs": ["report"], + "prompt": "Classify it", + "allowed_tools": [], + "classifier_config": { + "output_type": "enum" + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors[0].contains("non-empty options")); + } + + #[test] + fn test_classifier_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": "classify", + "type": "classifier", + "outputs": ["report"], + "prompt": "Classify it", + "allowed_tools": [] + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors[0].contains("requires classifier_config")); + } + + #[test] + fn test_rag_step() { + 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": "review", + "type": "rag", + "outputs": ["review"], + "prompt": "Review with context", + "allowed_tools": [], + "rag_config": { + "sources": [ + { "type": "glob", "pattern": "docs/**/*.md" }, + { "type": "file", "path": "ARCHITECTURE.md" }, + { "type": "mcp", "server": "confluence", "tool": "search", "query": "test query" } + ], + "max_context_tokens": 50000 + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::Rag); + let cfg = schema.steps[0].rag_config.as_ref().unwrap(); + assert_eq!(cfg.sources.len(), 3); + assert_eq!(cfg.max_context_tokens, Some(50000)); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_rag_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": "review", + "type": "rag", + "outputs": ["review"], + "prompt": "Review", + "allowed_tools": [] + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err()[0].contains("requires rag_config")); + } + + #[test] + fn test_multi_model_step() { + 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": "consensus", + "type": "multi_model", + "outputs": ["review"], + "prompt": "Review this PR", + "allowed_tools": [], + "multi_model_config": { + "delegators": ["claude-opus", "gemini-pro", "codex-high"], + "voting_strategy": "majority", + "share_answers": true + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::MultiModel); + let cfg = schema.steps[0].multi_model_config.as_ref().unwrap(); + assert_eq!(cfg.delegators.len(), 3); + assert_eq!(cfg.voting_strategy, VotingStrategy::Majority); + assert!(cfg.share_answers); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_multi_model_too_few_delegators_fails() { + 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": "consensus", + "type": "multi_model", + "outputs": ["review"], + "prompt": "Review", + "allowed_tools": [], + "multi_model_config": { + "delegators": ["only-one"], + "voting_strategy": "majority" + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err()[0].contains("at least 2 delegators")); + } + + #[test] + fn test_multi_prompt_step() { + 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": "explore", + "type": "multi_prompt", + "outputs": ["plan"], + "prompt": "Base context", + "allowed_tools": [], + "multi_prompt_config": { + "prompt_variations": [ + "Approach as refactoring", + "Approach as greenfield" + ], + "selection_strategy": "model_choice" + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::MultiPrompt); + let cfg = schema.steps[0].multi_prompt_config.as_ref().unwrap(); + assert_eq!(cfg.prompt_variations.len(), 2); + assert_eq!(cfg.selection_strategy, SelectionStrategy::ModelChoice); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_matrixed_step() { + 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": "matrix", + "type": "matrixed", + "outputs": ["report"], + "prompt": "Analyze the codebase", + "allowed_tools": [], + "matrixed_config": { + "delegators": ["claude-opus", "gemini-pro"], + "prompt_variations": [ + "Focus on performance", + "Focus on security", + "Focus on maintainability" + ], + "output_format": "structured" + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::Matrixed); + let cfg = schema.steps[0].matrixed_config.as_ref().unwrap(); + assert_eq!(cfg.delegators.len(), 2); + assert_eq!(cfg.prompt_variations.len(), 3); + assert_eq!(cfg.output_format, MatrixedOutputFormat::Structured); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_delegator_step() { + 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": "security_review", + "type": "delegator", + "outputs": ["review"], + "prompt": "Review for security", + "allowed_tools": [], + "delegator_config": { + "delegator": "claude-opus-security", + "prompt_flavor": "You are a security expert." + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::Delegator); + let cfg = schema.steps[0].delegator_config.as_ref().unwrap(); + assert_eq!(cfg.delegator, "claude-opus-security"); + assert_eq!( + cfg.prompt_flavor.as_deref(), + Some("You are a security expert.") + ); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_mcp_step() { + 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": "deploy", + "type": "mcp", + "outputs": ["report"], + "prompt": "Deploy infrastructure", + "allowed_tools": [], + "mcp_config": { + "required_tools": [ + { "server": "terraform", "tool": "plan" }, + { "server": "terraform", "tool": "apply" } + ], + "optional_tools": [ + { "server": "slack" } + ] + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + assert_eq!(schema.steps[0].step_type, StepTypeTag::Mcp); + let cfg = schema.steps[0].mcp_config.as_ref().unwrap(); + assert_eq!(cfg.required_tools.len(), 2); + assert_eq!(cfg.required_tools[0].server, "terraform"); + assert_eq!(cfg.optional_tools.len(), 1); + assert!(cfg.optional_tools[0].tool.is_none()); + assert!(schema.validate().is_ok()); + } + + #[test] + fn test_mcp_empty_tools_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": "deploy", + "type": "mcp", + "outputs": ["report"], + "prompt": "Deploy", + "allowed_tools": [], + "mcp_config": { + "required_tools": [], + "optional_tools": [] + } + } + ] + }"#; + + let schema = TemplateSchema::from_json(json).unwrap(); + let result = schema.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err()[0].contains("at least one required or optional tool")); + } } diff --git a/src/templates/step_type.rs b/src/templates/step_type.rs new file mode 100644 index 0000000..df382d5 --- /dev/null +++ b/src/templates/step_type.rs @@ -0,0 +1,891 @@ +//! Step-type-specific prompt augmentation and config derivation +//! +//! Handles the differences between step types (task, classifier, rag, delegator, mcp) +//! by augmenting prompts and deriving configuration from type-specific configs. + +use crate::templates::schema::{ + ClassifierConfig, ClassifierOutputType, DelegatorStepConfig, McpStepConfig, RagConfig, + RagSource, StepSchema, StepTypeTag, +}; + +/// Generate a JSON schema from a classifier config's output type +pub fn classifier_json_schema(config: &ClassifierConfig) -> serde_json::Value { + let value_schema = match config.output_type { + ClassifierOutputType::Boolean => serde_json::json!({ "type": "boolean" }), + ClassifierOutputType::Number => serde_json::json!({ "type": "number" }), + ClassifierOutputType::ShortString => { + let max_len = config.max_length.unwrap_or(255); + serde_json::json!({ "type": "string", "maxLength": max_len }) + } + ClassifierOutputType::BigText => serde_json::json!({ "type": "string" }), + ClassifierOutputType::Enum => { + if let Some(ref options) = config.options { + serde_json::json!({ "type": "string", "enum": options }) + } else { + serde_json::json!({ "type": "string" }) + } + } + }; + + serde_json::json!({ + "type": "object", + "required": ["value"], + "additionalProperties": false, + "properties": { + "value": value_schema, + "reasoning": { + "type": "string", + "description": "Brief explanation of why this value was chosen" + } + } + }) +} + +/// Generate prompt augmentation text for a step based on its type. +/// Returns additional text to append to the step prompt, or empty string for task steps. +pub fn prompt_augmentation(step: &StepSchema) -> String { + match step.step_type { + StepTypeTag::Task => String::new(), + + StepTypeTag::Classifier => { + if let Some(ref cfg) = step.classifier_config { + classifier_prompt_augmentation(cfg) + } else { + String::new() + } + } + + StepTypeTag::Rag => { + if let Some(ref cfg) = step.rag_config { + rag_prompt_augmentation(cfg) + } else { + String::new() + } + } + + StepTypeTag::Delegator => { + if let Some(ref cfg) = step.delegator_config { + delegator_prompt_augmentation(cfg) + } else { + String::new() + } + } + + StepTypeTag::Mcp => { + if let Some(ref cfg) = step.mcp_config { + mcp_prompt_augmentation(cfg) + } else { + String::new() + } + } + + // Multi-agent types handled in Phase 4 + StepTypeTag::MultiModel | StepTypeTag::MultiPrompt | StepTypeTag::Matrixed => String::new(), + } +} + +/// Derive the effective `allowed_tools` for a step, accounting for type-specific configs +pub fn effective_allowed_tools(step: &StepSchema) -> &[String] { + match step.step_type { + StepTypeTag::Rag => { + if let Some(ref cfg) = step.rag_config { + if !cfg.allowed_tools.is_empty() { + return &cfg.allowed_tools; + } + } + &step.allowed_tools + } + StepTypeTag::Delegator => { + if let Some(ref cfg) = step.delegator_config { + if !cfg.allowed_tools.is_empty() { + return &cfg.allowed_tools; + } + } + &step.allowed_tools + } + StepTypeTag::Mcp => { + if let Some(ref cfg) = step.mcp_config { + if !cfg.allowed_tools.is_empty() { + return &cfg.allowed_tools; + } + } + &step.allowed_tools + } + _ => &step.allowed_tools, + } +} + +/// Derive the effective agent (delegator) name for a step +pub fn effective_agent(step: &StepSchema) -> Option<&str> { + match step.step_type { + StepTypeTag::Classifier => step + .classifier_config + .as_ref() + .and_then(|c| c.agent.as_deref()) + .or(step.agent.as_deref()), + StepTypeTag::Rag => step + .rag_config + .as_ref() + .and_then(|c| c.agent.as_deref()) + .or(step.agent.as_deref()), + StepTypeTag::Delegator => step.delegator_config.as_ref().map(|c| c.delegator.as_str()), + StepTypeTag::Mcp => step + .mcp_config + .as_ref() + .and_then(|c| c.agent.as_deref()) + .or(step.agent.as_deref()), + _ => step.agent.as_deref(), + } +} + +// ── Per-type prompt augmentation ──────────────────────────────────────── + +fn classifier_prompt_augmentation(cfg: &ClassifierConfig) -> String { + let type_desc = match cfg.output_type { + ClassifierOutputType::Boolean => "a boolean (true or false)".to_string(), + ClassifierOutputType::Number => "a number".to_string(), + ClassifierOutputType::ShortString => { + let max = cfg.max_length.unwrap_or(255); + format!("a short string (max {max} characters)") + } + ClassifierOutputType::BigText => "a text string of any length".to_string(), + ClassifierOutputType::Enum => { + if let Some(ref opts) = cfg.options { + format!("one of: {}", opts.join(", ")) + } else { + "one of the given options".to_string() + } + } + }; + + format!( + "\n\n## Structured Output Required\n\ + Your response MUST be valid JSON matching the provided schema.\n\ + The `value` field must be {type_desc}.\n\ + Include a brief `reasoning` field explaining your choice." + ) +} + +fn rag_prompt_augmentation(cfg: &RagConfig) -> String { + let mut parts = vec!["\n\n## Retrieved Context".to_string()]; + parts.push("The following context sources have been loaded for this step:".to_string()); + + for source in &cfg.sources { + match source { + RagSource::Glob { pattern } => { + parts.push(format!("- Files matching `{pattern}`")); + } + RagSource::File { path } => { + parts.push(format!("- File: `{path}`")); + } + RagSource::Mcp { server, tool, .. } => { + parts.push(format!("- MCP retrieval: {server}/{tool}")); + } + } + } + + if let Some(max_tokens) = cfg.max_context_tokens { + parts.push(format!( + "\nContext is limited to approximately {max_tokens} tokens." + )); + } + + parts.join("\n") +} + +fn delegator_prompt_augmentation(cfg: &DelegatorStepConfig) -> String { + match &cfg.prompt_flavor { + Some(flavor) => format!("\n\n## Role\n{flavor}"), + None => String::new(), + } +} + +fn mcp_prompt_augmentation(cfg: &McpStepConfig) -> String { + let mut parts = vec!["\n\n## Available MCP Tools".to_string()]; + + if !cfg.required_tools.is_empty() { + parts.push("Required tools (use these to complete the step):".to_string()); + for tool_ref in &cfg.required_tools { + if let Some(ref tool_name) = tool_ref.tool { + parts.push(format!("- `{}/{tool_name}`", tool_ref.server)); + } else { + parts.push(format!("- All tools from `{}`", tool_ref.server)); + } + } + } + + if !cfg.optional_tools.is_empty() { + parts.push("Optional tools (available if needed):".to_string()); + for tool_ref in &cfg.optional_tools { + if let Some(ref tool_name) = tool_ref.tool { + parts.push(format!("- `{}/{tool_name}`", tool_ref.server)); + } else { + parts.push(format!("- All tools from `{}`", tool_ref.server)); + } + } + } + + parts.join("\n") +} + +// ── Multi-agent aggregation ───────────────────────────────────────── + +use crate::templates::schema::{ + MatrixedConfig, MatrixedOutputFormat, MultiModelConfig, MultiPromptConfig, VotingStrategy, +}; + +/// Aggregate multi-model outputs into the final step output artifact. +/// +/// `outputs` maps delegator name -> response value (typically a string or structured JSON). +/// Returns the structured output artifact with responses, votes, and winner. +pub fn aggregate_multi_model( + outputs: &HashMap, + config: &MultiModelConfig, +) -> serde_json::Value { + let responses: Vec = config + .delegators + .iter() + .map(|d| { + let response = outputs.get(d).cloned().unwrap_or(serde_json::Value::Null); + serde_json::json!({ + "delegator": d, + "response": response, + }) + }) + .collect(); + + // For now, voting is represented as a placeholder structure. + // Actual voting requires a Phase 2 agent round — the votes will be + // filled in by the sync loop after the voting phase completes. + // Here we select the winner based on strategy from the raw outputs. + let (winner_index, winner_delegator) = select_winner_by_strategy(outputs, config); + + let winner_response = outputs + .get(&winner_delegator) + .cloned() + .unwrap_or(serde_json::Value::Null); + + serde_json::json!({ + "type": "multi_model", + "responses": responses, + "winner_index": winner_index, + "winner_delegator": winner_delegator, + "winner_response": winner_response, + "value": winner_response, + }) +} + +/// Select winner from raw outputs using the voting strategy (pre-voting fallback). +fn select_winner_by_strategy( + outputs: &HashMap, + config: &MultiModelConfig, +) -> (usize, String) { + match config.voting_strategy { + VotingStrategy::Majority | VotingStrategy::Ranked => { + // Without actual votes, fall back to first delegator with output + for (i, d) in config.delegators.iter().enumerate() { + if outputs.contains_key(d) { + return (i, d.clone()); + } + } + (0, config.delegators.first().cloned().unwrap_or_default()) + } + VotingStrategy::Unanimous => { + // Fall back to the longest response + let mut best = (0, config.delegators.first().cloned().unwrap_or_default()); + let mut best_len = 0; + for (i, d) in config.delegators.iter().enumerate() { + if let Some(val) = outputs.get(d) { + let len = val.as_str().map_or(0, str::len); + if len > best_len { + best_len = len; + best = (i, d.clone()); + } + } + } + best + } + } +} + +/// Apply vote results to a multi-model aggregation. +/// +/// `votes` maps voter (delegator name) -> chosen index. +/// Updates the artifact with vote data and recalculates the winner. +pub fn apply_votes( + base: &mut serde_json::Value, + votes: &HashMap, + config: &MultiModelConfig, +) { + let vote_array: Vec = votes + .iter() + .map(|(voter, choice)| { + serde_json::json!({ + "voter": voter, + "choice": choice, + }) + }) + .collect(); + + base["votes"] = serde_json::json!(vote_array); + + // Tally votes + let mut tallies: HashMap = HashMap::new(); + for choice in votes.values() { + *tallies.entry(*choice).or_insert(0) += 1; + } + + // Find winner by highest vote count + if let Some((&winner_idx, _)) = tallies.iter().max_by_key(|(_, count)| *count) { + if winner_idx < config.delegators.len() { + base["winner_index"] = serde_json::json!(winner_idx); + base["winner_delegator"] = serde_json::json!(config.delegators[winner_idx]); + if let Some(responses) = base["responses"].as_array() { + if let Some(winner) = responses.get(winner_idx) { + let resp = winner["response"].clone(); + base["winner_response"] = resp.clone(); + base["value"] = resp; + } + } + } + } +} + +/// Aggregate multi-prompt outputs into the final step output artifact. +/// +/// `outputs` maps a key (prompt index as string) -> response value. +pub fn aggregate_multi_prompt( + outputs: &HashMap, + config: &MultiPromptConfig, +) -> serde_json::Value { + let variations: Vec = config + .prompt_variations + .iter() + .enumerate() + .map(|(i, prompt)| { + let key = i.to_string(); + let response = outputs + .get(&key) + .cloned() + .unwrap_or(serde_json::Value::Null); + // Truncate prompt for summary (first 80 chars) + let summary: String = prompt.chars().take(80).collect(); + serde_json::json!({ + "prompt_index": i, + "prompt_summary": summary, + "response": response, + }) + }) + .collect(); + + // Default to first variation; actual selection requires a Phase 2 agent round + let selected_index = 0; + let selected_response = outputs.get("0").cloned().unwrap_or(serde_json::Value::Null); + + serde_json::json!({ + "type": "multi_prompt", + "variations": variations, + "selected_index": selected_index, + "selected_response": selected_response, + "value": selected_response, + }) +} + +/// Apply selection result to a multi-prompt aggregation. +pub fn apply_selection(base: &mut serde_json::Value, selected_index: usize) { + base["selected_index"] = serde_json::json!(selected_index); + if let Some(variations) = base["variations"].as_array() { + if let Some(selected) = variations.get(selected_index) { + let resp = selected["response"].clone(); + base["selected_response"] = resp.clone(); + base["value"] = resp; + } + } +} + +/// Aggregate matrixed outputs into the final step output artifact. +/// +/// `outputs` maps a compound key "`{delegator}:{prompt_index}`" -> response value. +pub fn aggregate_matrixed( + outputs: &HashMap, + config: &MatrixedConfig, + step_name: &str, +) -> serde_json::Value { + let matrix: Vec> = config + .delegators + .iter() + .map(|delegator| { + config + .prompt_variations + .iter() + .enumerate() + .map(|(j, _)| { + let key = format!("{delegator}:{j}"); + let response = outputs + .get(&key) + .cloned() + .unwrap_or(serde_json::Value::Null); + let temp_file = format!(".tickets/steps/{step_name}/{delegator}/{j}.md"); + serde_json::json!({ + "delegator": delegator, + "prompt_index": j, + "response": response, + "temp_file": temp_file, + }) + }) + .collect() + }) + .collect(); + + let temp_dir = format!(".tickets/steps/{step_name}/"); + let output_format = match config.output_format { + MatrixedOutputFormat::Directory => "directory", + MatrixedOutputFormat::Structured => "structured", + }; + + serde_json::json!({ + "type": "matrixed", + "delegators": config.delegators, + "prompt_variations": config.prompt_variations, + "output_format": output_format, + "matrix": matrix, + "temp_dir": temp_dir, + "aggregated_result": null, + "value": null, + }) +} + +/// Apply aggregation result to a matrixed output. +pub fn apply_aggregation(base: &mut serde_json::Value, result: serde_json::Value) { + base["aggregated_result"] = result.clone(); + base["value"] = result; +} + +use std::collections::HashMap; + +#[cfg(test)] +mod tests { + use super::*; + use crate::templates::schema::*; + + fn make_base_step(step_type: StepTypeTag) -> StepSchema { + StepSchema { + name: "test".to_string(), + display_name: None, + step_type, + outputs: vec![], + prompt: "Test prompt".to_string(), + review_type: ReviewType::None, + visual_config: None, + on_reject: None, + next_step: None, + allowed_tools: vec!["Read".to_string()], + agent: None, + permissions: None, + cli_args: None, + permission_mode: PermissionMode::Default, + json_schema: None, + json_schema_file: None, + artifact_patterns: vec![], + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, + } + } + + // ── classifier_json_schema tests ──────────────────────────────── + + #[test] + fn test_classifier_schema_boolean() { + let cfg = ClassifierConfig { + output_type: ClassifierOutputType::Boolean, + options: None, + max_length: None, + agent: None, + }; + let schema = classifier_json_schema(&cfg); + assert_eq!(schema["properties"]["value"]["type"], "boolean"); + } + + #[test] + fn test_classifier_schema_number() { + let cfg = ClassifierConfig { + output_type: ClassifierOutputType::Number, + options: None, + max_length: None, + agent: None, + }; + let schema = classifier_json_schema(&cfg); + assert_eq!(schema["properties"]["value"]["type"], "number"); + } + + #[test] + fn test_classifier_schema_short_string_with_max() { + let cfg = ClassifierConfig { + output_type: ClassifierOutputType::ShortString, + options: None, + max_length: Some(100), + agent: None, + }; + let schema = classifier_json_schema(&cfg); + assert_eq!(schema["properties"]["value"]["type"], "string"); + assert_eq!(schema["properties"]["value"]["maxLength"], 100); + } + + #[test] + fn test_classifier_schema_enum_with_options() { + let cfg = ClassifierConfig { + output_type: ClassifierOutputType::Enum, + options: Some(vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + ]), + max_length: None, + agent: None, + }; + let schema = classifier_json_schema(&cfg); + let enum_vals = schema["properties"]["value"]["enum"].as_array().unwrap(); + assert_eq!(enum_vals.len(), 3); + assert_eq!(enum_vals[0], "low"); + } + + #[test] + fn test_classifier_schema_has_required_value() { + let cfg = ClassifierConfig { + output_type: ClassifierOutputType::Boolean, + options: None, + max_length: None, + agent: None, + }; + let schema = classifier_json_schema(&cfg); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&serde_json::json!("value"))); + } + + // ── prompt_augmentation tests ─────────────────────────────────── + + #[test] + fn test_task_no_augmentation() { + let step = make_base_step(StepTypeTag::Task); + assert!(prompt_augmentation(&step).is_empty()); + } + + #[test] + fn test_classifier_augmentation_contains_type() { + let mut step = make_base_step(StepTypeTag::Classifier); + step.classifier_config = Some(ClassifierConfig { + output_type: ClassifierOutputType::Enum, + options: Some(vec!["a".to_string(), "b".to_string()]), + max_length: None, + agent: None, + }); + let aug = prompt_augmentation(&step); + assert!(aug.contains("Structured Output Required")); + assert!(aug.contains("one of: a, b")); + } + + #[test] + fn test_rag_augmentation_lists_sources() { + let mut step = make_base_step(StepTypeTag::Rag); + step.rag_config = Some(RagConfig { + sources: vec![ + RagSource::Glob { + pattern: "docs/**/*.md".to_string(), + }, + RagSource::File { + path: "README.md".to_string(), + }, + ], + max_context_tokens: Some(30000), + agent: None, + allowed_tools: vec![], + }); + let aug = prompt_augmentation(&step); + assert!(aug.contains("docs/**/*.md")); + assert!(aug.contains("README.md")); + assert!(aug.contains("30000 tokens")); + } + + #[test] + fn test_delegator_augmentation_includes_flavor() { + let mut step = make_base_step(StepTypeTag::Delegator); + step.delegator_config = Some(DelegatorStepConfig { + delegator: "claude-opus".to_string(), + prompt_flavor: Some("You are a security expert.".to_string()), + allowed_tools: vec![], + permissions: None, + }); + let aug = prompt_augmentation(&step); + assert!(aug.contains("You are a security expert.")); + } + + #[test] + fn test_mcp_augmentation_lists_tools() { + let mut step = make_base_step(StepTypeTag::Mcp); + step.mcp_config = Some(McpStepConfig { + required_tools: vec![McpToolRef { + server: "terraform".to_string(), + tool: Some("plan".to_string()), + }], + optional_tools: vec![McpToolRef { + server: "slack".to_string(), + tool: None, + }], + agent: None, + allowed_tools: vec![], + }); + let aug = prompt_augmentation(&step); + assert!(aug.contains("terraform/plan")); + assert!(aug.contains("All tools from `slack`")); + } + + // ── effective_agent tests ─────────────────────────────────────── + + #[test] + fn test_effective_agent_task_uses_step_agent() { + let mut step = make_base_step(StepTypeTag::Task); + step.agent = Some("claude-opus".to_string()); + assert_eq!(effective_agent(&step), Some("claude-opus")); + } + + #[test] + fn test_effective_agent_classifier_prefers_config() { + let mut step = make_base_step(StepTypeTag::Classifier); + step.agent = Some("fallback".to_string()); + step.classifier_config = Some(ClassifierConfig { + output_type: ClassifierOutputType::Boolean, + options: None, + max_length: None, + agent: Some("classifier-model".to_string()), + }); + assert_eq!(effective_agent(&step), Some("classifier-model")); + } + + #[test] + fn test_effective_agent_classifier_falls_back_to_step_agent() { + let mut step = make_base_step(StepTypeTag::Classifier); + step.agent = Some("fallback".to_string()); + step.classifier_config = Some(ClassifierConfig { + output_type: ClassifierOutputType::Boolean, + options: None, + max_length: None, + agent: None, + }); + assert_eq!(effective_agent(&step), Some("fallback")); + } + + #[test] + fn test_effective_agent_delegator_uses_config_delegator() { + let mut step = make_base_step(StepTypeTag::Delegator); + step.agent = Some("should-not-use".to_string()); + step.delegator_config = Some(DelegatorStepConfig { + delegator: "claude-opus-security".to_string(), + prompt_flavor: None, + allowed_tools: vec![], + permissions: None, + }); + assert_eq!(effective_agent(&step), Some("claude-opus-security")); + } + + // ── effective_allowed_tools tests ─────────────────────────────── + + #[test] + fn test_effective_tools_task_uses_step_tools() { + let step = make_base_step(StepTypeTag::Task); + assert_eq!(effective_allowed_tools(&step), &["Read".to_string()]); + } + + #[test] + fn test_effective_tools_rag_prefers_config() { + let mut step = make_base_step(StepTypeTag::Rag); + step.rag_config = Some(RagConfig { + sources: vec![], + max_context_tokens: None, + agent: None, + allowed_tools: vec!["Read".to_string(), "Grep".to_string()], + }); + let tools = effective_allowed_tools(&step); + assert_eq!(tools.len(), 2); + assert_eq!(tools[1], "Grep"); + } + + #[test] + fn test_effective_tools_rag_falls_back_to_step() { + let mut step = make_base_step(StepTypeTag::Rag); + step.rag_config = Some(RagConfig { + sources: vec![], + max_context_tokens: None, + agent: None, + allowed_tools: vec![], + }); + assert_eq!(effective_allowed_tools(&step), &["Read".to_string()]); + } + + // ── Aggregation tests ────────────────────────────────────────── + + fn make_multi_model_config() -> MultiModelConfig { + MultiModelConfig { + delegators: vec![ + "claude-opus".to_string(), + "gemini-pro".to_string(), + "codex-high".to_string(), + ], + voting_strategy: VotingStrategy::Majority, + share_answers: true, + voting_prompt: None, + voting_mode: VotingMode::default(), + } + } + + #[test] + fn test_aggregate_multi_model_collects_responses() { + let config = make_multi_model_config(); + let mut outputs = HashMap::new(); + outputs.insert( + "claude-opus".to_string(), + serde_json::json!("Use approach A"), + ); + outputs.insert( + "gemini-pro".to_string(), + serde_json::json!("Use approach B"), + ); + outputs.insert( + "codex-high".to_string(), + serde_json::json!("Use approach A"), + ); + + let result = aggregate_multi_model(&outputs, &config); + assert_eq!(result["type"], "multi_model"); + assert_eq!(result["responses"].as_array().unwrap().len(), 3); + assert!(result["winner_delegator"].is_string()); + assert!(!result["winner_response"].is_null()); + } + + #[test] + fn test_aggregate_multi_model_unanimous_picks_longest() { + let config = MultiModelConfig { + delegators: vec!["a".to_string(), "b".to_string()], + voting_strategy: VotingStrategy::Unanimous, + share_answers: false, + voting_prompt: None, + voting_mode: VotingMode::default(), + }; + let mut outputs = HashMap::new(); + outputs.insert("a".to_string(), serde_json::json!("short")); + outputs.insert( + "b".to_string(), + serde_json::json!("this is a much longer response"), + ); + + let result = aggregate_multi_model(&outputs, &config); + assert_eq!(result["winner_delegator"], "b"); + assert_eq!(result["winner_index"], 1); + } + + #[test] + fn test_apply_votes_updates_winner() { + let config = make_multi_model_config(); + let mut outputs = HashMap::new(); + outputs.insert("claude-opus".to_string(), serde_json::json!("A")); + outputs.insert("gemini-pro".to_string(), serde_json::json!("B")); + outputs.insert("codex-high".to_string(), serde_json::json!("C")); + + let mut result = aggregate_multi_model(&outputs, &config); + + let mut votes = HashMap::new(); + votes.insert("claude-opus".to_string(), 1usize); // votes for gemini + votes.insert("gemini-pro".to_string(), 1usize); // votes for gemini + votes.insert("codex-high".to_string(), 0usize); // votes for claude + + apply_votes(&mut result, &votes, &config); + assert_eq!(result["winner_index"], 1); + assert_eq!(result["winner_delegator"], "gemini-pro"); + assert_eq!(result["votes"].as_array().unwrap().len(), 3); + } + + #[test] + fn test_aggregate_multi_prompt() { + let config = MultiPromptConfig { + prompt_variations: vec![ + "Approach as refactoring".to_string(), + "Approach as greenfield".to_string(), + ], + selection_strategy: SelectionStrategy::ModelChoice, + agent: None, + selection_prompt: None, + }; + let mut outputs = HashMap::new(); + outputs.insert("0".to_string(), serde_json::json!("refactoring plan")); + outputs.insert("1".to_string(), serde_json::json!("greenfield plan")); + + let result = aggregate_multi_prompt(&outputs, &config); + assert_eq!(result["type"], "multi_prompt"); + assert_eq!(result["variations"].as_array().unwrap().len(), 2); + assert_eq!(result["selected_index"], 0); // default + } + + #[test] + fn test_apply_selection() { + let config = MultiPromptConfig { + prompt_variations: vec!["A".to_string(), "B".to_string()], + selection_strategy: SelectionStrategy::ModelChoice, + agent: None, + selection_prompt: None, + }; + let mut outputs = HashMap::new(); + outputs.insert("0".to_string(), serde_json::json!("plan A")); + outputs.insert("1".to_string(), serde_json::json!("plan B")); + + let mut result = aggregate_multi_prompt(&outputs, &config); + apply_selection(&mut result, 1); + + assert_eq!(result["selected_index"], 1); + assert_eq!(result["selected_response"], "plan B"); + assert_eq!(result["value"], "plan B"); + } + + #[test] + fn test_aggregate_matrixed() { + let config = MatrixedConfig { + delegators: vec!["claude".to_string(), "gemini".to_string()], + prompt_variations: vec!["perf".to_string(), "security".to_string()], + output_format: MatrixedOutputFormat::Structured, + aggregation_prompt: None, + }; + let mut outputs = HashMap::new(); + outputs.insert("claude:0".to_string(), serde_json::json!("claude-perf")); + outputs.insert("claude:1".to_string(), serde_json::json!("claude-sec")); + outputs.insert("gemini:0".to_string(), serde_json::json!("gemini-perf")); + outputs.insert("gemini:1".to_string(), serde_json::json!("gemini-sec")); + + let result = aggregate_matrixed(&outputs, &config, "analysis"); + assert_eq!(result["type"], "matrixed"); + let matrix = result["matrix"].as_array().unwrap(); + assert_eq!(matrix.len(), 2); // 2 delegators + assert_eq!(matrix[0].as_array().unwrap().len(), 2); // 2 prompts each + assert_eq!(matrix[0][0]["response"], "claude-perf"); + assert_eq!(matrix[1][1]["response"], "gemini-sec"); + } + + #[test] + fn test_apply_aggregation() { + let config = MatrixedConfig { + delegators: vec!["a".to_string(), "b".to_string()], + prompt_variations: vec!["x".to_string(), "y".to_string()], + output_format: MatrixedOutputFormat::Structured, + aggregation_prompt: Some("Synthesize".to_string()), + }; + let outputs = HashMap::new(); + let mut result = aggregate_matrixed(&outputs, &config, "test"); + assert!(result["aggregated_result"].is_null()); + + apply_aggregation(&mut result, serde_json::json!("synthesized answer")); + assert_eq!(result["aggregated_result"], "synthesized answer"); + assert_eq!(result["value"], "synthesized answer"); + } +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 2632185..5646e0d 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -251,9 +251,35 @@ impl Dashboard { llm_tool: d.llm_tool.clone(), model: d.model.clone(), yolo: d.launch_config.as_ref().is_some_and(|lc| lc.yolo), + model_server: d.model_server.clone(), }) .collect(); + // Build model server info — implicit builtins plus any user-declared. + let mut model_servers: Vec = config + .model_servers + .iter() + .map(|s| crate::ui::status_panel::ModelServerInfo { + name: s.name.clone(), + kind: s.kind.clone(), + base_url: s.base_url.clone(), + display_name: s.display_name.clone(), + user_declared: true, + }) + .collect(); + for tool in ["claude", "codex", "gemini"] { + let implicit = crate::config::implicit_model_server_for_tool(tool); + if !model_servers.iter().any(|s| s.name == implicit.name) { + model_servers.push(crate::ui::status_panel::ModelServerInfo { + name: implicit.name, + kind: implicit.kind, + base_url: implicit.base_url, + display_name: implicit.display_name, + user_declared: false, + }); + } + } + // Git config let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); let git_token_set = match config.git.provider { @@ -278,6 +304,7 @@ impl Dashboard { default_llm_tool: config.llm_tools.default_tool.clone(), default_llm_model: config.llm_tools.default_model.clone(), delegators, + model_servers, git_provider, git_token_set, git_branch_format: Some(config.git.branch_format.clone()), diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs index 7835d7e..3364e7a 100644 --- a/src/ui/sections/connections_section.rs +++ b/src/ui/sections/connections_section.rs @@ -160,6 +160,7 @@ mod tests { default_llm_tool: None, default_llm_model: None, delegators: vec![], + model_servers: vec![], git_provider: None, git_token_set: false, git_branch_format: None, diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs index 1c721be..0a61006 100644 --- a/src/ui/sections/delegator_section.rs +++ b/src/ui/sections/delegator_section.rs @@ -42,7 +42,13 @@ impl StatusSection for DelegatorSection { .map(|d| { let label = d.display_name.as_deref().unwrap_or(&d.name).to_string(); let yolo_flag = if d.yolo { " · yolo" } else { "" }; - let description = format!("{}:{}{}", d.llm_tool, d.model, yolo_flag); + let server_suffix = d + .model_server + .as_deref() + .map(|s| format!(" @ {s}")) + .unwrap_or_default(); + let description = + format!("{}:{}{}{}", d.llm_tool, d.model, yolo_flag, server_suffix); TreeRow { section_id: SectionId::Delegators, @@ -68,3 +74,76 @@ impl StatusSection for DelegatorSection { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backstage::ServerStatus; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{DelegatorInfo, WrapperConnectionStatus}; + + fn snapshot_with(delegators: Vec) -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.30".into(), + api_status: RestApiStatus::Stopped, + backstage_status: ServerStatus::Stopped, + backstage_display: false, + kanban_providers: vec![], + llm_tools: vec![], + default_llm_tool: None, + default_llm_model: None, + delegators, + model_servers: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: None, + }, + env_editor: String::new(), + env_visual: String::new(), + } + } + + fn delegator(name: &str, tool: &str, model: &str, server: Option<&str>) -> DelegatorInfo { + DelegatorInfo { + name: name.into(), + display_name: None, + llm_tool: tool.into(), + model: model.into(), + yolo: false, + model_server: server.map(String::from), + } + } + + #[test] + fn test_description_includes_server_when_set() { + let snap = snapshot_with(vec![delegator( + "codex-qwen", + "codex", + "qwen2.5-coder", + Some("ollama-local"), + )]); + let rows = DelegatorSection.children(&snap); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].description, "codex:qwen2.5-coder @ ollama-local"); + } + + #[test] + fn test_description_omits_server_when_default() { + let snap = snapshot_with(vec![delegator("claude-opus", "claude", "opus", None)]); + let rows = DelegatorSection.children(&snap); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].description, "claude:opus"); + } +} diff --git a/src/ui/sections/git_section.rs b/src/ui/sections/git_section.rs index be1bab8..2f31845 100644 --- a/src/ui/sections/git_section.rs +++ b/src/ui/sections/git_section.rs @@ -171,6 +171,7 @@ mod tests { kanban_providers: vec![], llm_tools: vec![], delegators: vec![], + model_servers: vec![], git_provider: None, git_token_set: false, git_branch_format: None, diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs index f3c7d02..e3a124a 100644 --- a/src/ui/sections/kanban_section.rs +++ b/src/ui/sections/kanban_section.rs @@ -117,6 +117,7 @@ mod tests { default_llm_tool: None, default_llm_model: None, delegators: vec![], + model_servers: vec![], git_provider: None, git_token_set: false, git_branch_format: None, diff --git a/src/ui/sections/mod.rs b/src/ui/sections/mod.rs index a581e4b..4ac3546 100644 --- a/src/ui/sections/mod.rs +++ b/src/ui/sections/mod.rs @@ -4,6 +4,7 @@ mod delegator_section; mod git_section; mod kanban_section; mod llm_section; +mod modelserver_section; pub use config_section::ConfigSection; pub use connections_section::ConnectionsSection; @@ -11,3 +12,4 @@ pub use delegator_section::DelegatorSection; pub use git_section::GitSection; pub use kanban_section::KanbanSection; pub use llm_section::LlmSection; +pub use modelserver_section::ModelServerSection; diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs new file mode 100644 index 0000000..3647828 --- /dev/null +++ b/src/ui/sections/modelserver_section.rs @@ -0,0 +1,217 @@ +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct ModelServerSection; + +impl StatusSection for ModelServerSection { + fn section_id(&self) -> SectionId { + SectionId::ModelServers + } + + fn label(&self) -> &'static str { + "Model Servers" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::LlmTools] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.model_servers.iter().any(|s| s.user_declared) { + SectionHealth::Green + } else { + SectionHealth::Gray + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let declared = snapshot + .model_servers + .iter() + .filter(|s| s.user_declared) + .count(); + if declared == 0 { + "builtins only".into() + } else { + format!("{declared} declared") + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + snapshot + .model_servers + .iter() + .map(|s| { + let label = s.display_name.clone().unwrap_or_else(|| s.name.clone()); + let base = s + .base_url + .as_deref() + .map(truncate_base_url) + .unwrap_or_default(); + let description = if base.is_empty() { + if s.user_declared { + s.kind.clone() + } else { + format!("{} · builtin", s.kind) + } + } else { + format!("{} · {}", s.kind, base) + }; + + let action = if s.user_declared { + ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::EditFile(snapshot.config_path.clone()), + special_meta: Some(ActionMeta { + title: "Config", + tooltip: "Edit model server configuration", + }), + refresh: StatusAction::None, + refresh_meta: None, + } + } else { + ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + } + }; + + TreeRow { + section_id: SectionId::ModelServers, + depth: 1, + label, + description, + icon: StatusIcon::Tool, + is_header: false, + actions: action, + health: if s.user_declared { + SectionHealth::Green + } else { + SectionHealth::Gray + }, + } + }) + .collect() + } +} + +fn truncate_base_url(url: &str) -> String { + const MAX: usize = 40; + if url.len() <= MAX { + url.to_string() + } else { + format!("{}…", &url[..MAX.saturating_sub(1)]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backstage::ServerStatus; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{ModelServerInfo, WrapperConnectionStatus}; + + fn snapshot_with_servers(servers: Vec) -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_version: "0.1.30".into(), + api_status: RestApiStatus::Stopped, + backstage_status: ServerStatus::Stopped, + backstage_display: false, + kanban_providers: vec![], + llm_tools: vec![], + default_llm_tool: None, + default_llm_model: None, + delegators: vec![], + model_servers: servers, + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: None, + }, + env_editor: String::new(), + env_visual: String::new(), + } + } + + fn builtin(name: &str, kind: &str) -> ModelServerInfo { + ModelServerInfo { + name: name.into(), + kind: kind.into(), + base_url: None, + display_name: None, + user_declared: false, + } + } + + fn declared(name: &str, kind: &str, base: &str) -> ModelServerInfo { + ModelServerInfo { + name: name.into(), + kind: kind.into(), + base_url: Some(base.into()), + display_name: None, + user_declared: true, + } + } + + #[test] + fn test_description_builtins_only() { + let snapshot = snapshot_with_servers(vec![ + builtin("anthropic-api", "anthropic-api"), + builtin("openai-api", "openai-api"), + ]); + let section = ModelServerSection; + assert_eq!(section.description(&snapshot), "builtins only"); + assert!(matches!(section.health(&snapshot), SectionHealth::Gray)); + } + + #[test] + fn test_description_counts_declared() { + let snapshot = snapshot_with_servers(vec![ + builtin("anthropic-api", "anthropic-api"), + declared("ollama-local", "ollama", "http://localhost:11434"), + declared("vllm-gpu", "openai-compat", "http://gpu:8000"), + ]); + let section = ModelServerSection; + assert_eq!(section.description(&snapshot), "2 declared"); + assert!(matches!(section.health(&snapshot), SectionHealth::Green)); + } + + #[test] + fn test_children_render_base_url_for_declared() { + let snapshot = snapshot_with_servers(vec![declared( + "ollama-local", + "ollama", + "http://localhost:11434", + )]); + let rows = ModelServerSection.children(&snapshot); + assert_eq!(rows.len(), 1); + assert!(rows[0].description.contains("ollama")); + assert!(rows[0].description.contains("localhost:11434")); + } + + #[test] + fn test_children_mark_builtin_as_such() { + let snapshot = snapshot_with_servers(vec![builtin("anthropic-api", "anthropic-api")]); + let rows = ModelServerSection.children(&snapshot); + assert_eq!(rows.len(), 1); + assert!(rows[0].description.contains("builtin")); + } +} diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 91bf1a9..cf2fe5d 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -15,6 +15,7 @@ use crate::rest::RestApiStatus; use super::sections::{ ConfigSection, ConnectionsSection, DelegatorSection, GitSection, KanbanSection, LlmSection, + ModelServerSection, }; // --------------------------------------------------------------------------- @@ -35,6 +36,8 @@ pub enum SectionId { Kanban, #[serde(rename = "llm")] LlmTools, + #[serde(rename = "model-servers")] + ModelServers, #[serde(rename = "git")] Git, #[serde(rename = "issuetypes")] @@ -280,6 +283,19 @@ pub struct DelegatorInfo { pub llm_tool: String, pub model: String, pub yolo: bool, + /// Referenced model server name (None = implicit vendor default). + pub model_server: Option, +} + +/// Information about a declared (or implicit builtin) model server. +#[derive(Debug, Clone)] +pub struct ModelServerInfo { + pub name: String, + pub kind: String, + pub base_url: Option, + pub display_name: Option, + /// False for implicit builtins (anthropic-api, openai-api, google-api). + pub user_declared: bool, } /// Connection status for the active session wrapper. @@ -401,6 +417,7 @@ pub struct StatusSnapshot { pub default_llm_tool: Option, pub default_llm_model: Option, pub delegators: Vec, + pub model_servers: Vec, pub git_provider: Option, pub git_token_set: bool, pub git_branch_format: Option, @@ -470,6 +487,7 @@ impl TreeState { expanded.insert(SectionId::Connections, false); expanded.insert(SectionId::Kanban, false); expanded.insert(SectionId::LlmTools, false); + expanded.insert(SectionId::ModelServers, false); expanded.insert(SectionId::Delegators, false); expanded.insert(SectionId::Git, false); Self { @@ -499,6 +517,7 @@ impl StatusPanel { Box::new(ConnectionsSection), Box::new(KanbanSection), Box::new(LlmSection), + Box::new(ModelServerSection), Box::new(DelegatorSection), Box::new(GitSection), ]; @@ -850,7 +869,9 @@ mod tests { llm_tool: "claude".into(), model: "opus".into(), yolo: false, + model_server: None, }], + model_servers: Vec::new(), git_provider: Some("GitHub".into()), git_token_set: true, git_branch_format: Some("feature/{ticket}".into()), diff --git a/vscode-extension/src/sections/config-section.ts b/vscode-extension/src/sections/config-section.ts index 2578f36..9def576 100644 --- a/vscode-extension/src/sections/config-section.ts +++ b/vscode-extension/src/sections/config-section.ts @@ -96,6 +96,7 @@ export class ConfigSection implements StatusSection { : vscode.TreeItemCollapsibleState.Expanded, sectionId: this.sectionId, command: configCommand, + health: this.health(), }); } diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index 74e54e3..9def1ab 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -160,6 +160,7 @@ export class ConnectionsSection implements StatusSection { ? { command: 'operator.runSetup', title: 'Run Operator Setup' } : { command: 'operator.selectWorkingDirectory', title: 'Select Working Directory' } ), + health: this.health(), }); } diff --git a/vscode-extension/src/sections/delegator-section.ts b/vscode-extension/src/sections/delegator-section.ts index 58caf86..9c76faa 100644 --- a/vscode-extension/src/sections/delegator-section.ts +++ b/vscode-extension/src/sections/delegator-section.ts @@ -50,6 +50,7 @@ export class DelegatorSection implements StatusSection { ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded, sectionId: this.sectionId, + health: this.health(), }); } @@ -59,6 +60,7 @@ export class DelegatorSection implements StatusSection { icon: 'rocket', collapsibleState: vscode.TreeItemCollapsibleState.None, sectionId: this.sectionId, + health: this.health(), }); } @@ -72,10 +74,11 @@ export class DelegatorSection implements StatusSection { for (const delegator of this.state.delegators) { const label = delegator.display_name || delegator.name; const yoloFlag = delegator.launch_config?.yolo ? ' · yolo' : ''; + const serverSuffix = delegator.model_server ? ` @ ${delegator.model_server}` : ''; items.push(new StatusItem({ label, - description: `${delegator.llm_tool}:${delegator.model}${yoloFlag}`, + description: `${delegator.llm_tool}:${delegator.model}${yoloFlag}${serverSuffix}`, icon: `operator-${delegator.llm_tool}`, tooltip: this.buildTooltip(delegator), sectionId: this.sectionId, @@ -97,6 +100,7 @@ export class DelegatorSection implements StatusSection { private buildTooltip(d: DelegatorResponse): string { const lines = [`${d.name}: ${d.llm_tool} / ${d.model}`]; + if (d.model_server) { lines.push(`Model server: ${d.model_server}`); } if (d.launch_config) { if (d.launch_config.yolo) { lines.push('YOLO mode: enabled'); } if (d.launch_config.permission_mode) { lines.push(`Permission: ${d.launch_config.permission_mode}`); } diff --git a/vscode-extension/src/sections/git-section.ts b/vscode-extension/src/sections/git-section.ts index ae0c34e..6650b20 100644 --- a/vscode-extension/src/sections/git-section.ts +++ b/vscode-extension/src/sections/git-section.ts @@ -83,6 +83,7 @@ export class GitSection implements StatusSection { command: 'operator.startGitOnboarding', title: 'Connect Git Provider', }, + health: this.health(), }); } @@ -112,6 +113,7 @@ export class GitSection implements StatusSection { command: providerName === 'gitlab' ? 'operator.configureGitLab' : 'operator.configureGitHub', title: 'Set Token', }, + health: this.state.tokenSet ? 'Green' : 'Yellow', })); // Branch Format diff --git a/vscode-extension/src/sections/index.ts b/vscode-extension/src/sections/index.ts index 2d5b18e..d206bb5 100644 --- a/vscode-extension/src/sections/index.ts +++ b/vscode-extension/src/sections/index.ts @@ -18,4 +18,5 @@ export { LlmSection } from './llm-section'; export { GitSection } from './git-section'; export { IssueTypeSection } from './issuetype-section'; export { DelegatorSection } from './delegator-section'; +export { ModelServerSection } from './modelserver-section'; export { ManagedProjectsSection } from './managed-projects-section'; diff --git a/vscode-extension/src/sections/issuetype-section.ts b/vscode-extension/src/sections/issuetype-section.ts index 8483818..523f39c 100644 --- a/vscode-extension/src/sections/issuetype-section.ts +++ b/vscode-extension/src/sections/issuetype-section.ts @@ -49,6 +49,7 @@ export class IssueTypeSection implements StatusSection { icon: 'check', collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, sectionId: this.sectionId, + health: this.health(), }); } @@ -58,6 +59,7 @@ export class IssueTypeSection implements StatusSection { icon: 'warning', collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, sectionId: this.sectionId, + health: this.health(), }); } diff --git a/vscode-extension/src/sections/kanban-section.ts b/vscode-extension/src/sections/kanban-section.ts index 7ca1f67..e76af72 100644 --- a/vscode-extension/src/sections/kanban-section.ts +++ b/vscode-extension/src/sections/kanban-section.ts @@ -151,6 +151,7 @@ export class KanbanSection implements StatusSection { command: 'operator.startKanbanOnboarding', title: 'Configure Kanban', }, + health: this.health(), }); } diff --git a/vscode-extension/src/sections/llm-section.ts b/vscode-extension/src/sections/llm-section.ts index cf29774..9c3f6ea 100644 --- a/vscode-extension/src/sections/llm-section.ts +++ b/vscode-extension/src/sections/llm-section.ts @@ -130,6 +130,7 @@ export class LlmSection implements StatusSection { command: 'operator.detectLlmTools', title: 'Detect LLM Tools', }, + health: this.health(), }); } diff --git a/vscode-extension/src/sections/managed-projects-section.ts b/vscode-extension/src/sections/managed-projects-section.ts index ce80d84..6738749 100644 --- a/vscode-extension/src/sections/managed-projects-section.ts +++ b/vscode-extension/src/sections/managed-projects-section.ts @@ -47,6 +47,7 @@ export class ManagedProjectsSection implements StatusSection { ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None, sectionId: this.sectionId, + health: this.health(), }); } @@ -56,6 +57,7 @@ export class ManagedProjectsSection implements StatusSection { icon: 'project', collapsibleState: vscode.TreeItemCollapsibleState.None, sectionId: this.sectionId, + health: this.health(), }); } diff --git a/vscode-extension/src/sections/modelserver-section.ts b/vscode-extension/src/sections/modelserver-section.ts new file mode 100644 index 0000000..bbc802e --- /dev/null +++ b/vscode-extension/src/sections/modelserver-section.ts @@ -0,0 +1,114 @@ +import * as vscode from 'vscode'; +import { StatusItem } from '../status-item'; +import type { SectionContext, StatusSection } from './types'; +import type { SectionId, SectionHealth } from '../generated'; +import { discoverApiUrl } from '../api-client'; +import type { ModelServerResponse } from '../generated/ModelServerResponse'; +import type { ModelServersResponse } from '../generated/ModelServersResponse'; + +interface ModelServerState { + apiAvailable: boolean; + servers: ModelServerResponse[]; +} + +export class ModelServerSection implements StatusSection { + readonly sectionId: SectionId = 'model-servers'; + readonly prerequisites: SectionId[] = ['llm']; + + private state: ModelServerState = { apiAvailable: false, servers: [] }; + + health(): SectionHealth { + if (!this.state.apiAvailable) { return 'Yellow'; } + return this.state.servers.some((s) => s.user_declared) ? 'Green' : 'Gray'; + } + + async check(ctx: SectionContext): Promise { + try { + const apiUrl = await discoverApiUrl(ctx.ticketsDir); + const response = await fetch(`${apiUrl}/api/v1/model-servers`); + if (response.ok) { + const data = await response.json() as ModelServersResponse; + this.state = { apiAvailable: true, servers: data.servers }; + return; + } + } catch { + // API not available + } + this.state = { apiAvailable: false, servers: [] }; + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + if (this.state.apiAvailable) { + const declared = this.state.servers.filter((s) => s.user_declared).length; + const description = declared > 0 + ? `${declared} declared` + : 'builtins only'; + return new StatusItem({ + label: 'Model Servers', + description, + icon: 'server', + collapsibleState: this.state.servers.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + }); + } + + return new StatusItem({ + label: 'Model Servers', + description: 'API required', + icon: 'server', + collapsibleState: vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + const items: StatusItem[] = []; + + if (!this.state.apiAvailable) { + return items; + } + + for (const server of this.state.servers) { + const label = server.display_name || server.name; + const descriptionParts: string[] = [server.kind]; + if (server.base_url) { descriptionParts.push(server.base_url); } + if (!server.user_declared) { descriptionParts.push('builtin'); } + + items.push(new StatusItem({ + label, + description: descriptionParts.join(' · '), + icon: server.user_declared ? 'server' : 'circle-outline', + tooltip: this.buildTooltip(server), + sectionId: this.sectionId, + })); + } + + items.push(new StatusItem({ + label: 'Add Model Server', + icon: 'add', + command: { + command: 'operator.openSettings', + title: 'Add Model Server', + }, + sectionId: this.sectionId, + })); + + return items; + } + + private buildTooltip(s: ModelServerResponse): string { + const lines = [`${s.name} (${s.kind})`]; + if (s.base_url) { lines.push(`URL: ${s.base_url}`); } + if (s.api_key_env) { lines.push(`API key env: ${s.api_key_env}`); } + if (!s.user_declared) { lines.push('Implicit builtin — cannot be deleted.'); } + const extraKeys = Object.keys(s.extra_env); + if (extraKeys.length > 0) { + lines.push(`Extra env: ${extraKeys.join(', ')}`); + } + return lines.join('\n'); + } +} diff --git a/vscode-extension/src/status-item.ts b/vscode-extension/src/status-item.ts index bafdf29..8e7d936 100644 --- a/vscode-extension/src/status-item.ts +++ b/vscode-extension/src/status-item.ts @@ -1,4 +1,15 @@ import * as vscode from 'vscode'; +import type { SectionHealth } from './generated'; + +/** + * Map a SectionHealth value to a VS Code theme color id used to tint the + * row icon. Yellow/Red nudge the user to address the item; Green and Gray + * leave the icon at its default theme color. + */ +const HEALTH_THEME_COLOR: Partial> = { + Yellow: 'list.warningForeground', + Red: 'list.errorForeground', +}; /** * StatusItem options @@ -19,6 +30,13 @@ export interface StatusItemOptions { specialCommand?: vscode.Command; /** Y button (Ctrl+Enter) — contextual refresh */ refreshCommand?: vscode.Command; + /** + * Optional health state. When `Yellow` or `Red`, the row icon is tinted + * with the corresponding semantic theme color so the user is nudged to + * address the item. Mirrors the Rust TUI `kanban_section.rs` header + * colorization driven by `SectionHealth::to_color()`. + */ + health?: SectionHealth; } /** @@ -80,7 +98,10 @@ export class StatusItem extends vscode.TreeItem { } this.tooltip = tooltipLines.join('\n'); - this.iconPath = new vscode.ThemeIcon(opts.icon); + const themeColorId = opts.health ? HEALTH_THEME_COLOR[opts.health] : undefined; + this.iconPath = themeColorId + ? new vscode.ThemeIcon(opts.icon, new vscode.ThemeColor(themeColorId)) + : new vscode.ThemeIcon(opts.icon); if (opts.command) { this.command = opts.command; } diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index 24a4f79..5ce072b 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -23,6 +23,7 @@ import { LlmSection } from './sections/llm-section'; import { GitSection } from './sections/git-section'; import { IssueTypeSection } from './sections/issuetype-section'; import { DelegatorSection } from './sections/delegator-section'; +import { ModelServerSection } from './sections/modelserver-section'; import { ManagedProjectsSection } from './sections/managed-projects-section'; // Backward-compatible re-exports @@ -57,6 +58,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private gitSection: GitSection; private issueTypeSection: IssueTypeSection; private delegatorSection: DelegatorSection; + private modelServerSection: ModelServerSection; private managedProjectsSection: ManagedProjectsSection; // All sections for check() and routing @@ -73,6 +75,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.gitSection = new GitSection(); this.issueTypeSection = new IssueTypeSection(); this.delegatorSection = new DelegatorSection(); + this.modelServerSection = new ModelServerSection(); this.managedProjectsSection = new ManagedProjectsSection(); this.allSections = [ @@ -80,6 +83,7 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.connectionsSection, this.kanbanSection, this.llmSection, + this.modelServerSection, this.gitSection, this.issueTypeSection, this.delegatorSection, From fb08e63b104301a3cf713098a31d1c0453a131fc Mon Sep 17 00:00:00 2001 From: untra Date: Thu, 7 May 2026 07:47:21 -0600 Subject: [PATCH 2/6] wip docs words --- docs/delegators/index.md | 134 ++++++++++++++++ docs/startup/index.md | 145 +++++++++++++++--- docs/vscode/index.html | 16 ++ src/startup/mod.rs | 117 ++++++++++++-- src/ui/sections/delegator_section.rs | 25 +++ .../src/sections/delegator-section.ts | 2 +- 6 files changed, 399 insertions(+), 40 deletions(-) create mode 100644 docs/delegators/index.md create mode 100644 docs/vscode/index.html diff --git a/docs/delegators/index.md b/docs/delegators/index.md new file mode 100644 index 0000000..3171a96 --- /dev/null +++ b/docs/delegators/index.md @@ -0,0 +1,134 @@ +--- +title: "Delegators" +description: "Named LLM tool + model pairings for autonomous ticket launching." +layout: doc +--- + +A **delegator** is a named pairing of an LLM tool (e.g. `claude`) and a model alias (e.g. `opus`) that Operator uses to launch agents for tickets. Delegators give you control over which tool and model handles which tickets, and let you configure launch behavior per pairing. LLM tasks can be launched on behalf on a named delegator, which allows you to refine and version their prompts. + +## Quick start + +Add a `[[delegators]]` entry to your `operator.toml`: + +```toml +[[delegators]] +name = "claude-sonnet-auto" +llm_tool = "claude" +model = "sonnet" + +[delegators.launch_config] +yolo = true +``` + +Then run `cargo run -- launch` (or use the VS Code sidebar "Add Delegator" button) to create one interactively. + +## How delegators relate to LLM Tools and Model Servers + +Three concepts work together: + +| Concept | What it picks | Example | +|---------|--------------|---------| +| **LLM Tool** | Which CLI binary to run | `claude`, `codex`, `gemini` | +| **Delegator** | Which tool + model to use | `claude` + `opus` | +| **Model Server** | Which inference endpoint to call | `ollama-local`, `anthropic-api` | + +A delegator bridges a tool (the binary) to a model server (the backend). If `model_server` is omitted, the tool's implicit vendor default is used (`claude` → Anthropic API, `codex` → OpenAI API, `gemini` → Google API). + +## Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique identifier (e.g. `"claude-opus-auto"`) | +| `llm_tool` | Yes | Tool name matching a detected binary (`"claude"`, `"codex"`, `"gemini"`) | +| `model` | Yes | Model alias passed to the tool (e.g. `"opus"`, `"sonnet"`, `"gpt-4o"`) | +| `display_name` | No | Human-readable label shown in the UI | +| `model_server` | No | Name of a declared `[[model_servers]]` entry; omit to use the tool's vendor default | +| `model_properties` | No | Arbitrary key-value pairs forwarded to the model (e.g. `reasoning_effort = "high"`) | +| `launch_config` | No | Per-delegator launch overrides (see below) | + +## Launch configuration + +`[delegators.launch_config]` lets you override launch behavior for a specific delegator: + +```toml +[[delegators]] +name = "claude-opus-yolo" +llm_tool = "claude" +model = "opus" + +[delegators.launch_config] +yolo = true # auto-accept all prompts +permission_mode = "bypassPermissions" +use_worktrees = true # override global git.use_worktrees +prompt_suffix = "\n\nBe concise." +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `yolo` | `false` | Run in auto-accept mode (skips all confirmation prompts) | +| `permission_mode` | inherit | Permission mode override | +| `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 | +| `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. + +## Using a custom model server + +To route a delegator through a local Ollama instance: + +```toml +[[model_servers]] +name = "ollama-local" +kind = "ollama" +base_url = "http://localhost:11434" + +[[delegators]] +name = "codex-qwen" +llm_tool = "codex" +model = "qwen2.5-coder" +model_server = "ollama-local" +``` + +## Multiple delegators + +You can declare as many delegators as you like. When launching a ticket, Operator selects a delegator based on the ticket's `delegator` frontmatter field or the configured default: + +```toml +[[delegators]] +name = "claude-sonnet-auto" +llm_tool = "claude" +model = "sonnet" + +[[delegators]] +name = "claude-opus-research" +llm_tool = "claude" +model = "opus" + +[delegators.launch_config] +prompt_suffix = "\n\nThink carefully before acting." +``` + +## REST API + +The running Operator API exposes full CRUD for delegators: + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/delegators` | List all delegators | +| `POST` | `/api/v1/delegators` | Create a delegator | +| `POST` | `/api/v1/delegators/from-tool` | Create from a detected tool (auto-generates name) | +| `GET` | `/api/v1/delegators/{name}` | Get one delegator | +| `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 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` diff --git a/docs/startup/index.md b/docs/startup/index.md index 74c3d38..4853e04 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -15,12 +15,20 @@ When Operator starts and no `.tickets/` directory exists, the setup wizard guide | Step | Name | Description | | --- | --- | --- | | 1 | Welcome | Splash screen showing detected LLM tools and discovered projects | -| 2 | Collection Source | Choose which ticket template collection to use | -| 3 | Custom Collection | Select individual issue types (only shown if Custom Selection chosen) | -| 4 | Task Field Config | Configure optional fields for TASK issue type | -| 5 | Tmux Onboarding | Help and documentation about tmux session management | -| 6 | Startup Tickets | Optionally create tickets to bootstrap your projects | -| 7 | Confirm | Review settings and confirm initialization | +| 2 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | +| 3 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | +| 4 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | +| 5 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | +| 6 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | +| 7 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | +| 8 | Kanban Info | Kanban integration overview and provider credential detection | +| 9 | Kanban Provider Setup | Per-provider credential validation and project selection | +| 10 | Collection Source | Choose which issue type collection to use | +| 11 | Custom Collection | Select individual issue types (only shown if Custom Selection chosen) | +| 12 | Task Field Config | Configure optional fields for TASK issue type | +| 13 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | +| 14 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 15 | Confirm | Review settings and confirm initialization | ## Step Details @@ -38,21 +46,114 @@ This gives you an overview of your development environment before proceeding. **Navigation**: Enter to continue, Esc to cancel -### 2. Collection Source +### 2. Session Wrapper Choice -*Choose which ticket template collection to use* +*Select which session wrapper to use for launching coding agents* + +Choose how Operator will manage coding agent sessions: +- **tmux**: Terminal multiplexer, recommended for most setups +- **VS Code**: Launch agents as VS Code tasks (requires extension) +- **cmux**: Lightweight tmux wrapper with operator defaults pre-applied +- **Zellij**: Modern terminal workspace with built-in layouts + +Your choice determines which setup steps follow. + +**Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back + +### 3. Worktree Preference + +*Choose whether to use git worktrees for ticket isolation* + +Configure how Operator manages git branches per ticket: +- **In-place branches**: Each agent works in the main checkout, switching branches +- **Git worktrees**: Each ticket gets its own worktree directory for full isolation + +Worktrees allow multiple agents to work on different tickets simultaneously without branch conflicts. + +**Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back + +### 4. Tmux Onboarding + +*Help and documentation about tmux session management (shown if tmux selected)* + +Operator launches Coding agents in tmux sessions. Essential commands: +- **Detach from session**: Ctrl+a (quick, no prefix needed!) +- **Fallback detach**: Ctrl+b then d +- **List sessions**: `tmux ls` +- **Attach to session**: `tmux attach -t ` + +Operator session names start with 'op-' for easy identification. + +**Navigation**: Enter to continue, Esc to go back + +### 5. VS Code Setup + +*VS Code extension setup and verification (shown if VS Code selected)* + +Operator integrates with the VS Code extension to launch agents as tasks. +This step verifies the extension is installed and the webhook server is reachable. + +Install the extension from the VS Code marketplace if prompted. + +**Navigation**: Enter to continue, Esc to go back + +### 6. Cmux Setup + +*cmux session wrapper setup (shown if cmux selected)* + +cmux is a lightweight tmux wrapper that pre-applies Operator's preferred session defaults. + +This step verifies cmux is installed and accessible in your PATH. + +**Navigation**: Enter to continue, Esc to go back + +### 7. Zellij Setup + +*Zellij session wrapper setup (shown if Zellij selected)* + +Zellij is a modern terminal workspace with built-in layouts and multiplexing. + +This step verifies Zellij is installed and configures the layout Operator will use when launching agents. + +**Navigation**: Enter to continue, Esc to go back + +### 8. Kanban Info + +*Kanban integration overview and provider credential detection* + +Operator can sync with external kanban providers to pull in issues as tickets. +Supported providers: Jira, Linear, GitHub Projects. + +Credentials are read from environment variables (e.g. OPERATOR_JIRA_API_KEY). This step shows which providers were detected and validates connectivity. + +**Navigation**: Enter to continue, Esc to go back + +### 9. Kanban Provider Setup + +*Per-provider credential validation and project selection* + +For each detected provider, Operator: +1. Validates your API credentials against the provider +2. Fetches your workspace and user information +3. Discovers available projects for you to select + +Only projects you select will be synced to your ticket queue. You can skip this step to configure kanban providers later. + +**Navigation**: ↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back + +### 10. Collection Source + +*Choose which issue type collection to use* Select a preset collection of issue types: - **Simple**: Just TASK - minimal setup for general work - **Dev Kanban**: 3 types (TASK, FEAT, FIX) for development workflows - **DevOps Kanban**: 5 types (TASK, SPIKE, INV, FEAT, FIX) for full DevOps -- **Import from Jira**: (Coming soon) -- **Import from Notion**: (Coming soon) - **Custom Selection**: Choose individual issue types **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 3. Custom Collection +### 11. Custom Collection *Select individual issue types (only shown if Custom Selection chosen)* @@ -67,33 +168,31 @@ At least one issue type must be selected to proceed. **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 4. Task Field Config +### 12. Task Field Config *Configure optional fields for TASK issue type* TASK is the foundational issue type. Configure which optional fields to include: - **priority**: Priority level (P0-critical to P3-low) -- **context**: Background context for the task +- **points**: Story points estimate +- **user_story**: User story or background context These choices propagate to other issue types. The 'summary' field is always required, and 'id' is auto-generated. **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 5. Tmux Onboarding +### 13. Acceptance Criteria -*Help and documentation about tmux session management* +*Review and configure acceptance criteria for ticket completion* -Operator launches Coding agents in tmux sessions. Essential commands: -- **Detach from session**: Ctrl+a (quick, no prefix needed!) -- **Fallback detach**: Ctrl+b then d -- **List sessions**: `tmux ls` -- **Attach to session**: `tmux attach -t ` +Define what 'done' means for tickets in this workspace. +Acceptance criteria are checked by agents before marking a ticket complete. -Operator session names start with 'op-' for easy identification. +The default criteria cover formatting, tests, and lint checks. You can customize them for your team's standards. **Navigation**: Enter to continue, Esc to go back -### 6. Startup Tickets +### 14. Startup Tickets *Optionally create tickets to bootstrap your projects* @@ -106,7 +205,7 @@ These tickets are optional and help automate common setup tasks. **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 7. Confirm +### 15. Confirm *Review settings and confirm initialization* diff --git a/docs/vscode/index.html b/docs/vscode/index.html new file mode 100644 index 0000000..d5bc5de --- /dev/null +++ b/docs/vscode/index.html @@ -0,0 +1,16 @@ + + + + + Redirecting to VS Code Marketplace + + + + + + +

Redirecting to the Operator! VS Code extension on the Marketplace

+ + diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 9cd7a51..4bce553 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -39,10 +39,15 @@ pub struct SetupStepInfo { /// All setup wizard steps in order. /// -/// These steps correspond to the `SetupStep` enum in `src/ui/setup.rs`. +/// These steps correspond to the `SetupStep` enum in `src/ui/setup/types.rs`. +/// Steps follow a progressive disclosure model: config/welcome first, then +/// connections (session wrapper + git), then kanban providers, then issue type +/// selection, and finally confirmation. +/// /// When adding new steps to the setup wizard, add corresponding entries here. #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub static SETUP_STEPS: &[SetupStepInfo] = &[ + // ── Tier 0: Config / Welcome ───────────────────────────────────────────── SetupStepInfo { name: "Welcome", description: "Splash screen showing detected LLM tools and discovered projects", @@ -53,15 +58,92 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ This gives you an overview of your development environment before proceeding.", navigation: "Enter to continue, Esc to cancel", }, + // ── Tier 1: Connections (session wrapper + git) ────────────────────────── + SetupStepInfo { + name: "Session Wrapper Choice", + description: "Select which session wrapper to use for launching coding agents", + help_text: "Choose how Operator will manage coding agent sessions:\n\ + - **tmux**: Terminal multiplexer, recommended for most setups\n\ + - **VS Code**: Launch agents as VS Code tasks (requires extension)\n\ + - **cmux**: Lightweight tmux wrapper with operator defaults pre-applied\n\ + - **Zellij**: Modern terminal workspace with built-in layouts\n\n\ + Your choice determines which setup steps follow.", + navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", + }, + SetupStepInfo { + name: "Worktree Preference", + description: "Choose whether to use git worktrees for ticket isolation", + help_text: "Configure how Operator manages git branches per ticket:\n\ + - **In-place branches**: Each agent works in the main checkout, switching branches\n\ + - **Git worktrees**: Each ticket gets its own worktree directory for full isolation\n\n\ + Worktrees allow multiple agents to work on different tickets simultaneously \ + without branch conflicts.", + navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", + }, + SetupStepInfo { + name: "Tmux Onboarding", + description: "Help and documentation about tmux session management (shown if tmux selected)", + help_text: "Operator launches Coding agents in tmux sessions. Essential commands:\n\ + - **Detach from session**: Ctrl+a (quick, no prefix needed!)\n\ + - **Fallback detach**: Ctrl+b then d\n\ + - **List sessions**: `tmux ls`\n\ + - **Attach to session**: `tmux attach -t `\n\n\ + Operator session names start with 'op-' for easy identification.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStepInfo { + name: "VS Code Setup", + description: "VS Code extension setup and verification (shown if VS Code selected)", + help_text: "Operator integrates with the VS Code extension to launch agents as tasks.\n\ + This step verifies the extension is installed and the webhook server is reachable.\n\n\ + Install the extension from the VS Code marketplace if prompted.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStepInfo { + name: "Cmux Setup", + description: "cmux session wrapper setup (shown if cmux selected)", + help_text: "cmux is a lightweight tmux wrapper that pre-applies Operator's preferred \ + session defaults.\n\n\ + This step verifies cmux is installed and accessible in your PATH.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStepInfo { + name: "Zellij Setup", + description: "Zellij session wrapper setup (shown if Zellij selected)", + help_text: "Zellij is a modern terminal workspace with built-in layouts and multiplexing.\n\n\ + This step verifies Zellij is installed and configures the layout Operator will use \ + when launching agents.", + navigation: "Enter to continue, Esc to go back", + }, + // ── Tier 2: Kanban providers ───────────────────────────────────────────── + SetupStepInfo { + name: "Kanban Info", + description: "Kanban integration overview and provider credential detection", + help_text: "Operator can sync with external kanban providers to pull in issues as tickets.\n\ + Supported providers: Jira, Linear, GitHub Projects.\n\n\ + Credentials are read from environment variables (e.g. OPERATOR_JIRA_API_KEY). \ + This step shows which providers were detected and validates connectivity.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStepInfo { + name: "Kanban Provider Setup", + description: "Per-provider credential validation and project selection", + help_text: "For each detected provider, Operator:\n\ + 1. Validates your API credentials against the provider\n\ + 2. Fetches your workspace and user information\n\ + 3. Discovers available projects for you to select\n\n\ + Only projects you select will be synced to your ticket queue. \ + You can skip this step to configure kanban providers later.", + navigation: "↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back", + }, + // ── Tier 3: Issue types (configured after kanban providers are connected) ─ SetupStepInfo { name: "Collection Source", - description: "Choose which ticket template collection to use", + description: "Choose which issue type collection to use", help_text: "Select a preset collection of issue types:\n\ - **Simple**: Just TASK - minimal setup for general work\n\ - **Dev Kanban**: 3 types (TASK, FEAT, FIX) for development workflows\n\ - **DevOps Kanban**: 5 types (TASK, SPIKE, INV, FEAT, FIX) for full DevOps\n\ - - **Import from Jira**: (Coming soon)\n\ - - **Import from Notion**: (Coming soon)\n\ - **Custom Selection**: Choose individual issue types", navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", }, @@ -83,20 +165,20 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ help_text: "TASK is the foundational issue type. Configure which optional fields to include:\n\ - **priority**: Priority level (P0-critical to P3-low)\n\ - - **context**: Background context for the task\n\n\ + - **points**: Story points estimate\n\ + - **user_story**: User story or background context\n\n\ These choices propagate to other issue types. The 'summary' field is always required, \ and 'id' is auto-generated.", navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", }, + // ── Tier 4: Finalize ───────────────────────────────────────────────────── SetupStepInfo { - name: "Tmux Onboarding", - description: "Help and documentation about tmux session management", - help_text: "Operator launches Coding agents in tmux sessions. Essential commands:\n\ - - **Detach from session**: Ctrl+a (quick, no prefix needed!)\n\ - - **Fallback detach**: Ctrl+b then d\n\ - - **List sessions**: `tmux ls`\n\ - - **Attach to session**: `tmux attach -t `\n\n\ - Operator session names start with 'op-' for easy identification.", + name: "Acceptance Criteria", + description: "Review and configure acceptance criteria for ticket completion", + help_text: "Define what 'done' means for tickets in this workspace.\n\ + Acceptance criteria are checked by agents before marking a ticket complete.\n\n\ + The default criteria cover formatting, tests, and lint checks. \ + You can customize them for your team's standards.", navigation: "Enter to continue, Esc to go back", }, SetupStepInfo { @@ -151,9 +233,12 @@ mod tests { #[test] fn test_setup_steps_count_matches_enum() { - // There are 7 steps in SetupStep enum (Welcome, CollectionSource, CustomCollection, - // TaskFieldConfig, TmuxOnboarding, StartupTickets, Confirm) - assert_eq!(SETUP_STEPS.len(), 7); + // 15 steps: Welcome, SessionWrapperChoice, WorktreePreference, + // TmuxOnboarding, VSCodeSetup, CmuxSetup, ZellijSetup, + // KanbanInfo, KanbanProviderSetup, + // CollectionSource, CustomCollection, TaskFieldConfig, + // AcceptanceCriteria, StartupTickets, Confirm + assert_eq!(SETUP_STEPS.len(), 15); } #[test] diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs index 0a61006..252b55e 100644 --- a/src/ui/sections/delegator_section.rs +++ b/src/ui/sections/delegator_section.rs @@ -36,6 +36,19 @@ impl StatusSection for DelegatorSection { } fn children(&self, snapshot: &StatusSnapshot) -> Vec { + if snapshot.delegators.is_empty() { + return vec![TreeRow { + section_id: SectionId::Delegators, + depth: 1, + label: "Add delegator".into(), + description: "Edit config to configure a delegator".into(), + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet::primary(StatusAction::EditFile(snapshot.config_path.clone())), + health: SectionHealth::Gray, + }]; + } + snapshot .delegators .iter() @@ -126,6 +139,18 @@ mod tests { } } + #[test] + fn test_empty_delegators_shows_add_row() { + let snap = snapshot_with(vec![]); + let rows = DelegatorSection.children(&snap); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "Add delegator"); + assert!(matches!( + &rows[0].actions.primary, + StatusAction::EditFile(_) + )); + } + #[test] fn test_description_includes_server_when_set() { let snap = snapshot_with(vec![delegator( diff --git a/vscode-extension/src/sections/delegator-section.ts b/vscode-extension/src/sections/delegator-section.ts index 9c76faa..dea5177 100644 --- a/vscode-extension/src/sections/delegator-section.ts +++ b/vscode-extension/src/sections/delegator-section.ts @@ -89,7 +89,7 @@ export class DelegatorSection implements StatusSection { label: 'Add Delegator', icon: 'add', command: { - command: 'operator.openSettings', + command: 'operator.openCreateDelegator', title: 'Add Delegator', }, sectionId: this.sectionId, From ef730eadce3ba28bc925c9b8448cb1f62e37f3bd Mon Sep 17 00:00:00 2001 From: untra Date: Thu, 7 May 2026 11:01:57 -0600 Subject: [PATCH 3/6] feat(relay): merge relay-channel into opr8r as subcommand relay-channel was never code-signed or distributed; opr8r is signed, notarized, and released on all platforms. This moves relay logic into opr8r so agents on any standard install get relay tooling automatically. Changes: - Extract src/relay/ into crates/relay/ (operator-relay shared crate) - Add `opr8r relay-channel` subcommand (replaces standalone binary) - Update llm_command.rs to discover opr8r and emit relay-channel args in MCP config - Remove [[bin]] relay-channel from operator/Cargo.toml - Update relay_integration tests to use opr8r relay-channel - Update CI to build opr8r instead of relay-channel standalone Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yaml | 27 + .gitignore | 1 + Cargo.lock | 16 + Cargo.toml | 4 + README.md | 8 +- crates/relay/Cargo.lock | 1042 ++++++++++++++++++ crates/relay/Cargo.toml | 20 + crates/relay/src/channel_session.rs | 295 ++++++ crates/relay/src/client.rs | 128 +++ crates/relay/src/hub.rs | 1405 +++++++++++++++++++++++++ crates/relay/src/lib.rs | 12 + crates/relay/src/protocol.rs | 328 ++++++ crates/relay/src/session_name.rs | 257 +++++ crates/relay/src/socket_path.rs | 22 + docs/getting-started/agents/claude.md | 11 + docs/getting-started/agents/codex.md | 6 + docs/index.md | 2 + docs/relay/index.md | 70 ++ opr8r/Cargo.lock | 581 +++++++++- opr8r/Cargo.toml | 4 +- opr8r/src/cli.rs | 130 ++- opr8r/src/main.rs | 36 +- opr8r/src/relay_server.rs | 428 ++++++++ src/agents/launcher/cmux_session.rs | 16 + src/agents/launcher/llm_command.rs | 159 +++ src/agents/launcher/tmux_session.rs | 18 + src/agents/launcher/zellij_session.rs | 16 + src/app/mod.rs | 26 +- src/env_vars.rs | 4 + src/lib.rs | 3 + src/main.rs | 3 +- src/relay/mod.rs | 10 + src/startup/mod.rs | 12 +- tests/relay_integration.rs | 1278 ++++++++++++++++++++++ 34 files changed, 6322 insertions(+), 56 deletions(-) create mode 100644 crates/relay/Cargo.lock create mode 100644 crates/relay/Cargo.toml create mode 100644 crates/relay/src/channel_session.rs create mode 100644 crates/relay/src/client.rs create mode 100644 crates/relay/src/hub.rs create mode 100644 crates/relay/src/lib.rs create mode 100644 crates/relay/src/protocol.rs create mode 100644 crates/relay/src/session_name.rs create mode 100644 crates/relay/src/socket_path.rs create mode 100644 docs/relay/index.md create mode 100644 opr8r/src/relay_server.rs create mode 100644 src/relay/mod.rs create mode 100644 tests/relay_integration.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9738543..00d60b8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -159,6 +159,33 @@ jobs: tmux list-sessions -F '#{session_name}' 2>/dev/null | grep '^op' | xargs -I {} tmux kill-session -t {} || true zellij delete-all-sessions --yes --force 2>/dev/null || true + relay-integration: + needs: lint-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install toolchain + uses: dtolnay/rust-toolchain@1.85.0 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-relay-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-relay- + + - name: Build opr8r (provides relay-channel subcommand) + run: cargo build --manifest-path opr8r/Cargo.toml + + - name: Run relay integration tests + env: + OPERATOR_RELAY_INTEGRATION_TEST_ENABLED: 'true' + run: cargo test --test relay_integration -- --nocapture --test-threads=1 + build: needs: lint-test if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/.gitignore b/.gitignore index 2897269..834975f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Rust build artifacts +crates/**/target/ /target/ opr8r/target/ zed-extension/target/ diff --git a/Cargo.lock b/Cargo.lock index e717aa8..0b47045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,6 +2023,7 @@ dependencies = [ "notify", "notify-rust", "once_cell", + "operator-relay", "ratatui", "regex", "reqwest", @@ -2050,6 +2051,21 @@ dependencies = [ "which", ] +[[package]] +name = "operator-relay" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dirs", + "notify", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "option-ext" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 2161b5a..6d3d460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,9 @@ name = "generate_types" path = "src/bin/generate_types.rs" [dependencies] +# Shared relay crate (also used by opr8r) +operator-relay = { path = "crates/relay" } + # TUI ratatui = "0.29" crossterm = "0.28" @@ -89,6 +92,7 @@ utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-swagger-ui = { version = "8", features = ["axum"] } [dev-dependencies] +operator-relay = { path = "crates/relay" } tempfile = "3" [lints.rust] diff --git a/README.md b/README.md index 446c75d..b02c24d 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ # Operator! [![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/untra.operator-terminals?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals) -**|** **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) +* **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) -**|** **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) +* **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) -**|** **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) +* **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) -**|** **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) +* **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) 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. diff --git a/crates/relay/Cargo.lock b/crates/relay/Cargo.lock new file mode 100644 index 0000000..767e158 --- /dev/null +++ b/crates/relay/Cargo.lock @@ -0,0 +1,1042 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "operator-relay" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dirs", + "notify", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/relay/Cargo.toml b/crates/relay/Cargo.toml new file mode 100644 index 0000000..c3ccf89 --- /dev/null +++ b/crates/relay/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "operator-relay" +version = "0.1.0" +edition = "2021" +description = "Relay hub and channel-session types for operator and opr8r" +license = "MIT" + +[dependencies] +tokio = { version = "1", features = ["rt", "io-util", "net", "sync", "time", "macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } +anyhow = "1" +async-trait = "0.1" +notify = "7" +dirs = "5" +tracing = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/relay/src/channel_session.rs b/crates/relay/src/channel_session.rs new file mode 100644 index 0000000..64cbb70 --- /dev/null +++ b/crates/relay/src/channel_session.rs @@ -0,0 +1,295 @@ +//! Bidirectional relay session for the relay-channel binary. +//! +//! Unlike the thin `RelayClient` (which drops the read half after registration), +//! `ChannelSession` keeps a persistent background reader task and routes every +//! incoming `ServerMsg` to the appropriate waiter: +//! +//! - `Peers` / `Ack` / `Err { req_id: Some(_) }` → resolve via `req_map` +//! - `IncomingReply` / `Err { ask_id: Some(_) }` → resolve via `ask_map` +//! - `BroadcastAck` → resolve via `bcast_map` +//! - `IncomingAsk` → pushed to the caller via `incoming_ask_tx` + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::net::UnixStream; +use tokio::sync::{mpsc, oneshot}; + +use crate::protocol::{ClientMsg, PeerRecord, ServerMsg, PROTOCOL_VERSION}; + +type ReqMap = Arc>>>; +type BcastMap = Arc>>>; + +/// A connected relay peer with full bidirectional message routing. +pub struct ChannelSession { + name: String, + write_tx: mpsc::Sender, + req_map: ReqMap, + bcast_map: BcastMap, +} + +impl ChannelSession { + /// Connect to the hub, register, and return the session plus a channel for + /// unsolicited incoming asks (which the MCP layer forwards to the LLM). + pub async fn connect( + socket_path: &Path, + name: String, + cwd: String, + git_branch: String, + ) -> anyhow::Result<(Self, mpsc::Receiver)> { + let stream = UnixStream::connect(socket_path).await.map_err(|e| { + anyhow::anyhow!( + "Cannot connect to relay hub at {}: {e}", + socket_path.display() + ) + })?; + + // Use into_split so that dropping OwnedWriteHalf shuts down the socket write + // direction, letting the hub detect the disconnect via EOF on its reader. + let (read_half, write_half): (OwnedReadHalf, OwnedWriteHalf) = stream.into_split(); + let (write_tx, mut write_rx) = mpsc::channel::(32); + + // Writer task + tokio::spawn(async move { + let mut wh = write_half; + while let Some(msg) = write_rx.recv().await { + if let Ok(mut line) = serde_json::to_string(&msg) { + line.push('\n'); + if wh.write_all(line.as_bytes()).await.is_err() { + break; + } + } + } + }); + + // Send Register + let register_msg = ClientMsg::Register { + name: name.clone(), + cwd, + git_branch, + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }; + write_tx + .send(register_msg) + .await + .map_err(|_| anyhow::anyhow!("relay write channel closed before register"))?; + + // Read registration ack + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + reader.read_line(&mut line), + ) + .await + .map_err(|_| anyhow::anyhow!("Timed out waiting for hub registration ack"))? + .map_err(|e| anyhow::anyhow!("I/O error reading registration ack: {e}"))?; + + let resp: serde_json::Value = serde_json::from_str(line.trim()) + .map_err(|e| anyhow::anyhow!("Invalid ack from hub: {e}"))?; + if resp.get("type").and_then(|t| t.as_str()) != Some("ack") { + let code = resp + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or("unknown"); + return Err(anyhow::anyhow!("Registration failed: {code}")); + } + + let req_map: ReqMap = Arc::new(Mutex::new(HashMap::new())); + let bcast_map: BcastMap = Arc::new(Mutex::new(HashMap::new())); + let (incoming_ask_tx, incoming_ask_rx) = mpsc::channel::(32); + + // Background reader task: route all subsequent ServerMsg + { + let req_map = req_map.clone(); + let bcast_map = bcast_map.clone(); + tokio::spawn(async move { + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let Ok(msg) = serde_json::from_str::(line.trim()) else { + continue; + }; + route_msg(msg, &req_map, &bcast_map, &incoming_ask_tx).await; + } + }); + } + + Ok(( + Self { + name, + write_tx, + req_map, + bcast_map, + }, + incoming_ask_rx, + )) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// Send an ask fire-and-forget; the reply arrives as an `IncomingReply` notification. + pub async fn send_ask( + &self, + to: String, + question: String, + ask_id: String, + timeout_ms: Option, + thread_id: Option, + ) -> anyhow::Result<()> { + self.send(ClientMsg::Ask { + to, + question, + ask_id, + timeout_ms, + thread_id, + }) + .await + } + + /// List all peers registered with the hub (excluding self). + pub async fn list_peers(&self) -> anyhow::Result> { + let req_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.req_map.lock().unwrap().insert(req_id.clone(), tx); + self.send(ClientMsg::ListPeers { + req_id: Some(req_id), + }) + .await?; + match tokio::time::timeout(std::time::Duration::from_secs(10), rx) + .await + .map_err(|_| anyhow::anyhow!("list_peers timed out"))? + .map_err(|_| anyhow::anyhow!("list_peers channel dropped"))? + { + ServerMsg::Peers { peers, .. } => Ok(peers), + ServerMsg::Err { code, message, .. } => { + Err(anyhow::anyhow!("list_peers error: {code:?} — {message:?}")) + } + other => Err(anyhow::anyhow!("unexpected list_peers response: {other:?}")), + } + } + + /// Reply to an incoming ask (fire-and-forget). + pub fn reply(&self, ask_id: String, text: String) { + let write_tx = self.write_tx.clone(); + tokio::spawn(async move { + let _ = write_tx.send(ClientMsg::Reply { ask_id, text }).await; + }); + } + + /// Broadcast a question to all peers and return the number of peers reached. + pub async fn broadcast(&self, question: String) -> anyhow::Result { + let broadcast_id = uuid::Uuid::new_v4().to_string(); + self.broadcast_with_id(question, broadcast_id, None).await + } + + /// Broadcast with a caller-supplied ID and optional `exclude_self` override; returns peer count. + /// `exclude_self: None` delegates to the hub default (exclude self). + pub async fn broadcast_with_id( + &self, + question: String, + broadcast_id: String, + exclude_self: Option, + ) -> anyhow::Result { + let (tx, rx) = oneshot::channel::(); + self.bcast_map + .lock() + .unwrap() + .insert(broadcast_id.clone(), tx); + self.send(ClientMsg::Broadcast { + question, + broadcast_id, + exclude_self, + }) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(10), rx) + .await + .map_err(|_| anyhow::anyhow!("broadcast timed out"))? + .map_err(|_| anyhow::anyhow!("broadcast channel dropped")) + } + + /// Rename this peer on the hub. + pub async fn rename(&self, new_name: String) -> anyhow::Result<()> { + let req_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + self.req_map.lock().unwrap().insert(req_id.clone(), tx); + self.send(ClientMsg::Rename { + new_name, + req_id: Some(req_id), + }) + .await?; + match tokio::time::timeout(std::time::Duration::from_secs(5), rx) + .await + .map_err(|_| anyhow::anyhow!("rename timed out"))? + .map_err(|_| anyhow::anyhow!("rename channel dropped"))? + { + ServerMsg::Ack { .. } => Ok(()), + ServerMsg::Err { code, message, .. } => { + Err(anyhow::anyhow!("rename error: {code:?} — {message:?}")) + } + other => Err(anyhow::anyhow!("unexpected rename response: {other:?}")), + } + } + + async fn send(&self, msg: ClientMsg) -> anyhow::Result<()> { + self.write_tx + .send(msg) + .await + .map_err(|_| anyhow::anyhow!("relay write channel closed")) + } +} + +async fn route_msg( + msg: ServerMsg, + req_map: &ReqMap, + bcast_map: &BcastMap, + incoming_ask_tx: &mpsc::Sender, +) { + match &msg { + ServerMsg::Peers { + req_id: Some(id), .. + } + | ServerMsg::Ack { req_id: Some(id) } => { + if let Some(tx) = req_map.lock().unwrap().remove(id) { + let _ = tx.send(msg); + } + } + ServerMsg::Err { + req_id: Some(id), .. + } => { + if let Some(tx) = req_map.lock().unwrap().remove(id) { + let _ = tx.send(msg); + } + } + ServerMsg::IncomingReply { .. } | ServerMsg::IncomingAsk { .. } => { + let _ = incoming_ask_tx.send(msg).await; + } + ServerMsg::Err { + ask_id: Some(_), .. + } => { + let _ = incoming_ask_tx.send(msg).await; + } + ServerMsg::BroadcastAck { + broadcast_id, + peer_count, + } => { + let id = broadcast_id.clone(); + let count = *peer_count; + if let Some(tx) = bcast_map.lock().unwrap().remove(&id) { + let _ = tx.send(count); + } + } + // Ack/Peers/Err without correlation ID — ignore + _ => {} + } +} diff --git a/crates/relay/src/client.rs b/crates/relay/src/client.rs new file mode 100644 index 0000000..2e7afd1 --- /dev/null +++ b/crates/relay/src/client.rs @@ -0,0 +1,128 @@ +//! Thin relay client for connecting to the hub from opr8r or the relay-channel binary. +//! +//! Handles connection, registration, and rename. Does not manage reconnection — +//! that is the caller's responsibility for long-lived use cases. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; + +use crate::protocol::{ClientMsg, PROTOCOL_VERSION}; + +/// A connected relay peer. Stays registered until `close()` is called or dropped. +pub struct RelayClient { + socket_path: PathBuf, + name: Arc>, + write_tx: mpsc::Sender, +} + +impl RelayClient { + /// Connect to the hub, register with the given name, and return the client. + /// + /// Returns an error if the hub is not reachable or registration fails. + pub async fn connect( + socket_path: &Path, + name: String, + cwd: String, + git_branch: String, + ) -> anyhow::Result { + let stream = UnixStream::connect(socket_path).await.map_err(|e| { + anyhow::anyhow!( + "Cannot connect to relay hub at {}: {e}", + socket_path.display() + ) + })?; + + let (read_half, write_half) = tokio::io::split(stream); + let (write_tx, mut write_rx) = mpsc::channel::(32); + + // Writer task + tokio::spawn(async move { + let mut write_half = write_half; + while let Some(line) = write_rx.recv().await { + if write_half.write_all(line.as_bytes()).await.is_err() { + break; + } + } + }); + + let client = Self { + socket_path: socket_path.to_path_buf(), + name: Arc::new(Mutex::new(name.clone())), + write_tx, + }; + + // Register + let register_msg = ClientMsg::Register { + name, + cwd, + git_branch, + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }; + client.send_msg(®ister_msg).await?; + + // Read ack (with timeout) + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + reader.read_line(&mut line), + ) + .await + .map_err(|_| anyhow::anyhow!("Timed out waiting for hub registration ack"))? + .map_err(|e| anyhow::anyhow!("I/O error reading registration ack: {e}"))?; + + let resp: serde_json::Value = serde_json::from_str(line.trim()) + .map_err(|e| anyhow::anyhow!("Invalid ack from hub: {e}"))?; + + if resp.get("type").and_then(|t| t.as_str()) != Some("ack") { + let code = resp + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or("unknown"); + return Err(anyhow::anyhow!("Registration failed: {code}")); + } + + Ok(client) + } + + /// Send a rename to the hub. + pub async fn rename(&self, new_name: String) -> anyhow::Result<()> { + let msg = ClientMsg::Rename { + new_name: new_name.clone(), + req_id: None, + }; + self.send_msg(&msg).await?; + *self.name.lock().unwrap() = new_name; + Ok(()) + } + + /// Current registered name. + pub fn name(&self) -> String { + self.name.lock().unwrap().clone() + } + + pub fn socket_path(&self) -> &Path { + &self.socket_path + } + + /// Close the connection gracefully (drops the write channel, hub detects disconnect). + pub fn close(self) { + // Dropping write_tx closes the write channel; writer task exits; hub sees EOF + drop(self.write_tx); + } + + async fn send_msg(&self, msg: &ClientMsg) -> anyhow::Result<()> { + let mut line = serde_json::to_string(msg) + .map_err(|e| anyhow::anyhow!("Failed to serialize relay message: {e}"))?; + line.push('\n'); + self.write_tx + .send(line) + .await + .map_err(|_| anyhow::anyhow!("Relay write channel closed"))?; + Ok(()) + } +} diff --git a/crates/relay/src/hub.rs b/crates/relay/src/hub.rs new file mode 100644 index 0000000..3a5a058 --- /dev/null +++ b/crates/relay/src/hub.rs @@ -0,0 +1,1405 @@ +//! Relay hub — tokio actor that owns the peer registry and routes ask/reply/broadcast messages. +//! +//! The hub runs embedded in operator's async runtime (lifetime = operator lifetime). +//! No idle-shutdown timer: the hub exits only when operator exits. +//! +//! Wire protocol is byte-compatible with claude-relay's hub, so existing TypeScript +//! channel binaries connect unchanged. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::mpsc; + +use crate::protocol::{ + self, ClientMsg, ErrCode, PeerRecord, ServerMsg, MAX_LINE_LEN, PROTOCOL_VERSION, +}; + +// ── Public handle ──────────────────────────────────────────────────────────── + +/// Handle to the running relay hub. +pub struct RelayHub { + cmd_tx: mpsc::Sender, + socket_path: PathBuf, + accept_handle: tokio::task::JoinHandle<()>, +} + +impl RelayHub { + /// Start the relay hub on the given socket path. + /// + /// Performs stale-socket recovery: if the socket file exists but is not responsive, + /// it is removed and a fresh listener is bound. + pub async fn start(socket_path: PathBuf) -> anyhow::Result { + // Create parent directory + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o700); + let _ = std::fs::set_permissions(parent, perms); + } + } + + // Stale socket recovery + if socket_path.exists() { + match tokio::time::timeout( + Duration::from_millis(200), + UnixStream::connect(&socket_path), + ) + .await + { + Ok(Ok(_)) => { + return Err(anyhow::anyhow!( + "Relay hub socket {} is already in use (another hub may be running)", + socket_path.display() + )); + } + _ => { + // Stale socket — remove it + let _ = std::fs::remove_file(&socket_path); + } + } + } + + let listener = UnixListener::bind(&socket_path)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&socket_path, perms); + } + + let (cmd_tx, cmd_rx) = mpsc::channel(512); + + // Spawn actor + let actor_cmd_tx = cmd_tx.clone(); + tokio::spawn(async move { + run_actor(cmd_rx, actor_cmd_tx).await; + }); + + // Spawn accept loop + let accept_cmd_tx = cmd_tx.clone(); + let accept_handle = tokio::spawn(async move { + run_accept_loop(listener, accept_cmd_tx).await; + }); + + tracing::info!(socket = %socket_path.display(), "Relay hub started"); + + Ok(Self { + cmd_tx, + socket_path, + accept_handle, + }) + } + + /// Gracefully shut down the hub. + pub async fn shutdown(self) { + self.accept_handle.abort(); + let _ = self.cmd_tx.send(HubCommand::Shutdown).await; + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = std::fs::remove_file(&self.socket_path); + tracing::info!("Relay hub shut down"); + } + + pub fn socket_path(&self) -> &Path { + &self.socket_path + } +} + +// ── Actor commands ──────────────────────────────────────────────────────────── + +enum HubCommand { + ClientConnected { + conn_id: u64, + write_tx: mpsc::Sender, + }, + ClientLine { + conn_id: u64, + line: String, + }, + ClientDisconnect { + conn_id: u64, + }, + TimeoutExpired { + ask_id: String, + }, + Shutdown, +} + +// ── State ───────────────────────────────────────────────────────────────────── + +struct PeerEntry { + name: String, + cwd: String, + git_branch: String, + last_seen: u64, + /// Non-None for operator-managed peers: evict if `now - last_seen > lease_ms`. + lease_ms: Option, +} + +struct PendingAsk { + caller: String, + target: String, + broadcast_id: Option, + thread_id: Option, + timeout_abort: tokio::task::AbortHandle, +} + +struct HubState { + name_to_id: HashMap, + id_to_entry: HashMap, + pending: HashMap, + senders: HashMap>, + cmd_tx: mpsc::Sender, + default_timeout_ms: u64, +} + +impl HubState { + fn new(cmd_tx: mpsc::Sender) -> Self { + Self { + name_to_id: HashMap::new(), + id_to_entry: HashMap::new(), + pending: HashMap::new(), + senders: HashMap::new(), + cmd_tx, + default_timeout_ms: 120_000, + } + } + + fn send_to_id(&self, conn_id: u64, msg: ServerMsg) { + if let Some(tx) = self.senders.get(&conn_id) { + let _ = tx.try_send(msg); + } + } + + fn send_to_name(&self, name: &str, msg: ServerMsg) { + if let Some(&conn_id) = self.name_to_id.get(name) { + self.send_to_id(conn_id, msg); + } + } + + fn get_name(&self, conn_id: u64) -> Option<&str> { + self.id_to_entry.get(&conn_id).map(|e| e.name.as_str()) + } + + fn peer_list(&self, exclude: Option<&str>) -> Vec { + self.id_to_entry + .values() + .filter(|e| exclude.is_none_or(|n| e.name != n)) + .map(|e| PeerRecord { + name: e.name.clone(), + cwd: e.cwd.clone(), + git_branch: e.git_branch.clone(), + last_seen: e.last_seen, + }) + .collect() + } + + fn all_names(&self) -> Vec { + self.id_to_entry.values().map(|e| e.name.clone()).collect() + } + + // ── Message dispatch ────────────────────────────────────────────────────── + + fn handle_line(&mut self, conn_id: u64, line: &str) { + let Ok(raw) = serde_json::from_str::(line) else { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::BadMsg, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + }; + let Ok(msg) = serde_json::from_value::(raw) else { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::BadMsg, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + }; + + if let Some(entry) = self.id_to_entry.get_mut(&conn_id) { + entry.last_seen = protocol::now_ms(); + } + + match msg { + ClientMsg::Register { + name, + cwd, + git_branch, + protocol_version, + lease_ms, + } => { + self.handle_register(conn_id, name, cwd, git_branch, &protocol_version, lease_ms); + } + ClientMsg::Rename { new_name, req_id } => { + self.handle_rename(conn_id, new_name, req_id); + } + ClientMsg::ListPeers { req_id } => { + self.handle_list_peers(conn_id, req_id); + } + ClientMsg::Ask { + to, + question, + ask_id, + timeout_ms, + thread_id, + } => { + self.handle_ask(conn_id, to, question, ask_id, timeout_ms, thread_id); + } + ClientMsg::Reply { ask_id, text } => { + self.handle_reply(conn_id, ask_id, text); + } + ClientMsg::Broadcast { + question, + broadcast_id, + exclude_self, + } => { + self.handle_broadcast(conn_id, question, broadcast_id, exclude_self); + } + } + } + + fn handle_register( + &mut self, + conn_id: u64, + name: String, + cwd: String, + git_branch: String, + protocol_version: &str, + lease_ms: Option, + ) { + if protocol_version != PROTOCOL_VERSION { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::ProtocolMismatch, + message: Some(format!( + "expected {PROTOCOL_VERSION}, got {protocol_version}" + )), + req_id: None, + ask_id: None, + }, + ); + return; + } + if self.id_to_entry.contains_key(&conn_id) { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::AlreadyRegistered, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + } + if self.name_to_id.contains_key(&name) { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::NameTaken, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + } + self.name_to_id.insert(name.clone(), conn_id); + self.id_to_entry.insert( + conn_id, + PeerEntry { + name, + cwd, + git_branch, + last_seen: protocol::now_ms(), + lease_ms, + }, + ); + self.send_to_id(conn_id, ServerMsg::Ack { req_id: None }); + } + + fn handle_rename(&mut self, conn_id: u64, new_name: String, req_id: Option) { + let Some(entry) = self.id_to_entry.get(&conn_id) else { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::NotRegistered, + message: None, + req_id, + ask_id: None, + }, + ); + return; + }; + let old_name = entry.name.clone(); + let already_taken = self + .name_to_id + .get(&new_name) + .is_some_and(|&id| id != conn_id); + if already_taken { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::NameTaken, + message: None, + req_id, + ask_id: None, + }, + ); + return; + } + // Update registry + self.name_to_id.remove(&old_name); + self.name_to_id.insert(new_name.clone(), conn_id); + if let Some(entry) = self.id_to_entry.get_mut(&conn_id) { + entry.name = new_name.clone(); + } + // Update pending asks — must happen before ack (matches TS ordering) + self.update_name_on_rename(&old_name, &new_name); + self.send_to_id(conn_id, ServerMsg::Ack { req_id }); + } + + fn handle_list_peers(&self, conn_id: u64, req_id: Option) { + let self_name = self.get_name(conn_id).map(str::to_string); + let peers = self.peer_list(self_name.as_deref()); + self.send_to_id(conn_id, ServerMsg::Peers { peers, req_id }); + } + + fn handle_ask( + &mut self, + conn_id: u64, + to: String, + question: String, + ask_id: String, + timeout_ms: Option, + thread_id: Option, + ) { + let Some(caller_ref) = self.get_name(conn_id) else { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::NotRegistered, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + }; + let caller = caller_ref.to_string(); + if !self.name_to_id.contains_key(&to) { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::PeerNotFound, + message: None, + req_id: None, + ask_id: Some(ask_id), + }, + ); + return; + } + let timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms); + // Internal thread_id for reply routing; generated if not provided. + // Only forward the caller-provided value in IncomingAsk so receivers + // don't see hub-internal UUIDs they didn't ask for. + let internal_thread_id = thread_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let ask_id_clone = ask_id.clone(); + let cmd_tx = self.cmd_tx.clone(); + let timeout_task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(timeout_ms)).await; + let _ = cmd_tx + .send(HubCommand::TimeoutExpired { + ask_id: ask_id_clone, + }) + .await; + }); + + self.pending.insert( + ask_id.clone(), + PendingAsk { + caller: caller.clone(), + target: to.clone(), + broadcast_id: None, + thread_id: Some(internal_thread_id), + timeout_abort: timeout_task.abort_handle(), + }, + ); + + self.send_to_name( + &to, + ServerMsg::IncomingAsk { + from: caller, + question, + ask_id, + broadcast_id: None, + thread_id, // preserve caller-provided value (may be None) + }, + ); + } + + fn handle_reply(&mut self, conn_id: u64, ask_id: String, text: String) { + let Some(replier_ref) = self.get_name(conn_id) else { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::NotRegistered, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + }; + let replier = replier_ref.to_string(); + // Validate ask exists and replier is the target + let valid = self + .pending + .get(&ask_id) + .is_some_and(|a| a.target == replier); + if !valid { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::UnknownAsk, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + } + let ask = self.pending.remove(&ask_id).expect("validated above"); + ask.timeout_abort.abort(); + self.send_to_name( + &ask.caller, + ServerMsg::IncomingReply { + from: replier, + text, + ask_id, + broadcast_id: ask.broadcast_id, + thread_id: ask.thread_id, + }, + ); + } + + fn handle_broadcast( + &mut self, + conn_id: u64, + question: String, + broadcast_id: String, + exclude_self: Option, + ) { + let Some(caller_ref) = self.get_name(conn_id) else { + self.send_to_id( + conn_id, + ServerMsg::Err { + code: ErrCode::NotRegistered, + message: None, + req_id: None, + ask_id: None, + }, + ); + return; + }; + let caller = caller_ref.to_string(); + let exclude_self = exclude_self.unwrap_or(true); + let thread_id = broadcast_id.clone(); + let targets: Vec = self + .all_names() + .into_iter() + .filter(|n| !exclude_self || n != &caller) + .collect(); + let peer_count = targets.len() as u32; + let timeout_ms = self.default_timeout_ms; + let cmd_tx = self.cmd_tx.clone(); + + for target in targets { + let ask_id = format!("{broadcast_id}:{target}"); + let ask_id_clone = ask_id.clone(); + let cmd_tx_clone = cmd_tx.clone(); + let timeout_task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(timeout_ms)).await; + // Broadcast timeouts don't send errors — replies just stop arriving + let _ = cmd_tx_clone + .send(HubCommand::TimeoutExpired { + ask_id: ask_id_clone, + }) + .await; + }); + + self.pending.insert( + ask_id.clone(), + PendingAsk { + caller: caller.clone(), + target: target.clone(), + broadcast_id: Some(broadcast_id.clone()), + thread_id: Some(thread_id.clone()), + timeout_abort: timeout_task.abort_handle(), + }, + ); + + self.send_to_name( + &target, + ServerMsg::IncomingAsk { + from: caller.clone(), + question: question.clone(), + ask_id, + broadcast_id: Some(broadcast_id.clone()), + thread_id: Some(thread_id.clone()), + }, + ); + } + + self.send_to_id( + conn_id, + ServerMsg::BroadcastAck { + broadcast_id, + peer_count, + }, + ); + } + + fn handle_timeout(&mut self, ask_id: String) { + if let Some(ask) = self.pending.remove(&ask_id) { + // Only send error for direct asks, not broadcast asks + if ask.broadcast_id.is_none() { + self.send_to_name( + &ask.caller, + ServerMsg::Err { + code: ErrCode::Timeout, + message: None, + req_id: None, + ask_id: Some(ask_id), + }, + ); + } + } + // If not found: already resolved (reply/disconnect beat the timeout), ignore + } + + fn handle_disconnect(&mut self, conn_id: u64) { + self.senders.remove(&conn_id); + + let Some(entry) = self.id_to_entry.remove(&conn_id) else { + return; // Already cleaned up + }; + let name = entry.name; + self.name_to_id.remove(&name); + + // Collect asks targeting this peer, then resolve them as peer_gone + let peer_gone_asks: Vec<(String, String)> = self + .pending + .iter() + .filter(|(_, ask)| ask.target == name) + .map(|(ask_id, ask)| (ask_id.clone(), ask.caller.clone())) + .collect(); + + for (ask_id, caller) in peer_gone_asks { + if let Some(ask) = self.pending.remove(&ask_id) { + ask.timeout_abort.abort(); + self.send_to_name( + &caller, + ServerMsg::Err { + code: ErrCode::PeerGone, + message: None, + req_id: None, + ask_id: Some(ask_id), + }, + ); + } + } + } + + fn sweep_expired_leases(&mut self) { + let now = protocol::now_ms(); + let expired: Vec = self + .id_to_entry + .iter() + .filter(|(_, e)| { + e.lease_ms + .is_some_and(|ms| now.saturating_sub(e.last_seen) > ms) + }) + .map(|(id, _)| *id) + .collect(); + for conn_id in expired { + self.handle_disconnect(conn_id); + } + } + + /// Update caller/target strings in all pending asks after a rename. + fn update_name_on_rename(&mut self, old: &str, new: &str) { + for ask in self.pending.values_mut() { + if ask.caller == old { + ask.caller = new.to_string(); + } + if ask.target == old { + ask.target = new.to_string(); + } + } + } +} + +// ── Actor task ──────────────────────────────────────────────────────────────── + +async fn run_actor(mut cmd_rx: mpsc::Receiver, cmd_tx: mpsc::Sender) { + let mut state = HubState::new(cmd_tx); + let mut sweep = tokio::time::interval(Duration::from_secs(1)); + sweep.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + Some(cmd) = cmd_rx.recv() => { + match cmd { + HubCommand::ClientConnected { conn_id, write_tx } => { + state.senders.insert(conn_id, write_tx); + } + HubCommand::ClientLine { conn_id, line } => { + state.handle_line(conn_id, &line); + } + HubCommand::ClientDisconnect { conn_id } => { + state.handle_disconnect(conn_id); + } + HubCommand::TimeoutExpired { ask_id } => { + state.handle_timeout(ask_id); + } + HubCommand::Shutdown => break, + } + } + _ = sweep.tick() => { + state.sweep_expired_leases(); + } + } + } +} + +// ── Accept loop ─────────────────────────────────────────────────────────────── + +async fn run_accept_loop(listener: UnixListener, cmd_tx: mpsc::Sender) { + let next_conn_id = Arc::new(AtomicU64::new(1)); + loop { + match listener.accept().await { + Ok((socket, _)) => { + let conn_id = next_conn_id.fetch_add(1, Ordering::Relaxed); + let cmd_tx = cmd_tx.clone(); + tokio::spawn(async move { + handle_connection(socket, conn_id, cmd_tx).await; + }); + } + Err(e) => { + // Listener was closed (hub shutting down) + tracing::debug!("Relay hub accept loop exiting: {e}"); + break; + } + } + } +} + +async fn handle_connection(socket: UnixStream, conn_id: u64, cmd_tx: mpsc::Sender) { + let (read_half, write_half) = tokio::io::split(socket); + let (write_tx, mut write_rx) = mpsc::channel::(64); + + // Register the write channel with the actor BEFORE starting the reader. + // mpsc is FIFO: actor sees ClientConnected before any ClientLine from this conn. + if cmd_tx + .send(HubCommand::ClientConnected { conn_id, write_tx }) + .await + .is_err() + { + return; + } + + // Writer task: serialize messages and send to socket + tokio::spawn(async move { + let mut write_half = write_half; + while let Some(msg) = write_rx.recv().await { + match serde_json::to_string(&msg) { + Ok(mut line) => { + line.push('\n'); + if write_half.write_all(line.as_bytes()).await.is_err() { + break; + } + } + Err(e) => { + tracing::warn!("Failed to serialize relay message: {e}"); + } + } + } + }); + + // Reader: line-by-line, forward to actor + let mut reader = BufReader::new(read_half); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(n) if n > MAX_LINE_LEN => { + tracing::warn!( + conn_id, + "Relay line too long ({n} bytes), closing connection" + ); + break; + } + Ok(_) => { + let trimmed = line.trim_end_matches(['\n', '\r']).to_string(); + if trimmed.is_empty() { + continue; + } + if cmd_tx + .send(HubCommand::ClientLine { + conn_id, + line: trimmed, + }) + .await + .is_err() + { + break; + } + } + Err(_) => break, + } + } + + let _ = cmd_tx.send(HubCommand::ClientDisconnect { conn_id }).await; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + + // ── Test helpers ────────────────────────────────────────────────────────── + + struct TestHub { + hub: RelayHub, + socket_path: PathBuf, + _dir: tempfile::TempDir, + } + + impl TestHub { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("hub.sock"); + let hub = RelayHub::start(socket_path.clone()).await.unwrap(); + Self { + hub, + socket_path, + _dir: dir, + } + } + } + + struct TestClient { + reader: BufReader>, + writer: tokio::io::WriteHalf, + } + + impl TestClient { + async fn connect(path: &Path) -> Self { + let stream = UnixStream::connect(path).await.unwrap(); + let (r, w) = tokio::io::split(stream); + Self { + reader: BufReader::new(r), + writer: w, + } + } + + async fn send(&mut self, msg: &ClientMsg) { + let mut line = serde_json::to_string(msg).unwrap(); + line.push('\n'); + self.writer.write_all(line.as_bytes()).await.unwrap(); + } + + async fn recv(&mut self) -> ServerMsg { + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(2), self.reader.read_line(&mut line)) + .await + .expect("recv timed out") + .expect("recv error"); + serde_json::from_str(line.trim()).expect("invalid server msg") + } + + async fn register(&mut self, name: &str) { + self.send(&ClientMsg::Register { + name: name.into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }) + .await; + let resp = self.recv().await; + assert!( + matches!(resp, ServerMsg::Ack { .. }), + "expected ack: {resp:?}" + ); + } + } + + // Give the hub a tick to process between sends + async fn tick() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + #[tokio::test] + async fn test_register_ack() { + let th = TestHub::new().await; + let mut c = TestClient::connect(&th.socket_path).await; + c.send(&ClientMsg::Register { + name: "alice".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }) + .await; + let resp = c.recv().await; + assert!(matches!(resp, ServerMsg::Ack { .. })); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_register_duplicate_name_taken() { + let th = TestHub::new().await; + let mut c1 = TestClient::connect(&th.socket_path).await; + let mut c2 = TestClient::connect(&th.socket_path).await; + + c1.register("alice").await; + c2.send(&ClientMsg::Register { + name: "alice".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }) + .await; + let resp = c2.recv().await; + assert!( + matches!( + resp, + ServerMsg::Err { + code: ErrCode::NameTaken, + .. + } + ), + "expected name_taken: {resp:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_register_already_registered() { + let th = TestHub::new().await; + let mut c = TestClient::connect(&th.socket_path).await; + c.register("alice").await; + c.send(&ClientMsg::Register { + name: "alice2".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }) + .await; + let resp = c.recv().await; + assert!( + matches!( + resp, + ServerMsg::Err { + code: ErrCode::AlreadyRegistered, + .. + } + ), + "expected already_registered: {resp:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_protocol_version_mismatch() { + let th = TestHub::new().await; + let mut c = TestClient::connect(&th.socket_path).await; + c.send(&ClientMsg::Register { + name: "alice".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: "99".into(), + lease_ms: None, + }) + .await; + let resp = c.recv().await; + assert!( + matches!( + resp, + ServerMsg::Err { + code: ErrCode::ProtocolMismatch, + .. + } + ), + "expected protocol_mismatch: {resp:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_rename_updates_name() { + let th = TestHub::new().await; + let mut c = TestClient::connect(&th.socket_path).await; + c.register("alice").await; + + c.send(&ClientMsg::Rename { + new_name: "bob".into(), + req_id: Some("r1".into()), + }) + .await; + let resp = c.recv().await; + assert!( + matches!(resp, ServerMsg::Ack { req_id: Some(ref r), .. } if r == "r1"), + "expected ack r1: {resp:?}" + ); + + // Old name should be gone: another client can register as "alice" + let mut c2 = TestClient::connect(&th.socket_path).await; + c2.register("alice").await; // would fail if old name still held + + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_rename_to_taken_name() { + let th = TestHub::new().await; + let mut c1 = TestClient::connect(&th.socket_path).await; + let mut c2 = TestClient::connect(&th.socket_path).await; + c1.register("alice").await; + c2.register("bob").await; + + c1.send(&ClientMsg::Rename { + new_name: "bob".into(), + req_id: None, + }) + .await; + let resp = c1.recv().await; + assert!( + matches!( + resp, + ServerMsg::Err { + code: ErrCode::NameTaken, + .. + } + ), + "expected name_taken: {resp:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_ask_delivers_incoming_ask_to_target() { + let th = TestHub::new().await; + let mut sender = TestClient::connect(&th.socket_path).await; + let mut target = TestClient::connect(&th.socket_path).await; + sender.register("alice").await; + target.register("bob").await; + + sender + .send(&ClientMsg::Ask { + to: "bob".into(), + question: "hello?".into(), + ask_id: "a1".into(), + timeout_ms: Some(5000), + thread_id: None, + }) + .await; + + let incoming = target.recv().await; + assert!( + matches!( + incoming, + ServerMsg::IncomingAsk { ref from, ref question, ref ask_id, .. } + if from == "alice" && question == "hello?" && ask_id == "a1" + ), + "unexpected: {incoming:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_reply_delivers_incoming_reply_to_caller() { + let th = TestHub::new().await; + let mut caller = TestClient::connect(&th.socket_path).await; + let mut target = TestClient::connect(&th.socket_path).await; + caller.register("alice").await; + target.register("bob").await; + + caller + .send(&ClientMsg::Ask { + to: "bob".into(), + question: "ping?".into(), + ask_id: "a2".into(), + timeout_ms: Some(5000), + thread_id: None, + }) + .await; + + let _ = target.recv().await; // incoming_ask + + target + .send(&ClientMsg::Reply { + ask_id: "a2".into(), + text: "pong!".into(), + }) + .await; + tick().await; + + let reply = caller.recv().await; + assert!( + matches!( + reply, + ServerMsg::IncomingReply { ref from, ref text, ref ask_id, .. } + if from == "bob" && text == "pong!" && ask_id == "a2" + ), + "unexpected: {reply:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_ask_to_unknown_peer_returns_peer_not_found() { + let th = TestHub::new().await; + let mut c = TestClient::connect(&th.socket_path).await; + c.register("alice").await; + c.send(&ClientMsg::Ask { + to: "nobody".into(), + question: "hey".into(), + ask_id: "a3".into(), + timeout_ms: None, + thread_id: None, + }) + .await; + let resp = c.recv().await; + assert!( + matches!( + resp, + ServerMsg::Err { + code: ErrCode::PeerNotFound, + .. + } + ), + "expected peer_not_found: {resp:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_ask_timeout_sends_err_to_caller() { + let th = TestHub::new().await; + let mut caller = TestClient::connect(&th.socket_path).await; + let mut target = TestClient::connect(&th.socket_path).await; + caller.register("alice").await; + target.register("bob").await; + + caller + .send(&ClientMsg::Ask { + to: "bob".into(), + question: "waiting...".into(), + ask_id: "a4".into(), + timeout_ms: Some(100), // very short + thread_id: None, + }) + .await; + + let _ = target.recv().await; // incoming_ask (bob doesn't reply) + + // Caller should receive timeout error after 100ms + let err = tokio::time::timeout(Duration::from_secs(2), caller.recv()) + .await + .expect("timeout waiting for error"); + assert!( + matches!(err, ServerMsg::Err { code: ErrCode::Timeout, ask_id: Some(ref id), .. } if id == "a4"), + "expected timeout: {err:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_peer_disconnect_sends_peer_gone_to_caller() { + let th = TestHub::new().await; + let mut caller = TestClient::connect(&th.socket_path).await; + caller.register("alice").await; + + // Connect target, register, send ask, then drop target + { + let mut target = TestClient::connect(&th.socket_path).await; + target.register("bob").await; + caller + .send(&ClientMsg::Ask { + to: "bob".into(), + question: "you there?".into(), + ask_id: "a5".into(), + timeout_ms: Some(10_000), + thread_id: None, + }) + .await; + let _ = target.recv().await; // incoming_ask + // target drops here, simulating disconnect + } + + tick().await; + + let err = tokio::time::timeout(Duration::from_secs(2), caller.recv()) + .await + .expect("timeout waiting for peer_gone"); + assert!( + matches!(err, ServerMsg::Err { code: ErrCode::PeerGone, ask_id: Some(ref id), .. } if id == "a5"), + "expected peer_gone: {err:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_broadcast_delivers_to_all_peers() { + let th = TestHub::new().await; + let mut broadcaster = TestClient::connect(&th.socket_path).await; + let mut peer1 = TestClient::connect(&th.socket_path).await; + let mut peer2 = TestClient::connect(&th.socket_path).await; + broadcaster.register("alice").await; + peer1.register("bob").await; + peer2.register("carol").await; + + broadcaster + .send(&ClientMsg::Broadcast { + question: "everyone?".into(), + broadcast_id: "bc1".into(), + exclude_self: Some(true), + }) + .await; + + let ack = broadcaster.recv().await; + assert!( + matches!(ack, ServerMsg::BroadcastAck { ref broadcast_id, peer_count: 2 } if broadcast_id == "bc1"), + "expected broadcast_ack with peer_count=2: {ack:?}" + ); + + let ask1 = peer1.recv().await; + let ask2 = peer2.recv().await; + assert!( + matches!(ask1, ServerMsg::IncomingAsk { .. }), + "peer1: {ask1:?}" + ); + assert!( + matches!(ask2, ServerMsg::IncomingAsk { .. }), + "peer2: {ask2:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_rename_updates_pending_ask_caller() { + let th = TestHub::new().await; + let mut caller = TestClient::connect(&th.socket_path).await; + let mut target = TestClient::connect(&th.socket_path).await; + caller.register("alice").await; + target.register("bob").await; + + caller + .send(&ClientMsg::Ask { + to: "bob".into(), + question: "q".into(), + ask_id: "a6".into(), + timeout_ms: Some(5000), + thread_id: None, + }) + .await; + let _ = target.recv().await; // incoming_ask + + // alice renames to carol + caller + .send(&ClientMsg::Rename { + new_name: "carol".into(), + req_id: None, + }) + .await; + let _ = caller.recv().await; // ack + + // bob replies — should reach carol (formerly alice) + target + .send(&ClientMsg::Reply { + ask_id: "a6".into(), + text: "ok".into(), + }) + .await; + tick().await; + + let reply = caller.recv().await; + assert!( + matches!(reply, ServerMsg::IncomingReply { ref from, .. } if from == "bob"), + "expected incoming_reply: {reply:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_stale_socket_recovery() { + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("hub.sock"); + + // First hub + let hub1 = RelayHub::start(socket_path.clone()).await.unwrap(); + hub1.accept_handle.abort(); + drop(hub1.cmd_tx); + tokio::time::sleep(Duration::from_millis(50)).await; + // Leave the socket file without unlinking it (stale) + + // Second hub should recover the stale socket and start cleanly + let hub2 = RelayHub::start(socket_path.clone()).await.unwrap(); + let mut c = TestClient::connect(&socket_path).await; + c.register("test").await; + hub2.shutdown().await; + } + + #[tokio::test] + async fn test_bad_json_returns_bad_msg() { + let th = TestHub::new().await; + let mut c = TestClient::connect(&th.socket_path).await; + // Send raw bad JSON (not a valid ClientMsg) + c.writer.write_all(b"not json at all\n").await.unwrap(); + let resp = c.recv().await; + assert!( + matches!( + resp, + ServerMsg::Err { + code: ErrCode::BadMsg, + .. + } + ), + "expected bad_msg: {resp:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_lease_expires_evicts_peer() { + let th = TestHub::new().await; + let mut c1 = TestClient::connect(&th.socket_path).await; + let mut observer = TestClient::connect(&th.socket_path).await; + + // Register with a 150ms lease + c1.send(&ClientMsg::Register { + name: "shortlived".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: Some(150), + }) + .await; + assert!(matches!(c1.recv().await, ServerMsg::Ack { .. })); + observer.register("observer").await; + + // Verify peer is visible + observer.send(&ClientMsg::ListPeers { req_id: None }).await; + let peers = match observer.recv().await { + ServerMsg::Peers { peers, .. } => peers, + other => panic!("expected peers: {other:?}"), + }; + assert!(peers.iter().any(|p| p.name == "shortlived")); + + // Wait for sweep to evict the peer (lease = 150ms, sweep runs every 1s) + tokio::time::sleep(Duration::from_millis(1500)).await; + + observer.send(&ClientMsg::ListPeers { req_id: None }).await; + let peers = match observer.recv().await { + ServerMsg::Peers { peers, .. } => peers, + other => panic!("expected peers: {other:?}"), + }; + assert!( + !peers.iter().any(|p| p.name == "shortlived"), + "evicted peer still visible: {peers:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_lease_resets_on_message() { + let th = TestHub::new().await; + let mut c1 = TestClient::connect(&th.socket_path).await; + let mut observer = TestClient::connect(&th.socket_path).await; + + // Register with a 400ms lease + c1.send(&ClientMsg::Register { + name: "active".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: Some(400), + }) + .await; + assert!(matches!(c1.recv().await, ServerMsg::Ack { .. })); + observer.register("observer").await; + + // At 300ms (within lease), send a message to reset last_seen + tokio::time::sleep(Duration::from_millis(300)).await; + c1.send(&ClientMsg::ListPeers { req_id: None }).await; + let _ = c1.recv().await; // consume Peers response + + // At 600ms total: original lease would have expired (400ms), but last_seen was reset at 300ms + // so the new deadline is 300ms + 400ms = 700ms. Peer must still be alive at 600ms. + tokio::time::sleep(Duration::from_millis(300)).await; + + observer.send(&ClientMsg::ListPeers { req_id: None }).await; + let peers = match observer.recv().await { + ServerMsg::Peers { peers, .. } => peers, + other => panic!("expected peers: {other:?}"), + }; + assert!( + peers.iter().any(|p| p.name == "active"), + "peer evicted early, should still be alive: {peers:?}" + ); + th.hub.shutdown().await; + } + + #[tokio::test] + async fn test_list_peers_excludes_self() { + let th = TestHub::new().await; + let mut c1 = TestClient::connect(&th.socket_path).await; + let mut c2 = TestClient::connect(&th.socket_path).await; + c1.register("alice").await; + c2.register("bob").await; + + c1.send(&ClientMsg::ListPeers { req_id: None }).await; + let resp = c1.recv().await; + if let ServerMsg::Peers { peers, .. } = resp { + assert_eq!(peers.len(), 1, "should see only 1 peer (not self)"); + assert_eq!(peers[0].name, "bob"); + } else { + panic!("expected peers: {resp:?}"); + } + th.hub.shutdown().await; + } +} diff --git a/crates/relay/src/lib.rs b/crates/relay/src/lib.rs new file mode 100644 index 0000000..2829ee6 --- /dev/null +++ b/crates/relay/src/lib.rs @@ -0,0 +1,12 @@ +//! Relay hub and channel-session types for operator and opr8r. +//! +//! This crate is shared between the `operator` TUI and the `opr8r` step-wrapper +//! so that relay tooling can be distributed via the signed `opr8r` binary without +//! pulling in the full TUI/REST dependency stack. + +pub mod channel_session; +pub mod client; +pub mod hub; +pub mod protocol; +pub mod session_name; +pub mod socket_path; diff --git a/crates/relay/src/protocol.rs b/crates/relay/src/protocol.rs new file mode 100644 index 0000000..5e4ad4a --- /dev/null +++ b/crates/relay/src/protocol.rs @@ -0,0 +1,328 @@ +//! Wire protocol types for the relay hub, byte-compatible with claude-relay's protocol.ts. +//! +//! All serde field names and type discriminants match the TypeScript implementation exactly +//! so that existing claude-relay channels can connect to the Rust hub unchanged. + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const PROTOCOL_VERSION: &str = "2"; +pub const MAX_LINE_LEN: usize = 8 * 1024 * 1024; // 8MB + +/// Current time as milliseconds since Unix epoch (matches JS `Date.now()`) +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerRecord { + pub name: String, + pub cwd: String, + pub git_branch: String, + /// Milliseconds since Unix epoch (matches JS `Date.now()`) + pub last_seen: u64, +} + +/// Messages sent from a channel client to the hub. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMsg { + Register { + name: String, + cwd: String, + git_branch: String, + protocol_version: String, + /// Optional TTL in milliseconds for operator-managed peers. + /// TS clients omit this field; serde treats absence as None (infinite lease). + #[serde(skip_serializing_if = "Option::is_none")] + lease_ms: Option, + }, + Rename { + new_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + req_id: Option, + }, + ListPeers { + #[serde(skip_serializing_if = "Option::is_none")] + req_id: Option, + }, + Ask { + to: String, + question: String, + ask_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + Reply { + ask_id: String, + text: String, + }, + Broadcast { + question: String, + broadcast_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + exclude_self: Option, + }, +} + +/// Error codes, matching the `ErrCode` enum in claude-relay's `protocol.ts`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrCode { + PeerNotFound, + PeerGone, + Timeout, + NameTaken, + NotRegistered, + AlreadyRegistered, + UnknownAsk, + BadMsg, + HubUnreachable, + BadArgs, + ProtocolMismatch, + Unexpected, +} + +/// Messages sent from the hub to channel clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMsg { + Ack { + #[serde(skip_serializing_if = "Option::is_none")] + req_id: Option, + }, + Err { + code: ErrCode, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + req_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ask_id: Option, + }, + Peers { + peers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + req_id: Option, + }, + IncomingAsk { + from: String, + question: String, + ask_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + broadcast_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + IncomingReply { + from: String, + text: String, + ask_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + broadcast_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + BroadcastAck { + broadcast_id: String, + peer_count: u32, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip Deserialize<'de>>(v: &T) -> T { + let s = serde_json::to_string(v).unwrap(); + serde_json::from_str(&s).unwrap() + } + + #[test] + fn test_register_type_field() { + let msg = ClientMsg::Register { + name: "foo".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"register\""), "got: {json}"); + assert!( + !json.contains("lease_ms"), + "None fields omitted, got: {json}" + ); + } + + #[test] + fn test_rename_type_field() { + let msg = ClientMsg::Rename { + new_name: "bar".into(), + req_id: Some("r1".into()), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"rename\""), "got: {json}"); + assert!(json.contains("\"req_id\":\"r1\""), "got: {json}"); + } + + #[test] + fn test_list_peers_type_field() { + let msg = ClientMsg::ListPeers { req_id: None }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"list_peers\""), "got: {json}"); + assert!(!json.contains("req_id"), "None omitted, got: {json}"); + } + + #[test] + fn test_ask_type_field() { + let msg = ClientMsg::Ask { + to: "b".into(), + question: "q".into(), + ask_id: "a1".into(), + timeout_ms: None, + thread_id: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"ask\""), "got: {json}"); + } + + #[test] + fn test_broadcast_type_field() { + let msg = ClientMsg::Broadcast { + question: "q".into(), + broadcast_id: "b1".into(), + exclude_self: Some(true), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"broadcast\""), "got: {json}"); + } + + #[test] + fn test_ack_type_field() { + let msg = ServerMsg::Ack { req_id: None }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"ack\""), "got: {json}"); + } + + #[test] + fn test_err_type_field() { + let msg = ServerMsg::Err { + code: ErrCode::PeerNotFound, + message: None, + req_id: None, + ask_id: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"err\""), "got: {json}"); + assert!(json.contains("\"code\":\"peer_not_found\""), "got: {json}"); + } + + #[test] + fn test_incoming_ask_type_field() { + let msg = ServerMsg::IncomingAsk { + from: "a".into(), + question: "q".into(), + ask_id: "x".into(), + broadcast_id: None, + thread_id: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"incoming_ask\""), "got: {json}"); + } + + #[test] + fn test_broadcast_ack_type_field() { + let msg = ServerMsg::BroadcastAck { + broadcast_id: "b1".into(), + peer_count: 3, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"type\":\"broadcast_ack\""), "got: {json}"); + assert!(json.contains("\"peer_count\":3"), "got: {json}"); + } + + #[test] + fn test_all_err_codes_roundtrip() { + let codes = [ + ErrCode::PeerNotFound, + ErrCode::PeerGone, + ErrCode::Timeout, + ErrCode::NameTaken, + ErrCode::NotRegistered, + ErrCode::AlreadyRegistered, + ErrCode::UnknownAsk, + ErrCode::BadMsg, + ErrCode::HubUnreachable, + ErrCode::BadArgs, + ErrCode::ProtocolMismatch, + ErrCode::Unexpected, + ]; + for code in &codes { + let rt = roundtrip(code); + assert_eq!(&rt, code); + } + } + + #[test] + fn test_peer_record_last_seen_is_u64() { + let rec = PeerRecord { + name: "x".into(), + cwd: "/".into(), + git_branch: "main".into(), + last_seen: 1_700_000_000_000, + }; + let json = serde_json::to_string(&rec).unwrap(); + assert!(json.contains("1700000000000"), "got: {json}"); + let rt: PeerRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(rt.last_seen, 1_700_000_000_000); + } + + #[test] + fn test_lease_ms_omitted_for_none() { + let msg = ClientMsg::Register { + name: "x".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!( + !json.contains("lease_ms"), + "None lease_ms must be omitted: {json}" + ); + } + + #[test] + fn test_lease_ms_present_when_some() { + let msg = ClientMsg::Register { + name: "x".into(), + cwd: "/".into(), + git_branch: "main".into(), + protocol_version: PROTOCOL_VERSION.into(), + lease_ms: Some(30_000), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"lease_ms\":30000"), "got: {json}"); + } + + #[test] + fn test_ts_produced_register_deserializes() { + // Simulate a TS-produced register message (no lease_ms field) + let raw = r#"{"type":"register","name":"my-proj","cwd":"/home/user/proj","git_branch":"main","protocol_version":"2"}"#; + let msg: ClientMsg = serde_json::from_str(raw).unwrap(); + match msg { + ClientMsg::Register { name, lease_ms, .. } => { + assert_eq!(name, "my-proj"); + assert_eq!(lease_ms, None); + } + _ => panic!("wrong variant"), + } + } +} diff --git a/crates/relay/src/session_name.rs b/crates/relay/src/session_name.rs new file mode 100644 index 0000000..270bacd --- /dev/null +++ b/crates/relay/src/session_name.rs @@ -0,0 +1,257 @@ +//! Provider-agnostic session name source for the relay channel binary. +//! +//! Each LLM tool has its own convention for session naming. This trait abstracts +//! over those differences so the channel binary can support Claude, Codex, and others +//! with the same registration flow. + +use async_trait::async_trait; + +/// Provides the name for a relay peer registration and watches for name changes. +#[async_trait] +pub trait SessionNameSource: Send + Sync + 'static { + /// Name to use at registration time. Returns `None` if not yet known. + fn initial_name(&self) -> Option; + + /// Watch for name changes and call `on_name` when a new name is detected. + /// Returns after setting up the watcher (does not block indefinitely). + async fn watch(&self, on_name: F) -> anyhow::Result<()> + where + F: Fn(String) + Send + 'static; +} + +// ── ExplicitSessionNameSource ───────────────────────────────────────────────── + +/// A fixed name assigned by the caller (e.g., operator assigning a ticket ID). +/// Used for operator-managed agents where the name is known at launch time. +pub struct ExplicitSessionNameSource { + name: String, +} + +impl ExplicitSessionNameSource { + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + +#[async_trait] +impl SessionNameSource for ExplicitSessionNameSource { + fn initial_name(&self) -> Option { + Some(self.name.clone()) + } + + async fn watch(&self, _on_name: F) -> anyhow::Result<()> + where + F: Fn(String) + Send + 'static, + { + // Explicit names are fixed; no file to watch + Ok(()) + } +} + +// ── ClaudeSessionNameSource ─────────────────────────────────────────────────── + +/// Watches `~/.claude/sessions/{ppid}.json` for Claude Code's `/rename` command output. +/// +/// When the user runs `/rename` in Claude Code, it writes `{"name": "..."}` to this file. +/// The watcher detects the change and calls `on_name` with the sanitized name, keeping +/// the hub registry in sync. +pub struct ClaudeSessionNameSource { + session_file: std::path::PathBuf, +} + +impl ClaudeSessionNameSource { + /// Derive the session file path from the current process's parent PID. + #[cfg(unix)] + pub fn for_current_process() -> Self { + let ppid = libc_ppid(); + let home = dirs::home_dir().unwrap_or_default(); + Self { + session_file: home + .join(".claude") + .join("sessions") + .join(format!("{ppid}.json")), + } + } + + pub fn from_path(session_file: std::path::PathBuf) -> Self { + Self { session_file } + } + + fn read_name(&self) -> Option { + let content = std::fs::read_to_string(&self.session_file).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + let name = value.get("name")?.as_str()?; + let sanitized = sanitize_session_name(name); + if sanitized.is_empty() { + None + } else { + Some(sanitized) + } + } +} + +#[async_trait] +impl SessionNameSource for ClaudeSessionNameSource { + fn initial_name(&self) -> Option { + self.read_name() + } + + async fn watch(&self, on_name: F) -> anyhow::Result<()> + where + F: Fn(String) + Send + 'static, + { + use notify::{Event, RecursiveMode, Watcher}; + use std::time::Duration; + use tokio::sync::mpsc; + + let session_file = self.session_file.clone(); + let watch_dir = session_file + .parent() + .ok_or_else(|| anyhow::anyhow!("Session file has no parent directory"))? + .to_path_buf(); + + let (tx, mut rx) = mpsc::channel::(16); + + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + let _ = tx.blocking_send(event); + } + })?; + + if watch_dir.exists() { + watcher.watch(&watch_dir, RecursiveMode::NonRecursive)?; + } + + tokio::spawn(async move { + let _watcher = watcher; // Keep alive for the duration of the spawn + let mut debounce_pending = false; + + loop { + tokio::select! { + Some(_event) = rx.recv() => { + debounce_pending = true; + } + () = tokio::time::sleep(Duration::from_millis(50)), if debounce_pending => { + debounce_pending = false; + if let Ok(content) = std::fs::read_to_string(&session_file) { + if let Ok(value) = serde_json::from_str::(&content) { + if let Some(name) = value.get("name").and_then(|n| n.as_str()) { + let sanitized = sanitize_session_name(name); + if !sanitized.is_empty() { + on_name(sanitized); + } + } + } + } + } + } + } + }); + + Ok(()) + } +} + +/// Sanitize a session name: keep alphanumeric, `.`, `-`, `_`; truncate to 64 chars. +/// Matches claude-relay's `sanitizeSessionName` in `src/identity.ts`. +pub fn sanitize_session_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_') + .take(64) + .collect() +} + +// Platform shim for PPID +#[cfg(unix)] +fn libc_ppid() -> u32 { + // Use std::process for current PID; PPID requires a syscall. + // On Linux/macOS, read from /proc/self/status or use getppid() via libc. + // Since we don't depend on the libc crate, read from /proc/self/status on Linux + // and fall back to 0 on macOS (where the binary reads its own parent from the OS). + #[cfg(target_os = "linux")] + { + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if let Some(rest) = line.strip_prefix("PPid:\t") { + if let Ok(ppid) = rest.trim().parse::() { + return ppid; + } + } + } + } + 0 + } + #[cfg(not(target_os = "linux"))] + { + // macOS: use sysctl or just return 0 as a safe fallback + // The relay-channel binary is the primary user of ClaudeSessionNameSource; + // operator itself uses ExplicitSessionNameSource. + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_keeps_valid_chars() { + assert_eq!( + sanitize_session_name("my-project.v2_ok"), + "my-project.v2_ok" + ); + } + + #[test] + fn test_sanitize_strips_spaces_and_slashes() { + assert_eq!(sanitize_session_name("my project/name"), "myprojectname"); + } + + #[test] + fn test_sanitize_truncates_to_64() { + let long = "a".repeat(100); + assert_eq!(sanitize_session_name(&long).len(), 64); + } + + #[test] + fn test_sanitize_empty_input() { + assert_eq!(sanitize_session_name(""), ""); + } + + #[test] + fn test_explicit_source_initial_name() { + let src = ExplicitSessionNameSource::new("FEAT-123"); + assert_eq!(src.initial_name(), Some("FEAT-123".to_string())); + } + + #[tokio::test] + async fn test_explicit_source_watch_is_noop() { + let src = ExplicitSessionNameSource::new("FEAT-123"); + let result = src.watch(|_| {}).await; + assert!(result.is_ok()); + } + + #[test] + fn test_claude_source_reads_name_from_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.json"); + std::fs::write(&path, r#"{"name":"my-session"}"#).unwrap(); + let src = ClaudeSessionNameSource::from_path(path); + assert_eq!(src.initial_name(), Some("my-session".to_string())); + } + + #[test] + fn test_claude_source_returns_none_for_missing_file() { + let src = ClaudeSessionNameSource::from_path("/tmp/nonexistent_relay_session.json".into()); + assert_eq!(src.initial_name(), None); + } + + #[test] + fn test_claude_source_sanitizes_name() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s.json"); + std::fs::write(&path, r#"{"name":"my project/v2"}"#).unwrap(); + let src = ClaudeSessionNameSource::from_path(path); + assert_eq!(src.initial_name(), Some("myprojectv2".to_string())); + } +} diff --git a/crates/relay/src/socket_path.rs b/crates/relay/src/socket_path.rs new file mode 100644 index 0000000..f7f7ce7 --- /dev/null +++ b/crates/relay/src/socket_path.rs @@ -0,0 +1,22 @@ +//! Hub socket path resolution, matching claude-relay's data-dir.ts priority order. + +use std::path::PathBuf; + +/// Returns the Unix socket path for the relay hub. +/// +/// Priority: `$RELAY_HUB_SOCKET` → `$CLAUDE_PLUGIN_DATA/hub.sock` → `~/.claude-relay/hub.sock` +/// +/// This matches claude-relay's `data-dir.ts` so existing deployments find the hub +/// at the same path regardless of whether the TS or Rust hub is running. +pub fn hub_socket_path() -> PathBuf { + if let Ok(explicit) = std::env::var("RELAY_HUB_SOCKET") { + return PathBuf::from(explicit); + } + if let Ok(data_dir) = std::env::var("CLAUDE_PLUGIN_DATA") { + return PathBuf::from(data_dir).join("hub.sock"); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude-relay") + .join("hub.sock") +} diff --git a/docs/getting-started/agents/claude.md b/docs/getting-started/agents/claude.md index a0b049d..296fb29 100644 --- a/docs/getting-started/agents/claude.md +++ b/docs/getting-started/agents/claude.md @@ -44,6 +44,17 @@ Claude Code requires an API key or Claude Pro subscription. Set up authenticatio claude auth login ``` +## Multi-agent relay + +Agents launched by Operator automatically participate in the relay hub when the hub is running. Operator: + +1. Injects `RELAY_HUB_SOCKET` and `RELAY_AGENT_NAME` (the ticket ID, e.g. `FEAT-042`) into the session environment. +2. Writes a per-session `relay-mcp.json` config and passes `--mcp-config ` to Claude Code, so the `relay-channel` MCP server starts automatically alongside the agent. + +This means Claude agents can use `relay_peers`, `relay_ask`, `relay_reply`, `relay_broadcast`, and `relay_rename` tools out of the box — no manual MCP server configuration needed. + +See [Relay](/docs/relay/) for the full architecture. + ## Troubleshooting ### Claude not found diff --git a/docs/getting-started/agents/codex.md b/docs/getting-started/agents/codex.md index 284b440..ae6af80 100644 --- a/docs/getting-started/agents/codex.md +++ b/docs/getting-started/agents/codex.md @@ -49,6 +49,12 @@ export OPENAI_API_KEY="your-api-key" Or add it to your shell profile for persistence. +## Multi-agent relay + +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. + ## API Usage Codex uses the OpenAI API which has usage-based pricing. Monitor your usage at [platform.openai.com](https://platform.openai.com/). diff --git a/docs/index.md b/docs/index.md index 3616573..c510c62 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,8 @@ Welcome friend! Operator! is an application These are tools that comparable and aspirational for Operator - [agtx](https://github.com/fynnfluegge/agtx) +- [claude-relay](https://github.com/Innestic/claude-relay) +- [gastown](https://github.com/gastownhall/gastown) ## Similar, but Worse: diff --git a/docs/relay/index.md b/docs/relay/index.md new file mode 100644 index 0000000..e94d165 --- /dev/null +++ b/docs/relay/index.md @@ -0,0 +1,70 @@ +--- +title: "Relay" +description: "Multi-agent peer-to-peer communication hub embedded in Operator." +layout: doc +--- + +Operator! embeds a relay hub that lets agents launched for different tickets discover and message each other in real time. When the hub is running and `RELAY_HUB_SOCKET` is set, Operator automatically injects the `relay-channel` MCP server into Claude Code launches so agents can use the relay tools without manual configuration. Codex and other tools receive the env vars but require manual MCP configuration. + +## How it works + +The relay hub is a Unix socket server that runs inside the Operator process. Each agent registers as a named peer when it connects. Operator assigns each agent its ticket ID as its peer name, so agents can address each other by ticket. + +``` +operator process +└── RelayHub (Unix socket) + ├── Claude agent "FEAT-001" ──── ask ────→ Claude agent "FEAT-002" + └── Claude agent "FEAT-002" ◄─── reply ─── Claude agent "FEAT-001" +``` + +The hub runs for the lifetime of the Operator process. Unlike the standalone `claude-relay` tool, there is no idle-shutdown timer — the hub stays up as long as Operator is running. + +## Hub socket + +The hub binds to a Unix domain socket. The path is resolved in this priority order (matching claude-relay's `data-dir.ts` so existing deployments need no changes): + +| Priority | Source | Default | +|----------|--------|---------| +| 1 | `$RELAY_HUB_SOCKET` | — | +| 2 | `$CLAUDE_PLUGIN_DATA/hub.sock` | — | +| 3 | fallback | `~/.claude-relay/hub.sock` | + +Operator exports `RELAY_HUB_SOCKET` automatically at startup, so every child process it spawns can find the hub. For Claude Code, Operator also writes a per-session `relay-mcp.json` and passes `--mcp-config ` at launch time, so the relay-channel MCP server is active without any manual setup. For other tools, the socket env var is exported but MCP wiring requires manual configuration. + +## Agent naming + +When Operator launches an agent, it injects two env vars into the session: + +```bash +export RELAY_HUB_SOCKET=/path/to/hub.sock +export RELAY_AGENT_NAME=FEAT-042 +``` + +The agent connects to the hub and registers under the name `FEAT-042`. Any other agent that knows the ticket ID can address it directly. + +For agents running standalone (not launched by Operator), the relay channel binary reads `~/.claude/sessions/{ppid}.json` and uses whatever name the user sets with `/rename` in Claude Code. + +## Wire compatibility + +The protocol is byte-compatible with TypeScript claude-relay. Existing TS channels connect to the Rust hub unchanged: + +- Same `PROTOCOL_VERSION = "2"` +- Same message type names: `register`, `rename`, `list_peers`, `ask`, `reply`, `broadcast` +- Same error codes: `peer_not_found`, `name_taken`, `timeout`, etc. +- Same line-delimited JSON framing over Unix socket + +## Environment variables + +| Variable | Set by | Purpose | +|----------|--------|---------| +| `RELAY_HUB_SOCKET` | Operator at startup | Unix socket path for the hub | +| `RELAY_AGENT_NAME` | Operator at launch time | Peer name for the agent (ticket ID) | +| `CLAUDE_PLUGIN_DATA` | Claude Code | Secondary socket path fallback | + +These variables intentionally do not use the `OPERATOR_` prefix — they are shared with the claude-relay ecosystem so existing tools pick them up automatically. + +## 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 diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index 6c28ddf..9548bf2 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -52,6 +52,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -66,9 +83,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" @@ -150,6 +173,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -161,6 +205,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -177,12 +227,29 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -192,6 +259,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -247,11 +323,39 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -440,6 +544,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -461,6 +571,47 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -499,12 +650,50 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -542,10 +731,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -558,18 +776,41 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "operator-relay" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dirs", + "notify", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "opr8r" version = "0.1.30" dependencies = [ "clap", + "operator-relay", "reqwest", "serde", "serde_json", "tempfile", "tokio", + "uuid", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -588,6 +829,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "potential_utf" version = "0.1.4" @@ -606,6 +853,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -629,7 +886,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -650,7 +907,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -685,6 +942,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -714,6 +977,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -778,7 +1061,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -832,6 +1115,21 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -987,13 +1285,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1090,7 +1408,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -1121,9 +1439,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1145,6 +1475,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1175,6 +1511,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1196,7 +1553,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -1257,6 +1623,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -1286,12 +1686,30 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1319,6 +1737,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1352,6 +1785,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1364,6 +1803,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1376,6 +1821,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1400,6 +1851,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1412,6 +1869,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1424,6 +1887,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1436,6 +1905,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1454,6 +1929,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index 490ad51..bed9e1d 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -8,10 +8,12 @@ repository = "https://github.com/untra/operator" [dependencies] clap = { version = "4", features = ["derive"] } -tokio = { version = "1", features = ["rt", "macros", "process", "time"] } +tokio = { version = "1", features = ["rt", "io-util", "net", "sync", "time", "macros", "process"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +operator-relay = { path = "../crates/relay" } +uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" diff --git a/opr8r/src/cli.rs b/opr8r/src/cli.rs index a0efae2..687a340 100644 --- a/opr8r/src/cli.rs +++ b/opr8r/src/cli.rs @@ -4,17 +4,23 @@ use clap::Parser; /// /// Wraps LLM commands (claude, gemini, codex), passes through output, /// and orchestrates step transitions via the Operator API. +/// +/// Run `opr8r relay-channel` to start as an MCP stdio relay-channel server. #[derive(Parser, Debug)] #[command(name = "opr8r")] #[command(version, about, long_about = None)] pub struct Args { - /// Ticket ID being worked on (e.g., FEAT-123) - #[arg(long, required = true)] - pub ticket_id: String, + /// Subcommand (e.g., relay-channel). If absent, runs in step-wrapper mode. + #[command(subcommand)] + pub subcommand: Option, + + /// Ticket ID being worked on (e.g., FEAT-123) [required in step-wrapper mode] + #[arg(long)] + pub ticket_id: Option, - /// Current step name (e.g., "plan", "build", "test") - #[arg(long, required = true)] - pub step: String, + /// Current step name (e.g., "plan", "build", "test") [required in step-wrapper mode] + #[arg(long)] + pub step: Option, /// Operator API URL. If not provided, auto-discovers from /// .tickets/operator/api-session.json @@ -37,17 +43,43 @@ pub struct Args { #[arg(long, default_value = "false")] pub dry_run: bool, - /// The LLM command and its arguments to execute - #[arg(last = true, required = true)] + /// The LLM command and its arguments to execute [required in step-wrapper mode] + #[arg(last = true)] pub command: Vec, } +/// Available subcommands for opr8r. +#[derive(clap::Subcommand, Debug, PartialEq)] +pub enum Cmd { + /// Run as an MCP stdio relay-channel server. + /// + /// Connects to the relay hub and exposes relay tools (relay_peers, + /// relay_ask, relay_reply, relay_broadcast, relay_rename) to LLM agents + /// via the MCP stdio protocol. + RelayChannel, +} + impl Args { /// Parse command line arguments pub fn parse_args() -> Self { Args::parse() } + /// Validate that all required step-wrapper fields are present. + /// Returns Err with a descriptive message if any are missing. + pub fn validate_step_wrapper(&self) -> Result<(), String> { + if self.ticket_id.is_none() { + return Err("--ticket-id is required in step-wrapper mode".to_string()); + } + if self.step.is_none() { + return Err("--step is required in step-wrapper mode".to_string()); + } + if self.command.is_empty() { + return Err("command is required in step-wrapper mode (pass after --)".to_string()); + } + Ok(()) + } + /// Get the LLM command as program and arguments pub fn command_parts(&self) -> Option<(&str, &[String])> { if self.command.is_empty() { @@ -62,6 +94,47 @@ impl Args { mod tests { use super::*; + #[test] + fn test_relay_channel_subcommand_parses() { + let result = Args::try_parse_from(["opr8r", "relay-channel"]); + assert!( + result.is_ok(), + "relay-channel subcommand should parse successfully" + ); + let args = result.unwrap(); + assert!(matches!(args.subcommand, Some(Cmd::RelayChannel))); + } + + #[test] + fn test_step_wrapper_mode_still_requires_ticket_id() { + let args = Args::try_parse_from(["opr8r", "--step=plan", "--", "claude"]).unwrap(); + let err = args.validate_step_wrapper().unwrap_err(); + assert!( + err.contains("ticket-id"), + "error should mention ticket-id, got: {err}" + ); + } + + #[test] + fn test_step_wrapper_mode_still_requires_step() { + let args = Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--", "claude"]).unwrap(); + let err = args.validate_step_wrapper().unwrap_err(); + assert!( + err.contains("step"), + "error should mention step, got: {err}" + ); + } + + #[test] + fn test_step_wrapper_mode_still_requires_command() { + let args = Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--step=plan"]).unwrap(); + let err = args.validate_step_wrapper().unwrap_err(); + assert!( + err.contains("command"), + "error should mention command, got: {err}" + ); + } + #[test] fn test_parse_minimal_args() { let args = Args::try_parse_from([ @@ -75,12 +148,13 @@ mod tests { ]) .unwrap(); - assert_eq!(args.ticket_id, "FEAT-123"); - assert_eq!(args.step, "plan"); + assert_eq!(args.ticket_id, Some("FEAT-123".to_string())); + assert_eq!(args.step, Some("plan".to_string())); assert!(args.api_url.is_none()); assert!(!args.no_auto_proceed); assert!(!args.verbose); assert_eq!(args.command, vec!["claude", "--prompt", "test"]); + assert!(args.subcommand.is_none()); } #[test] @@ -100,8 +174,8 @@ mod tests { ]) .unwrap(); - assert_eq!(args.ticket_id, "FIX-456"); - assert_eq!(args.step, "build"); + assert_eq!(args.ticket_id, Some("FIX-456".to_string())); + assert_eq!(args.step, Some("build".to_string())); assert_eq!(args.api_url, Some("http://localhost:7008".to_string())); assert_eq!(args.session_id, Some("abc-123".to_string())); assert!(args.no_auto_proceed); @@ -129,25 +203,25 @@ mod tests { #[test] fn test_missing_required_args() { - // Missing --ticket-id - let result = Args::try_parse_from(["opr8r", "--step=plan", "--", "claude"]); - assert!(result.is_err()); + // Missing --ticket-id in step-wrapper mode: parses but validate_step_wrapper rejects + let args = Args::try_parse_from(["opr8r", "--step=plan", "--", "claude"]).unwrap(); + assert!(args.validate_step_wrapper().is_err()); - // Missing --step - let result = Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--", "claude"]); - assert!(result.is_err()); + // Missing --step in step-wrapper mode + let args = Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--", "claude"]).unwrap(); + assert!(args.validate_step_wrapper().is_err()); - // Missing command - let result = Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--step=plan", "--"]); - assert!(result.is_err()); + // Missing command in step-wrapper mode + let args = Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--step=plan"]).unwrap(); + assert!(args.validate_step_wrapper().is_err()); } #[test] fn test_empty_command_parts() { - // Create args manually with empty command vec let args = Args { - ticket_id: "FEAT-1".to_string(), - step: "plan".to_string(), + subcommand: None, + ticket_id: Some("FEAT-1".to_string()), + step: Some("plan".to_string()), api_url: None, session_id: None, no_auto_proceed: false, @@ -184,4 +258,12 @@ mod tests { assert!(args.dry_run); } + + #[test] + fn test_validate_step_wrapper_all_present() { + let args = + Args::try_parse_from(["opr8r", "--ticket-id=FEAT-1", "--step=plan", "--", "claude"]) + .unwrap(); + assert!(args.validate_step_wrapper().is_ok()); + } } diff --git a/opr8r/src/main.rs b/opr8r/src/main.rs index 2ed640e..4f9057d 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -1,11 +1,12 @@ mod api; mod cli; mod output_parser; +mod relay_server; mod runner; mod transition; use api::{ApiClient, StepCompleteRequest}; -use cli::Args; +use cli::{Args, Cmd}; use runner::{dry_run_command, run_command, RunConfig}; use std::process::ExitCode; use transition::{ @@ -53,6 +54,20 @@ fn build_step_complete_request( async fn main() -> ExitCode { let args = Args::parse_args(); + // Dispatch relay-channel subcommand before any step-wrapper logic + if args.subcommand == Some(Cmd::RelayChannel) { + return relay_server::run().await; + } + + // Step-wrapper mode: validate required fields + if let Err(e) = args.validate_step_wrapper() { + eprintln!("[opr8r] Error: {e}"); + return ExitCode::from(EXIT_CONFIG_ERROR); + } + + let ticket_id = args.ticket_id.as_deref().unwrap(); + let step = args.step.as_deref().unwrap(); + // Validate command let (program, cmd_args) = match args.command_parts() { Some(parts) => parts, @@ -66,8 +81,8 @@ async fn main() -> ExitCode { if args.dry_run { dry_run_command(program, cmd_args); println!("[opr8r dry-run] Would report to API:"); - println!(" Ticket: {}", args.ticket_id); - println!(" Step: {}", args.step); + println!(" Ticket: {ticket_id}"); + println!(" Step: {step}"); println!( " API URL: {}", args.api_url.as_deref().unwrap_or("auto-discover") @@ -75,7 +90,7 @@ async fn main() -> ExitCode { return ExitCode::SUCCESS; } - print_step_starting(&args.ticket_id, &args.step, args.verbose); + print_step_starting(ticket_id, step, args.verbose); // Configure runner with output capture enabled let config = RunConfig::new() @@ -112,11 +127,11 @@ async fn main() -> ExitCode { } } - print_step_completed(&args.step, duration_secs, args.verbose); + print_step_completed(step, duration_secs, args.verbose); // Print command failure if applicable if exit_code != 0 { - print_command_failed(exit_code, &args.step); + print_command_failed(exit_code, step); } // Discover and connect to API @@ -136,10 +151,7 @@ async fn main() -> ExitCode { operator_output, ); - let response = match api_client - .complete_step(&args.ticket_id, &args.step, request) - .await - { + let response = match api_client.complete_step(ticket_id, step, request).await { Ok(r) => r, Err(e) => { print_api_unreachable_error(&e.to_string()); @@ -160,7 +172,7 @@ async fn main() -> ExitCode { } } else { // No next step - workflow complete - print_workflow_complete(&args.ticket_id); + print_workflow_complete(ticket_id); } } "awaiting_review" => { @@ -170,7 +182,7 @@ async fn main() -> ExitCode { .as_ref() .map(|s| s.review_type.as_str()) .unwrap_or("unknown"); - print_awaiting_review(&args.step, review_type); + print_awaiting_review(step, review_type); } "failed" => { // Step failed diff --git a/opr8r/src/relay_server.rs b/opr8r/src/relay_server.rs new file mode 100644 index 0000000..e054042 --- /dev/null +++ b/opr8r/src/relay_server.rs @@ -0,0 +1,428 @@ +//! MCP stdio relay-channel server mode for opr8r. +//! +//! Invoked via `opr8r relay-channel`. Connects to the relay hub and exposes +//! relay tools (relay_peers, relay_ask, relay_reply, relay_broadcast, +//! relay_rename) to LLM agents via the MCP stdio protocol. +//! +//! This is the distribution vehicle for relay-channel: since opr8r is signed, +//! notarized, and released on all platforms, relay functionality is available +//! to agents on any machine with a standard operator install. + +use std::io::{BufRead, Write}; +use std::process::ExitCode; +use std::sync::Arc; + +use operator_relay::channel_session::ChannelSession; +use operator_relay::protocol::ServerMsg; +use operator_relay::socket_path::hub_socket_path; +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use uuid::Uuid; + +// ── Tool schemas ────────────────────────────────────────────────────────────── + +fn tools_list() -> Value { + json!([ + { + "name": "relay_peers", + "description": "List OTHER active sessions on this machine. Returns {me, peers} where me is your own session name and peers is every other session (excluding you). Each peer has cwd and git_branch for disambiguation.", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "relay_ask", + "description": "Ask a specific peer a question. Non-blocking: returns immediately with {ok, ask_id}; the reply arrives later as a channel notification whose meta carries the same ask_id. Errors tied to this ask (peer_not_found, peer_gone, timeout) also arrive as channel notifications. Correlate by ask_id. If multiple peers may share a similar name, call relay_peers first and match by cwd or git_branch to pick the right target.", + "inputSchema": { + "type": "object", + "properties": { + "to": { "type": "string", "description": "Name of the peer to ask" }, + "question": { "type": "string", "description": "The question to send" }, + "thread_id": { "type": "string", "description": "Optional thread identifier to correlate multi-turn exchanges. If you received an ask with a thread_id and are replying or continuing, pass the same thread_id." }, + "timeout_ms": { "type": "number", "description": "Timeout in milliseconds (default: 120000)" } + }, + "required": ["to", "question"] + } + }, + { + "name": "relay_reply", + "description": "Reply to an incoming ask by its ask_id. text is a plain string. Replies are one-shot — no streaming, no cancellation, no structured payload. If you need structured data, serialize JSON inside the string; the asker parses it.", + "inputSchema": { + "type": "object", + "properties": { + "ask_id": { "type": "string", "description": "The ask_id from the incoming ask notification" }, + "text": { "type": "string", "description": "The reply text" } + }, + "required": ["ask_id", "text"] + } + }, + { + "name": "relay_broadcast", + "description": "Broadcast a question to ALL other peers on this machine, including sessions on unrelated projects. Use ONLY when the user explicitly wants every session asked. Do NOT use as a fallback when relay_ask returns an error (peer_not_found, peer_gone, timeout); surface the error to the user and let them decide. If you want to reach a specific peer, use relay_ask.", + "inputSchema": { + "type": "object", + "properties": { + "question": { "type": "string", "description": "The question to broadcast" }, + "exclude_self": { "type": "boolean", "description": "If true (default), the sender is excluded from recipients." } + }, + "required": ["question"] + } + }, + { + "name": "relay_rename", + "description": "Rename this session's registered name.", + "inputSchema": { + "type": "object", + "properties": { + "new_name": { "type": "string", "description": "New peer name (alphanumeric, ., -, _ only)" } + }, + "required": ["new_name"] + } + } + ]) +} + +// ── Stdout helpers ──────────────────────────────────────────────────────────── + +fn write_response(id: Option<&Value>, result: Value) { + let resp = json!({ "jsonrpc": "2.0", "id": id, "result": result }); + let mut out = std::io::stdout().lock(); + let _ = serde_json::to_writer(&mut out, &resp); + let _ = out.write_all(b"\n"); + let _ = out.flush(); +} + +fn write_error(id: Option<&Value>, code: i64, message: &str) { + let resp = json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }); + let mut out = std::io::stdout().lock(); + let _ = serde_json::to_writer(&mut out, &resp); + let _ = out.write_all(b"\n"); + let _ = out.flush(); +} + +fn write_notification(method: &str, params: Value) { + let notif = json!({ "jsonrpc": "2.0", "method": method, "params": params }); + let mut out = std::io::stdout().lock(); + let _ = serde_json::to_writer(&mut out, ¬if); + let _ = out.write_all(b"\n"); + let _ = out.flush(); +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// Run the relay-channel MCP stdio server. +pub async fn run() -> ExitCode { + let socket_path = hub_socket_path(); + + let name = determine_name(); + + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| ".".to_string()); + + let git_branch = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let (session, mut incoming_ask_rx) = + match ChannelSession::connect(&socket_path, name, cwd, git_branch).await { + Ok(pair) => pair, + Err(e) => { + eprintln!("[opr8r relay-channel] Failed to connect to relay hub: {e}"); + return ExitCode::from(1); + } + }; + let session = Arc::new(session); + + // If no explicit name, watch for Claude /rename and propagate + #[cfg(unix)] + if std::env::var("RELAY_AGENT_NAME").is_err() { + use operator_relay::session_name::{ClaudeSessionNameSource, SessionNameSource}; + let src = ClaudeSessionNameSource::for_current_process(); + let s = session.clone(); + if let Err(e) = src + .watch(move |new_name| { + let s = s.clone(); + tokio::spawn(async move { + let _ = s.rename(new_name).await; + }); + }) + .await + { + eprintln!("[opr8r relay-channel] Warning: could not watch for session renames: {e}"); + } + } + + // Stdin reader thread feeding a tokio channel + let (stdin_tx, mut stdin_rx) = mpsc::channel::(32); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + match line { + Ok(l) if !l.is_empty() => { + if stdin_tx.blocking_send(l).is_err() { + break; + } + } + _ => {} + } + } + }); + + // Main event loop: interleave stdin requests and hub notifications. + loop { + tokio::select! { + line = stdin_rx.recv() => { + match line { + Some(l) => handle_request(&l, &session).await, + None => break, + } + } + Some(msg) = incoming_ask_rx.recv() => { + match msg { + ServerMsg::IncomingAsk { from, question, ask_id, broadcast_id, thread_id } => { + let mut meta = serde_json::Map::new(); + meta.insert("from".into(), json!(from)); + meta.insert("ask_id".into(), json!(ask_id)); + if let Some(bid) = broadcast_id { meta.insert("broadcast_id".into(), json!(bid)); } + if let Some(tid) = thread_id { meta.insert("thread_id".into(), json!(tid)); } + write_notification("notifications/claude/channel", json!({ + "content": question, + "meta": meta + })); + } + ServerMsg::IncomingReply { from, text, ask_id, broadcast_id, thread_id } => { + let mut meta = serde_json::Map::new(); + meta.insert("from".into(), json!(from)); + meta.insert("ask_id".into(), json!(ask_id)); + if let Some(bid) = broadcast_id { meta.insert("broadcast_id".into(), json!(bid)); } + if let Some(tid) = thread_id { meta.insert("thread_id".into(), json!(tid)); } + write_notification("notifications/claude/channel", json!({ + "content": text, + "meta": meta + })); + } + ServerMsg::Err { ask_id: Some(ask_id), code, .. } => { + let code_val = serde_json::to_value(&code).unwrap_or(json!("unknown")); + let code_str = code_val.as_str().unwrap_or("unknown"); + write_notification("notifications/claude/channel", json!({ + "content": format!("Ask error ({code_str}): the ask could not be delivered."), + "meta": { "ask_id": ask_id, "code": code_str } + })); + } + _ => {} + } + } + } + } + + ExitCode::SUCCESS +} + +fn determine_name() -> String { + if let Ok(explicit) = std::env::var("RELAY_AGENT_NAME") { + return explicit; + } + #[cfg(unix)] + { + use operator_relay::session_name::{ClaudeSessionNameSource, SessionNameSource}; + let src = ClaudeSessionNameSource::for_current_process(); + if let Some(name) = src.initial_name() { + return name; + } + } + format!("channel-{}", std::process::id()) +} + +// ── Request dispatch ────────────────────────────────────────────────────────── + +async fn handle_request(line: &str, session: &Arc) { + let Ok(req) = serde_json::from_str::(line) else { + return; + }; + + let id = req.get("id"); + let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = req.get("params").cloned().unwrap_or(json!({})); + + match method { + "initialize" => { + write_response( + id, + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "experimental": { "claude/channel": {} } + }, + "serverInfo": { "name": "relay-channel", "version": "0.1.0" }, + "instructions": "Always reply to messages via relay_reply BEFORE \ + other work. Use relay_peers to pick targets. Use relay_ask \ + for one peer, relay_broadcast for all. Surface ask errors \ + to the user." + }), + ); + } + "initialized" | "notifications/initialized" => {} + "tools/list" => { + write_response(id, json!({ "tools": tools_list() })); + } + "tools/call" => { + let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + dispatch_tool(id, tool_name, &args, session).await; + } + "ping" => { + write_response(id, json!({})); + } + _ => { + if id.is_some() { + write_error(id, -32601, &format!("Method not found: {method}")); + } + } + } +} + +async fn dispatch_tool( + id: Option<&Value>, + tool_name: &str, + args: &Value, + session: &Arc, +) { + match tool_name { + "relay_peers" => match session.list_peers().await { + Ok(peers) => { + let payload = json!({ + "ok": true, + "me": session.name(), + "peers": peers.iter().map(|p| json!({ + "name": p.name, + "cwd": p.cwd, + "git_branch": p.git_branch, + "last_seen": p.last_seen + })).collect::>() + }); + write_response( + id, + json!({ "content": [{ "type": "text", + "text": serde_json::to_string(&payload).unwrap_or_default() + }]}), + ); + } + Err(e) => { + let payload = json!({ "ok": false, "error": e.to_string() }); + write_response( + id, + json!({ "content": [{ "type": "text", + "text": serde_json::to_string(&payload).unwrap_or_default() + }]}), + ); + } + }, + + "relay_ask" => { + let to = args.get("to").and_then(|v| v.as_str()).unwrap_or(""); + let question = args.get("question").and_then(|v| v.as_str()).unwrap_or(""); + let timeout_ms = args.get("timeout_ms").and_then(Value::as_u64); + let thread_id = args + .get("thread_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + if to.is_empty() || question.is_empty() { + write_error(id, -32602, "relay_ask requires 'to' and 'question'"); + return; + } + let ask_id = Uuid::new_v4().to_string(); + match session + .send_ask( + to.to_string(), + question.to_string(), + ask_id.clone(), + timeout_ms, + thread_id, + ) + .await + { + Ok(()) => { + let payload = json!({ "ok": true, "ask_id": ask_id }); + write_response( + id, + json!({ "content": [{ "type": "text", + "text": serde_json::to_string(&payload).unwrap_or_default() + }]}), + ); + } + Err(e) => write_error(id, -32000, &e.to_string()), + } + } + + "relay_reply" => { + let ask_id = args.get("ask_id").and_then(|v| v.as_str()).unwrap_or(""); + let text = args.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if ask_id.is_empty() { + write_error(id, -32602, "relay_reply requires 'ask_id'"); + return; + } + session.reply(ask_id.to_string(), text.to_string()); + let payload = json!({ "ok": true }); + write_response( + id, + json!({ "content": [{ "type": "text", + "text": serde_json::to_string(&payload).unwrap_or_default() + }]}), + ); + } + + "relay_broadcast" => { + let question = args.get("question").and_then(|v| v.as_str()).unwrap_or(""); + if question.is_empty() { + write_error(id, -32602, "relay_broadcast requires 'question'"); + return; + } + let exclude_self = args.get("exclude_self").and_then(Value::as_bool); + let broadcast_id = Uuid::new_v4().to_string(); + match session + .broadcast_with_id(question.to_string(), broadcast_id.clone(), exclude_self) + .await + { + Ok(count) => { + let payload = + json!({ "ok": true, "broadcast_id": broadcast_id, "peer_count": count }); + write_response( + id, + json!({ "content": [{ "type": "text", + "text": serde_json::to_string(&payload).unwrap_or_default() + }]}), + ); + } + Err(e) => write_error(id, -32000, &e.to_string()), + } + } + + "relay_rename" => { + let new_name = args.get("new_name").and_then(|v| v.as_str()).unwrap_or(""); + if new_name.is_empty() { + write_error(id, -32602, "relay_rename requires 'new_name'"); + return; + } + match session.rename(new_name.to_string()).await { + Ok(()) => { + let payload = json!({ "ok": true, "name": new_name }); + write_response( + id, + json!({ "content": [{ "type": "text", + "text": serde_json::to_string(&payload).unwrap_or_default() + }]}), + ); + } + Err(e) => write_error(id, -32000, &e.to_string()), + } + } + + other => { + write_error(id, -32601, &format!("Unknown tool: {other}")); + } + } +} diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index aa8880b..dbe39d5 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -169,6 +169,15 @@ pub fn launch_in_cmux_with_options( // Write the command to a shell script file let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + // Inject relay env vars so agents can find the hub and register with their ticket ID + if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + let export_cmd = format!( + "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", + ticket.id + ); + let _ = cmux.send_text(&workspace_ref, &export_cmd); + } + // Send the command to the cmux workspace let bash_cmd = format!("bash {}\n", command_file.display()); if let Err(e) = cmux.send_text(&workspace_ref, &bash_cmd) { @@ -314,6 +323,13 @@ pub fn launch_in_cmux_with_relaunch_options( // Write and send command let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + let export_cmd = format!( + "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", + ticket.id + ); + let _ = cmux.send_text(&workspace_ref, &export_cmd); + } let bash_cmd = format!("bash {}\n", command_file.display()); if let Err(e) = cmux.send_text(&workspace_ref, &bash_cmd) { let _ = cmux.close_workspace(&workspace_ref); diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 421a023..e995d5f 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -196,6 +196,14 @@ fn generate_config_flags( cli_flags.push("--add-dir".to_string()); cli_flags.push(project_path.to_string()); + // Inject relay-channel MCP server when hub is running + if std::env::var("RELAY_HUB_SOCKET").is_ok() { + if let Some(config_path) = relay_mcp_config_flag(&session_dir) { + cli_flags.push("--mcp-config".to_string()); + cli_flags.push(config_path); + } + } + // Add JSON schema flag for structured output (when enabled) // Write schema to a file to avoid shell escaping issues with inline JSON // Inline jsonSchema takes precedence over jsonSchemaFile @@ -247,6 +255,81 @@ fn generate_config_flags( } } +/// Write a per-session relay MCP config and return the path for `--mcp-config`. +/// Returns `None` if the relay command cannot be located. +fn relay_mcp_config_flag(session_dir: &std::path::Path) -> Option { + let (binary, args) = locate_relay_command()?; + relay_mcp_config_flag_with_command(session_dir, binary, args) +} + +fn relay_mcp_config_flag_with_command( + session_dir: &std::path::Path, + binary: PathBuf, + args: Vec, +) -> Option { + let config_path = session_dir.join("relay-mcp.json"); + let relay_entry = if args.is_empty() { + serde_json::json!({ "command": binary.display().to_string(), "type": "stdio" }) + } else { + serde_json::json!({ "command": binary.display().to_string(), "args": args, "type": "stdio" }) + }; + let config = serde_json::json!({ "mcpServers": { "relay": relay_entry } }); + let content = serde_json::to_string_pretty(&config).ok()?; + fs::write(&config_path, content).ok()?; + Some(config_path.display().to_string()) +} + +/// Locate the relay-channel command, returning `(binary_path, extra_args)`. +/// +/// Discovery order: +/// 1. `opr8r` alongside the running operator binary → returns `(opr8r, ["relay-channel"])` +/// 2. `RELAY_CHANNEL` env var → returns `(path, ["relay-channel"])` (treated as opr8r) +/// 3. `opr8r` on PATH → returns `(opr8r, ["relay-channel"])` +/// 4. Legacy `relay-channel` standalone binary alongside operator → returns `(relay-channel, [])` +/// 5. Legacy `relay-channel` on PATH → returns `(relay-channel, [])` +fn locate_relay_command() -> Option<(PathBuf, Vec)> { + // 1. opr8r alongside the running operator binary (primary: signed distribution) + 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, vec!["relay-channel".to_string()])); + } + // 4. Legacy standalone relay-channel alongside operator + let legacy = parent.join("relay-channel"); + if legacy.exists() { + return Some((legacy, vec![])); + } + } + } + // 2. RELAY_CHANNEL env var (user override — treated as opr8r path) + if let Ok(path) = std::env::var("RELAY_CHANNEL") { + let p = PathBuf::from(&path); + if p.exists() { + return Some((p, vec!["relay-channel".to_string()])); + } + } + // 3. opr8r on PATH + if std::process::Command::new("which") + .arg("opr8r") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Some((PathBuf::from("opr8r"), vec!["relay-channel".to_string()])); + } + // 5. Legacy relay-channel on PATH + if std::process::Command::new("which") + .arg("relay-channel") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Some((PathBuf::from("relay-channel"), vec![])); + } + None +} + /// Get the detected tool for a given provider fn get_detected_tool<'a>(config: &'a Config, tool_name: &str) -> Option<&'a DetectedTool> { config @@ -933,6 +1016,35 @@ mod tests { } } + // ======================================== + // Relay MCP config injection tests + // ======================================== + + #[test] + fn test_relay_mcp_config_flag_writes_json() { + let dir = tempfile::tempdir().unwrap(); + let result = + relay_mcp_config_flag_with_command(dir.path(), PathBuf::from("/usr/bin/true"), vec![]); + assert!(result.is_some(), "Should return config path"); + let returned_path = result.unwrap(); + assert!( + returned_path.contains("relay-mcp.json"), + "Path should contain relay-mcp.json" + ); + let config_path = dir.path().join("relay-mcp.json"); + let contents = std::fs::read_to_string(config_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert!( + json["mcpServers"]["relay"]["command"].is_string(), + "command should be a string" + ); + assert_eq!(json["mcpServers"]["relay"]["type"].as_str(), Some("stdio")); + assert_eq!( + json["mcpServers"]["relay"]["command"].as_str(), + Some("/usr/bin/true") + ); + } + // ======================================== // JSON schema file path tests // ======================================== @@ -1041,4 +1153,51 @@ mod tests { .contains("$(subshell)")); } } + + // ======================================== + // relay MCP config tests + // ======================================== + + #[test] + fn test_relay_mcp_config_with_opr8r_includes_relay_channel_arg() { + let dir = tempfile::tempdir().unwrap(); + let result = relay_mcp_config_flag_with_command( + dir.path(), + PathBuf::from("/usr/local/bin/opr8r"), + vec!["relay-channel".to_string()], + ); + assert!(result.is_some(), "Config path should be returned"); + let config_path = result.unwrap(); + let content = std::fs::read_to_string(&config_path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + v["mcpServers"]["relay"]["args"][0].as_str(), + Some("relay-channel"), + "First arg must be relay-channel: {content}" + ); + assert_eq!( + v["mcpServers"]["relay"]["command"].as_str(), + Some("/usr/local/bin/opr8r"), + "Command must be opr8r path: {content}" + ); + } + + #[test] + fn test_relay_mcp_config_with_empty_args_has_no_args_key() { + let dir = tempfile::tempdir().unwrap(); + let result = relay_mcp_config_flag_with_command( + dir.path(), + PathBuf::from("/usr/local/bin/relay-channel"), + vec![], + ); + assert!(result.is_some()); + let config_path = result.unwrap(); + let content = std::fs::read_to_string(&config_path).unwrap(); + let v: serde_json::Value = serde_json::from_str(&content).unwrap(); + // No "args" key when args are empty (backward compat with standalone binary) + assert!( + v["mcpServers"]["relay"].get("args").is_none(), + "No args key for standalone binary: {content}" + ); + } } diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index d3170eb..ef3988b 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -186,6 +186,15 @@ pub fn launch_in_tmux_with_options( // and special characters when using tmux send-keys let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + // Inject relay env vars so agents can find the hub and register with their ticket ID + if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + let export_cmd = format!( + "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}", + ticket.id + ); + let _ = tmux.send_keys(&session_name, &export_cmd, true); + } + // Send simple bash command to execute the script (always short, so no buffer needed) let bash_cmd = format!("bash {}", command_file.display()); if let Err(e) = tmux.send_keys(&session_name, &bash_cmd, true) { @@ -400,6 +409,15 @@ pub fn launch_in_tmux_with_relaunch_options( // and special characters when using tmux send-keys let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + // Inject relay env vars so agents can find the hub and register with their ticket ID + if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + let export_cmd = format!( + "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}", + ticket.id + ); + let _ = tmux.send_keys(&session_name, &export_cmd, true); + } + // Send simple bash command to execute the script (always short, so no buffer needed) let bash_cmd = format!("bash {}", command_file.display()); if let Err(e) = tmux.send_keys(&session_name, &bash_cmd, true) { diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 3c2a439..b122b22 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -137,6 +137,15 @@ pub fn launch_in_zellij_with_options( // Write the command to a shell script file let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + // Inject relay env vars so agents can find the hub and register with their ticket ID + if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + let export_cmd = format!( + "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", + ticket.id + ); + let _ = zellij.send_text(&tab_name, &export_cmd); + } + // Send the command to the zellij tab let bash_cmd = format!("bash {}\n", command_file.display()); if let Err(e) = zellij.send_text(&tab_name, &bash_cmd) { @@ -286,6 +295,13 @@ pub fn launch_in_zellij_with_relaunch_options( // Write and send command let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + let export_cmd = format!( + "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", + ticket.id + ); + let _ = zellij.send_text(&tab_name, &export_cmd); + } let bash_cmd = format!("bash {}\n", command_file.display()); if let Err(e) = zellij.send_text(&tab_name, &bash_cmd) { let _ = zellij.close_tab(&tab_name); diff --git a/src/app/mod.rs b/src/app/mod.rs index 510256f..5eab75f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,8 @@ use crate::backstage::BackstageServer; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; use crate::notifications::NotificationService; +use crate::relay::hub::RelayHub; +use crate::relay::socket_path::hub_socket_path; use crate::rest::RestApiServer; use crate::services::{KanbanSyncService, PrMonitorService, PrStatusEvent, TrackedPr}; use crate::ui::create_dialog::CreateDialog; @@ -108,10 +110,12 @@ pub struct App { pub(crate) version_rx: mpsc::UnboundedReceiver, /// True if REST API port was in use at startup (another instance may be running) pub(crate) api_port_conflict: bool, + /// Relay hub handle (None if hub failed to start or another instance is running) + pub(crate) relay_hub: Option, } impl App { - pub fn new(mut config: Config, start_web: bool) -> Result { + pub async fn new(mut config: Config, start_web: bool) -> Result { // Run LLM tool detection on first startup if !config.llm_tools.detection_complete { tracing::info!("Detecting LLM CLI tools..."); @@ -267,6 +271,20 @@ impl App { let kanban_sync_service = KanbanSyncService::new(&config); let help_dialog = HelpDialog::new(config.sessions.wrapper); + // Start the relay hub embedded in this process + let relay_hub = match RelayHub::start(hub_socket_path()).await { + Ok(hub) => { + // Export socket path so child processes (agents) can find the hub + std::env::set_var("RELAY_HUB_SOCKET", hub.socket_path()); + tracing::info!(socket = %hub.socket_path().display(), "Relay hub started"); + Some(hub) + } + Err(e) => { + tracing::warn!("Relay hub failed to start (another instance may be running): {e}"); + None + } + }; + Ok(Self { config, dashboard, @@ -303,6 +321,7 @@ impl App { update_notification_shown_at: None, version_rx, api_port_conflict: false, + relay_hub, tmux_client, }) } @@ -460,6 +479,11 @@ impl App { // Terminal cleanup is handled by _terminal_guard drop + // Shut down relay hub before exit + if let Some(hub) = self.relay_hub.take() { + hub.shutdown().await; + } + // Check for exit message (unimplemented features) if let Some(message) = &self.exit_message { eprintln!("{message}"); diff --git a/src/env_vars.rs b/src/env_vars.rs index a185de6..ef6aee6 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -365,6 +365,10 @@ pub static ENV_VARS: &[EnvVar] = &[ default: Some("true"), example: Some("false"), }, + // Note: RELAY_HUB_SOCKET and RELAY_AGENT_NAME are intentionally excluded from this + // registry because they follow the cross-project claude-relay naming convention + // (no OPERATOR_ prefix) for wire compatibility with existing TS relay channels. + // They are documented in docs/relay/ instead. ]; /// Get all environment variables for a given category diff --git a/src/lib.rs b/src/lib.rs index 3e6fc02..32cc310 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,3 +36,6 @@ pub mod mcp; // Re-export env_vars for potential external use pub mod env_vars; + +// Relay hub and channel client +pub mod relay; diff --git a/src/main.rs b/src/main.rs index 627c3e2..dc17b0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ pub mod env_vars; mod mcp; mod notifications; mod queue; +mod relay; mod rest; mod setup; mod startup; @@ -370,7 +371,7 @@ async fn run_tui(config: Config, log_file_path: Option, start_web: bool // Note: tmux availability is now checked in the setup wizard (TmuxOnboarding step) // when the user selects tmux as their session wrapper - let mut app = App::new(config, start_web)?; + let mut app = App::new(config, start_web).await?; let result = app.run().await; // Print log file path on exit if logs were written diff --git a/src/relay/mod.rs b/src/relay/mod.rs new file mode 100644 index 0000000..388fc01 --- /dev/null +++ b/src/relay/mod.rs @@ -0,0 +1,10 @@ +//! Relay hub and channel client for multi-agent peer-to-peer communication. +//! +//! The hub runs embedded in operator's process lifetime. Existing TypeScript +//! claude-relay channels connect unchanged (wire-compatible protocol). +//! +//! See `docs/relay/` for architecture documentation. + +// Re-export the shared relay crate so existing `crate::relay::*` paths continue to work. +pub use operator_relay::hub; +pub use operator_relay::socket_path; diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 4bce553..406ebc8 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -82,7 +82,8 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ }, SetupStepInfo { name: "Tmux Onboarding", - description: "Help and documentation about tmux session management (shown if tmux selected)", + description: + "Help and documentation about tmux session management (shown if tmux selected)", help_text: "Operator launches Coding agents in tmux sessions. Essential commands:\n\ - **Detach from session**: Ctrl+a (quick, no prefix needed!)\n\ - **Fallback detach**: Ctrl+b then d\n\ @@ -110,7 +111,8 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ SetupStepInfo { name: "Zellij Setup", description: "Zellij session wrapper setup (shown if Zellij selected)", - help_text: "Zellij is a modern terminal workspace with built-in layouts and multiplexing.\n\n\ + help_text: + "Zellij is a modern terminal workspace with built-in layouts and multiplexing.\n\n\ This step verifies Zellij is installed and configures the layout Operator will use \ when launching agents.", navigation: "Enter to continue, Esc to go back", @@ -119,7 +121,8 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ SetupStepInfo { name: "Kanban Info", description: "Kanban integration overview and provider credential detection", - help_text: "Operator can sync with external kanban providers to pull in issues as tickets.\n\ + help_text: + "Operator can sync with external kanban providers to pull in issues as tickets.\n\ Supported providers: Jira, Linear, GitHub Projects.\n\n\ Credentials are read from environment variables (e.g. OPERATOR_JIRA_API_KEY). \ This step shows which providers were detected and validates connectivity.", @@ -134,7 +137,8 @@ pub static SETUP_STEPS: &[SetupStepInfo] = &[ 3. Discovers available projects for you to select\n\n\ Only projects you select will be synced to your ticket queue. \ You can skip this step to configure kanban providers later.", - navigation: "↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back", + navigation: + "↑/↓ or j/k to navigate, Space to select projects, Enter to confirm, Esc to go back", }, // ── Tier 3: Issue types (configured after kanban providers are connected) ─ SetupStepInfo { diff --git a/tests/relay_integration.rs b/tests/relay_integration.rs new file mode 100644 index 0000000..ebacbbc --- /dev/null +++ b/tests/relay_integration.rs @@ -0,0 +1,1278 @@ +//! Integration tests for the relay subsystem. +//! +//! Two layers: +//! +//! **Layer 1** — `ChannelSession` over a real `RelayHub` Unix socket. +//! Exercises ask/reply, broadcast, rename, timeout, and peer-gone flows +//! using only in-process async code (no external services needed). +//! +//! **Layer 2** — `relay-channel` binary driven via JSON-RPC stdio. +//! Verifies the MCP protocol surface: initialize, tools/list, relay_peers. +//! Binary tests skip gracefully if the binary hasn't been built yet. +//! +//! ## Running +//! +//! ```bash +//! # Build opr8r first (provides relay-channel subcommand for Layer 2) +//! cargo build --manifest-path opr8r/Cargo.toml +//! +//! # Run all relay integration tests +//! OPERATOR_RELAY_INTEGRATION_TEST_ENABLED=true \ +//! cargo test --test relay_integration -- --nocapture --test-threads=1 +//! +//! # Layer 1 only (no binary needed) +//! OPERATOR_RELAY_INTEGRATION_TEST_ENABLED=true \ +//! cargo test --test relay_integration test_register -- --nocapture +//! +//! # Layer 2 only +//! OPERATOR_RELAY_INTEGRATION_TEST_ENABLED=true \ +//! cargo test --test relay_integration test_binary -- --nocapture +//! ``` + +use std::path::PathBuf; +use std::time::Duration; + +use operator::relay::hub::RelayHub; +use operator_relay::channel_session::ChannelSession; +use operator_relay::protocol::ServerMsg; +use tempfile::TempDir; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; +use uuid::Uuid; + +// ── Enable guard ────────────────────────────────────────────────────────────── + +fn relay_tests_enabled() -> bool { + std::env::var("OPERATOR_RELAY_INTEGRATION_TEST_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false) +} + +macro_rules! skip_if_not_configured { + () => { + if !relay_tests_enabled() { + eprintln!("Skipping: set OPERATOR_RELAY_INTEGRATION_TEST_ENABLED=true to run relay integration tests"); + return; + } + }; +} + +// ── Test context ────────────────────────────────────────────────────────────── + +struct RelayTestContext { + hub: RelayHub, + socket_path: PathBuf, + _dir: TempDir, +} + +impl RelayTestContext { + async fn new() -> Self { + let dir = TempDir::new().expect("failed to create temp dir"); + let socket_path = dir.path().join("hub.sock"); + let hub = RelayHub::start(socket_path.clone()) + .await + .expect("failed to start relay hub"); + RelayTestContext { + hub, + socket_path, + _dir: dir, + } + } + + async fn connect_as(&self, name: &str) -> (ChannelSession, mpsc::Receiver) { + ChannelSession::connect( + &self.socket_path, + name.to_string(), + "/tmp".to_string(), + "main".to_string(), + ) + .await + .unwrap_or_else(|e| panic!("failed to connect as {name}: {e}")) + } +} + +// ── Layer 1: ChannelSession over real socket ────────────────────────────────── + +#[tokio::test] +async fn test_register_and_list_peers() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (alice, _) = ctx.connect_as("alice").await; + let (bob, _) = ctx.connect_as("bob").await; + + let alice_peers = alice.list_peers().await.expect("alice list_peers failed"); + let alice_sees: Vec<&str> = alice_peers.iter().map(|p| p.name.as_str()).collect(); + assert!( + alice_sees.contains(&"bob"), + "alice should see bob, got: {alice_sees:?}" + ); + assert!( + !alice_sees.contains(&"alice"), + "alice should not see herself, got: {alice_sees:?}" + ); + + let bob_peers = bob.list_peers().await.expect("bob list_peers failed"); + let bob_sees: Vec<&str> = bob_peers.iter().map(|p| p.name.as_str()).collect(); + assert!( + bob_sees.contains(&"alice"), + "bob should see alice, got: {bob_sees:?}" + ); + assert!( + !bob_sees.contains(&"bob"), + "bob should not see himself, got: {bob_sees:?}" + ); + + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_ask_reply_end_to_end() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (alice, mut alice_replies) = ctx.connect_as("alice").await; + let (bob, mut bob_asks) = ctx.connect_as("bob").await; + + // Bob replies as soon as the ask arrives + let ask_id = Uuid::new_v4().to_string(); + let ask_id_clone = ask_id.clone(); + tokio::spawn(async move { + if let Some(ServerMsg::IncomingAsk { ask_id, .. }) = bob_asks.recv().await { + bob.reply(ask_id, "pong".to_string()); + } + }); + + alice + .send_ask( + "bob".to_string(), + "ping".to_string(), + ask_id_clone, + Some(5_000), + None, + ) + .await + .expect("send_ask failed"); + + let msg = tokio::time::timeout(Duration::from_secs(5), alice_replies.recv()) + .await + .expect("timed out waiting for reply") + .expect("alice_replies channel closed"); + + if let ServerMsg::IncomingReply { text, .. } = msg { + assert_eq!(text, "pong"); + } else { + panic!("expected IncomingReply, got: {msg:?}"); + } + + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_broadcast_reaches_all_peers() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (broadcaster, _) = ctx.connect_as("broadcaster").await; + let (_alice, mut alice_asks) = ctx.connect_as("alice").await; + let (_bob, mut bob_asks) = ctx.connect_as("bob").await; + + let peer_count = broadcaster + .broadcast("hello".to_string()) + .await + .expect("broadcast failed"); + + assert_eq!(peer_count, 2, "broadcast should reach 2 peers"); + + // Both alice and bob should receive the ask + tokio::time::timeout(Duration::from_secs(2), alice_asks.recv()) + .await + .expect("alice did not receive broadcast within 2s") + .expect("alice_asks channel closed"); + + tokio::time::timeout(Duration::from_secs(2), bob_asks.recv()) + .await + .expect("bob did not receive broadcast within 2s") + .expect("bob_asks channel closed"); + + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_rename_via_channel_session() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (alice, _) = ctx.connect_as("alice").await; + let (watcher, _) = ctx.connect_as("watcher").await; + + alice + .rename("alice-v2".to_string()) + .await + .expect("rename failed"); + + let peers = watcher.list_peers().await.expect("list_peers failed"); + let names: Vec<&str> = peers.iter().map(|p| p.name.as_str()).collect(); + assert!( + names.contains(&"alice-v2"), + "new name should appear; got: {names:?}" + ); + assert!( + !names.contains(&"alice"), + "old name should be gone; got: {names:?}" + ); + + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_ask_to_unknown_peer_returns_error() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (alice, mut alice_msgs) = ctx.connect_as("alice").await; + + let ask_id = Uuid::new_v4().to_string(); + alice + .send_ask( + "nobody".to_string(), + "hello?".to_string(), + ask_id.clone(), + Some(2_000), + None, + ) + .await + .expect("send_ask failed"); + + let msg = tokio::time::timeout(Duration::from_secs(3), alice_msgs.recv()) + .await + .expect("timed out waiting for error notification") + .expect("alice_msgs channel closed"); + + match msg { + ServerMsg::Err { + ask_id: Some(id), .. + } => { + assert_eq!(id, ask_id, "ask_id mismatch in error"); + } + other => panic!("expected Err with ask_id, got: {other:?}"), + } + + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_ask_timeout_propagates() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (alice, mut alice_msgs) = ctx.connect_as("alice").await; + let (_bob, _bob_asks) = ctx.connect_as("bob").await; + // Bob intentionally never replies + + let ask_id = Uuid::new_v4().to_string(); + alice + .send_ask( + "bob".to_string(), + "are you there?".to_string(), + ask_id.clone(), + Some(200), + None, + ) + .await + .expect("send_ask failed"); + + // Hub delivers AskTimeout after 200ms; allow 3s total + let msg = tokio::time::timeout(Duration::from_secs(3), alice_msgs.recv()) + .await + .expect("timed out waiting for timeout error notification") + .expect("alice_msgs channel closed"); + + match msg { + ServerMsg::Err { + ask_id: Some(id), .. + } => { + assert_eq!(id, ask_id, "ask_id mismatch in timeout error"); + } + other => panic!("expected Err with ask_id, got: {other:?}"), + } + + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_peer_gone_on_disconnect() { + skip_if_not_configured!(); + + let ctx = RelayTestContext::new().await; + let (alice, mut alice_msgs) = ctx.connect_as("alice").await; + + let (bob, _) = ctx.connect_as("bob").await; + tokio::time::sleep(Duration::from_millis(20)).await; + + let ask_id = Uuid::new_v4().to_string(); + alice + .send_ask( + "bob".to_string(), + "hi".to_string(), + ask_id.clone(), + Some(5_000), + None, + ) + .await + .expect("send_ask failed"); + + tokio::time::sleep(Duration::from_millis(50)).await; + drop(bob); + + // Hub detects bob gone, sends Err{ask_id} back to alice + let msg = tokio::time::timeout(Duration::from_secs(3), alice_msgs.recv()) + .await + .expect("timed out waiting for peer-gone error") + .expect("alice_msgs channel closed"); + + match msg { + ServerMsg::Err { + ask_id: Some(id), .. + } => { + assert_eq!(id, ask_id, "ask_id mismatch in peer-gone error"); + } + other => panic!("expected Err with ask_id, got: {other:?}"), + } + + ctx.hub.shutdown().await; +} + +// ── Layer 2: relay-channel binary via JSON-RPC stdio ───────────────────────── + +/// Returns `(binary_path, extra_args)` for invoking the relay-channel MCP server. +/// +/// Preference order: +/// 1. `opr8r/target/debug/opr8r relay-channel` (primary distribution vehicle) +/// 2. `opr8r/target/release/opr8r relay-channel` +/// 3. `target/debug/relay-channel` (legacy standalone, kept for transition) +/// 4. `target/release/relay-channel` +fn relay_channel_command() -> (PathBuf, Vec) { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let opr8r_debug = manifest.join("opr8r/target/debug/opr8r"); + if opr8r_debug.exists() { + return (opr8r_debug, vec!["relay-channel".to_string()]); + } + let opr8r_release = manifest.join("opr8r/target/release/opr8r"); + if opr8r_release.exists() { + return (opr8r_release, vec!["relay-channel".to_string()]); + } + let debug = manifest.join("target/debug/relay-channel"); + if debug.exists() { + return (debug, vec![]); + } + (manifest.join("target/release/relay-channel"), vec![]) +} + +fn binary_available() -> bool { + let (binary, _) = relay_channel_command(); + binary.exists() +} + +/// Create a tokio Command pre-configured to run the relay-channel MCP server. +fn make_relay_channel_cmd() -> tokio::process::Command { + let (binary, args) = relay_channel_command(); + let mut cmd = tokio::process::Command::new(binary); + cmd.args(args); + cmd +} + +/// Send a JSON-RPC line to the process stdin (async). +async fn rpc_send(stdin: &mut (impl AsyncWriteExt + Unpin), msg: serde_json::Value) { + let mut line = serde_json::to_string(&msg).unwrap(); + line.push('\n'); + stdin.write_all(line.as_bytes()).await.unwrap(); + stdin.flush().await.unwrap(); +} + +/// Read lines from stdout until we find one that is valid JSON with a matching id (async, 5s timeout). +async fn rpc_recv( + reader: &mut BufReader, + id: u64, +) -> serde_json::Value { + tokio::time::timeout(Duration::from_secs(5), async { + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.expect("read_line failed"); + if n == 0 { + panic!("relay-channel process closed stdout waiting for id={id}"); + } + if let Ok(val) = serde_json::from_str::(line.trim()) { + if val.get("id").and_then(|v| v.as_u64()) == Some(id) { + return val; + } + } + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for JSON-RPC response with id={id}")) +} + +#[tokio::test] +async fn test_binary_initialize() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!( + "Skipping: relay-channel binary not found (run `cargo build --bin relay-channel`)" + ); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "test-agent-init") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + // Give binary time to connect to hub + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }), + ) + .await; + + let resp = rpc_recv(&mut stdout, 1).await; + + assert_eq!( + resp["result"]["serverInfo"]["name"].as_str(), + Some("relay-channel"), + "serverInfo.name mismatch: {resp}" + ); + assert!( + resp["result"]["protocolVersion"].as_str().is_some(), + "missing protocolVersion in initialize response: {resp}" + ); + let caps = resp["result"]["capabilities"] + .as_object() + .expect("capabilities should be an object"); + assert!( + caps.contains_key("experimental"), + "missing experimental capability in: {resp}" + ); + assert!( + resp["result"]["capabilities"]["experimental"]["claude/channel"].is_object(), + "missing claude/channel in experimental: {resp}" + ); + + drop(stdin); // Close stdin → process exits + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_binary_tools_list() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "test-agent-tools") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + // Initialize first + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + // Request tools list + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + + let tools = resp["result"]["tools"] + .as_array() + .expect("tools should be an array"); + let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + + let expected = [ + "relay_peers", + "relay_ask", + "relay_reply", + "relay_broadcast", + "relay_rename", + ]; + for name in &expected { + assert!( + tool_names.contains(name), + "missing tool {name}, got: {tool_names:?}" + ); + } + assert_eq!( + tools.len(), + 5, + "expected exactly 5 tools, got: {tool_names:?}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_binary_relay_peers_empty() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "solo-agent") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + rpc_send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "relay_peers", "arguments": {} } + }), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + + let text = resp["result"]["content"][0]["text"].as_str().unwrap_or(""); + let data: serde_json::Value = serde_json::from_str(text) + .unwrap_or_else(|_| panic!("relay_peers text is not JSON: {text:?}")); + assert_eq!(data["ok"], true, "ok should be true: {data}"); + assert!(data["me"].is_string(), "me should be a string: {data}"); + assert_eq!( + data["peers"].as_array().map(Vec::len), + Some(0), + "peers should be empty array: {data}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_binary_relay_peers_with_peer() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + // Register alice via ChannelSession so the binary will see her + let (alice, _) = ctx.connect_as("alice").await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "observer") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + rpc_send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "relay_peers", "arguments": {} } + }), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + + let text = resp["result"]["content"][0]["text"].as_str().unwrap_or(""); + let data: serde_json::Value = serde_json::from_str(text) + .unwrap_or_else(|_| panic!("relay_peers text is not JSON: {text:?}")); + assert_eq!(data["ok"], true, "ok should be true: {data}"); + let peers = data["peers"].as_array().expect("peers should be array"); + let names: Vec<&str> = peers.iter().filter_map(|p| p["name"].as_str()).collect(); + assert!( + names.contains(&"alice"), + "expected alice in peer list, got: {names:?}" + ); + + drop(stdin); + let _ = child.wait().await; + drop(alice); + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_binary_relay_ask_returns_immediately() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "asker") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + // Ask a non-existent peer — the tool call should return immediately with ask_id + let before = std::time::Instant::now(); + rpc_send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "relay_ask", "arguments": { "to": "nobody", "question": "hi?" } } + }), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + let elapsed = before.elapsed(); + + // Response should arrive well under 1 second (not blocking for reply) + assert!( + elapsed < Duration::from_secs(1), + "relay_ask took too long ({elapsed:?}), should return immediately" + ); + + let text = resp["result"]["content"][0]["text"].as_str().unwrap_or(""); + let data: serde_json::Value = serde_json::from_str(text) + .unwrap_or_else(|_| panic!("relay_ask response not JSON: {text:?}")); + assert_eq!(data["ok"], true, "ok should be true: {data}"); + assert!( + data["ask_id"].is_string(), + "ask_id should be a string: {data}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_binary_relay_rename_structured() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "old-name") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + rpc_send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "relay_rename", "arguments": { "new_name": "new-name" } } + }), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + + let text = resp["result"]["content"][0]["text"].as_str().unwrap_or(""); + let data: serde_json::Value = serde_json::from_str(text) + .unwrap_or_else(|_| panic!("relay_rename response not JSON: {text:?}")); + assert_eq!(data["ok"], true, "ok should be true: {data}"); + assert_eq!( + data["name"].as_str(), + Some("new-name"), + "name should be new-name: {data}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +#[tokio::test] +async fn test_binary_incoming_ask_notification_shape() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + // Spawn a binary that will receive the ask + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "receiver") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout_raw = child.stdout.take().unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + + // Also connect an in-process asker + let (asker, _) = ctx.connect_as("asker").await; + tokio::time::sleep(Duration::from_millis(100)).await; + + let ask_id = Uuid::new_v4().to_string(); + asker + .send_ask( + "receiver".to_string(), + "test question?".to_string(), + ask_id.clone(), + Some(5_000), + None, + ) + .await + .expect("send_ask failed"); + + // Read lines from binary stdout until we find a notification + let mut reader = BufReader::new(stdout_raw); + let notification = tokio::time::timeout(Duration::from_secs(5), async { + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.expect("read_line failed"); + if n == 0 { + panic!("stdout closed"); + } + if let Ok(val) = serde_json::from_str::(line.trim()) { + if val.get("method").and_then(|m| m.as_str()) + == Some("notifications/claude/channel") + { + return val; + } + } + } + }) + .await + .expect("timed out waiting for notifications/claude/channel"); + + assert_eq!( + notification["method"].as_str(), + Some("notifications/claude/channel"), + "notification method mismatch: {notification}" + ); + let params = ¬ification["params"]; + // G1: content must be the raw question text, not prefixed with "{from} asks: " + assert_eq!( + params["content"].as_str(), + Some("test question?"), + "content should be the raw question text, got: {params}" + ); + assert_eq!( + params["meta"]["from"].as_str(), + Some("asker"), + "meta.from mismatch: {params}" + ); + assert_eq!( + params["meta"]["ask_id"].as_str(), + Some(ask_id.as_str()), + "meta.ask_id mismatch: {params}" + ); + // G3: absent optional fields must be omitted from meta (sparse), not sent as null. + // broadcast_id is only present for broadcast-sourced asks; omit it for direct asks. + // thread_id is hub-generated so it may be present; only broadcast_id should be absent. + assert!( + params["meta"].get("broadcast_id").is_none(), + "broadcast_id should be absent from meta for a direct ask, got: {params}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +// G1: incoming reply notification content is the raw reply text, not "{from} replied: {text}" +#[tokio::test] +async fn test_binary_incoming_reply_notification_content_is_raw_text() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut asker_child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "asker2") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut asker_stdin = asker_child.stdin.take().unwrap(); + let mut asker_stdout = BufReader::new(asker_child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut asker_stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut asker_stdout, 1).await; + + // In-process peer that will reply to the ask + let (replier, mut replier_asks) = ctx.connect_as("replier2").await; + tokio::time::sleep(Duration::from_millis(100)).await; + + tokio::spawn(async move { + if let Some(ServerMsg::IncomingAsk { ask_id, .. }) = replier_asks.recv().await { + replier.reply(ask_id, "raw reply text".to_string()); + } + }); + + // Send the ask from the binary; it returns immediately with ask_id + rpc_send( + &mut asker_stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { "name": "relay_ask", "arguments": { "to": "replier2", "question": "what is up?" } } + }), + ) + .await; + let _ = rpc_recv(&mut asker_stdout, 2).await; + + // Wait for the reply notification (no id — it's a JSON-RPC notification) + let notification = tokio::time::timeout(Duration::from_secs(5), async { + let mut line = String::new(); + loop { + line.clear(); + let n = asker_stdout + .read_line(&mut line) + .await + .expect("read_line failed"); + if n == 0 { + panic!("stdout closed"); + } + if let Ok(val) = serde_json::from_str::(line.trim()) { + if val.get("method").and_then(|m| m.as_str()) + == Some("notifications/claude/channel") + && val["params"]["meta"]["ask_id"].is_string() + { + return val; + } + } + } + }) + .await + .expect("timed out waiting for reply notification"); + + let params = ¬ification["params"]; + // G1: reply content must be the raw text, not "{from} replied: {text}" + assert_eq!( + params["content"].as_str(), + Some("raw reply text"), + "reply notification content should be raw text, got: {params}" + ); + + drop(asker_stdin); + let _ = asker_child.wait().await; + ctx.hub.shutdown().await; +} + +// G2: ask-error notification uses lowercase error code matching the protocol spec +#[tokio::test] +async fn test_binary_ask_error_notification_uses_lowercase_code() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "err-tester") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout_raw = child.stdout.take().unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + + // Ask a non-existent peer → peer_not_found error + rpc_send( + &mut stdin, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "relay_ask", + "arguments": { "to": "ghost-peer", "question": "hello?" } + } + }), + ) + .await; + + let mut reader = BufReader::new(stdout_raw); + let notification = tokio::time::timeout(Duration::from_secs(5), async { + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.expect("read_line failed"); + if n == 0 { + panic!("stdout closed"); + } + if let Ok(val) = serde_json::from_str::(line.trim()) { + if val.get("method").and_then(|m| m.as_str()) + == Some("notifications/claude/channel") + { + return val; + } + } + } + }) + .await + .expect("timed out waiting for error notification"); + + let params = ¬ification["params"]; + + // G2: code must be lowercase snake_case, not Rust Debug format ("PeerNotFound") + let code = params["meta"]["code"].as_str().unwrap_or(""); + assert_eq!( + code, "peer_not_found", + "error code should be lowercase 'peer_not_found', got: {code:?}" + ); + + // G2: content should be human-readable guidance, not "Ask {id} failed: PeerNotFound" + let content = params["content"].as_str().unwrap_or(""); + assert!( + !content.contains("PeerNotFound") && !content.contains("failed:"), + "content should be human-readable, not debug repr, got: {content:?}" + ); + assert!( + !content.is_empty(), + "error content should not be empty: {params}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +// G4: relay_ask tool schema must include optional thread_id property +#[tokio::test] +async fn test_binary_relay_ask_schema_has_thread_id() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "schema-tester") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + + let tools = resp["result"]["tools"].as_array().expect("tools array"); + let ask_tool = tools + .iter() + .find(|t| t["name"] == "relay_ask") + .expect("relay_ask tool not found"); + + let props = &ask_tool["inputSchema"]["properties"]; + assert!( + props.get("thread_id").is_some(), + "relay_ask inputSchema should have thread_id property, got: {props}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} + +// G4: thread_id passed to relay_ask must appear in the incoming_ask notification meta +#[tokio::test] +async fn test_binary_relay_ask_thread_id_propagated_to_notification() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut receiver_child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "thread-receiver") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel (receiver)"); + + let mut receiver_stdin = receiver_child.stdin.take().unwrap(); + let receiver_stdout_raw = receiver_child.stdout.take().unwrap(); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut receiver_stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + + // In-process asker sends with explicit thread_id + let (asker, _) = ctx.connect_as("thread-asker").await; + tokio::time::sleep(Duration::from_millis(100)).await; + + let thread_id = "my-thread-001".to_string(); + let ask_id = Uuid::new_v4().to_string(); + asker + .send_ask( + "thread-receiver".to_string(), + "thread-correlated question".to_string(), + ask_id.clone(), + Some(5_000), + Some(thread_id.clone()), + ) + .await + .expect("send_ask with thread_id failed"); + + let mut reader = BufReader::new(receiver_stdout_raw); + let notification = tokio::time::timeout(Duration::from_secs(5), async { + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await.expect("read_line failed"); + if n == 0 { + panic!("stdout closed"); + } + if let Ok(val) = serde_json::from_str::(line.trim()) { + if val.get("method").and_then(|m| m.as_str()) + == Some("notifications/claude/channel") + { + return val; + } + } + } + }) + .await + .expect("timed out waiting for ask notification with thread_id"); + + let params = ¬ification["params"]; + assert_eq!( + params["meta"]["thread_id"].as_str(), + Some(thread_id.as_str()), + "thread_id should appear in notification meta, got: {params}" + ); + + drop(receiver_stdin); + let _ = receiver_child.wait().await; + ctx.hub.shutdown().await; +} + +// G5: relay_broadcast tool schema must include optional exclude_self property +#[tokio::test] +async fn test_binary_relay_broadcast_schema_has_exclude_self() { + skip_if_not_configured!(); + if !binary_available() { + eprintln!("Skipping: relay-channel binary not found"); + return; + } + + let ctx = RelayTestContext::new().await; + + let mut child = make_relay_channel_cmd() + .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) + .env("RELAY_AGENT_NAME", "broadcast-schema-tester") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to spawn relay-channel"); + + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + + tokio::time::sleep(Duration::from_millis(200)).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}), + ) + .await; + let _ = rpc_recv(&mut stdout, 1).await; + + rpc_send( + &mut stdin, + serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + ) + .await; + let resp = rpc_recv(&mut stdout, 2).await; + + let tools = resp["result"]["tools"].as_array().expect("tools array"); + let broadcast_tool = tools + .iter() + .find(|t| t["name"] == "relay_broadcast") + .expect("relay_broadcast tool not found"); + + let props = &broadcast_tool["inputSchema"]["properties"]; + assert!( + props.get("exclude_self").is_some(), + "relay_broadcast inputSchema should have exclude_self property, got: {props}" + ); + + drop(stdin); + let _ = child.wait().await; + ctx.hub.shutdown().await; +} From 4d943eb45c7515b6a655a939c2bce9c55bfa887e Mon Sep 17 00:00:00 2001 From: untra Date: Thu, 7 May 2026 11:31:40 -0600 Subject: [PATCH 4/6] redefine as operator-relay subcommand bidirectional kanban sync, opr8r relay subcommand rename, delegator support for relay access and injection dependabot and security review refactor of config.rs dto.rs, regeneration Co-Authored-By: Claude Opus 4.5 --- .github/dependabot.yml | 38 + .github/workflows/build.yaml | 9 +- .github/workflows/opr8r.yaml | 7 + Cargo.lock | 26 +- bindings/Config.ts | 7 +- bindings/DelegatorLaunchConfig.ts | 6 +- bindings/DelegatorLaunchConfigDto.ts | 6 +- bindings/ProjectSyncConfig.ts | 8 +- bindings/RelayConfig.ts | 11 + config/default.toml | 3 + deny.toml | 39 + docs/configuration/index.md | 3 + docs/delegators/index.md | 15 + docs/getting-started/agents/claude.md | 33 +- docs/relay/index.md | 74 +- docs/schemas/openapi.json | 7 + opr8r/config/operator-relay.json | 62 + opr8r/src/cli.rs | 16 +- opr8r/src/main.rs | 8 +- .../{relay_server.rs => operator_relay.rs} | 172 +- src/agents/delegator_resolution.rs | 2 + src/agents/launcher/cmux_session.rs | 2 + src/agents/launcher/interpolation.rs | 1 + src/agents/launcher/llm_command.rs | 110 +- src/agents/launcher/mod.rs | 17 + src/agents/launcher/options.rs | 26 + src/agents/launcher/step_config.rs | 1 + src/agents/launcher/tests.rs | 1 + src/agents/launcher/tmux_session.rs | 2 + src/agents/launcher/worktree_setup.rs | 1 + src/agents/launcher/zellij_session.rs | 2 + src/agents/sync.rs | 6 + src/api/kanban_sync.rs | 313 +++ src/api/mod.rs | 1 + src/api/providers/kanban/github_projects.rs | 385 ++++ src/api/providers/kanban/jira.rs | 105 + src/api/providers/kanban/linear.rs | 193 ++ src/api/providers/kanban/mod.rs | 41 + src/app/tests.rs | 154 ++ src/config.rs | 1806 +---------------- src/config/backstage_config.rs | 164 ++ src/config/config_tests.rs | 546 +++++ src/config/git_config.rs | 97 + src/config/kanban.rs | 292 +++ src/config/llm_tools.rs | 243 +++ src/config/notifications_config.rs | 140 ++ src/config/sessions.rs | 213 ++ src/queue/ticket.rs | 132 +- src/rest/dto.rs | 1712 ---------------- src/rest/dto/agents.rs | 410 ++++ src/rest/dto/configuration.rs | 310 +++ src/rest/dto/issue_types.rs | 458 +++++ src/rest/dto/kanban.rs | 352 ++++ src/rest/dto/mod.rs | 220 ++ src/rest/routes/delegators.rs | 22 + src/rest/routes/launch.rs | 19 + src/rest/state.rs | 69 +- src/steps/manager.rs | 1 + src/steps/session.rs | 1 + src/ui/create_dialog.rs | 1 - src/ui/dialogs/confirm.rs | 1 + src/ui/kanban_view.rs | 6 - tests/relay_integration.rs | 110 +- 63 files changed, 5586 insertions(+), 3652 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 bindings/RelayConfig.ts create mode 100644 deny.toml create mode 100644 opr8r/config/operator-relay.json rename opr8r/src/{relay_server.rs => operator_relay.rs} (74%) create mode 100644 src/api/kanban_sync.rs create mode 100644 src/config/backstage_config.rs create mode 100644 src/config/config_tests.rs create mode 100644 src/config/git_config.rs create mode 100644 src/config/kanban.rs create mode 100644 src/config/llm_tools.rs create mode 100644 src/config/notifications_config.rs create mode 100644 src/config/sessions.rs delete mode 100644 src/rest/dto.rs create mode 100644 src/rest/dto/agents.rs create mode 100644 src/rest/dto/configuration.rs create mode 100644 src/rest/dto/issue_types.rs create mode 100644 src/rest/dto/kanban.rs create mode 100644 src/rest/dto/mod.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9072ad3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + groups: + rust-deps: + patterns: ["*"] + + - package-ecosystem: cargo + directory: "/opr8r" + schedule: + interval: weekly + groups: + opr8r-deps: + patterns: ["*"] + + - package-ecosystem: npm + directory: "/vscode-extension" + schedule: + interval: weekly + groups: + vscode-deps: + patterns: ["*"] + + - package-ecosystem: bundler + directory: "/docs" + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 00d60b8..9cd7031 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -61,6 +61,13 @@ jobs: - name: Tests run: cargo test --all-features + - name: cargo-deny + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: Cargo.toml + arguments: --all-features + command: check + coverage: needs: lint-test if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -178,7 +185,7 @@ jobs: key: ${{ runner.os }}-cargo-relay-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-relay- - - name: Build opr8r (provides relay-channel subcommand) + - name: Build opr8r (provides relay subcommand) run: cargo build --manifest-path opr8r/Cargo.toml - name: Run relay integration tests diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index 80ab276..682677c 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -52,6 +52,13 @@ jobs: - name: Run tests run: cargo test --all-features + - name: cargo-deny + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: opr8r/Cargo.toml + arguments: --all-features + command: check + coverage: name: Coverage needs: lint-test diff --git a/Cargo.lock b/Cargo.lock index 0b47045..40cc217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,9 +402,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cassowary" @@ -1878,9 +1878,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-modular" @@ -2668,9 +2668,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -3155,30 +3155,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/bindings/Config.ts b/bindings/Config.ts index 231cc7b..c5a3837 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -12,6 +12,7 @@ import type { ModelServer } from "./ModelServer"; import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; import type { QueueConfig } from "./QueueConfig"; +import type { RelayConfig } from "./RelayConfig"; import type { RestApiConfig } from "./RestApiConfig"; import type { SessionsConfig } from "./SessionsConfig"; import type { TemplatesConfig } from "./TemplatesConfig"; @@ -44,4 +45,8 @@ delegators: Array, * 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. */ -model_servers: Array, }; +model_servers: Array, +/** + * Relay MCP injection configuration + */ +relay: RelayConfig, }; diff --git a/bindings/DelegatorLaunchConfig.ts b/bindings/DelegatorLaunchConfig.ts index aee60b0..329b15c 100644 --- a/bindings/DelegatorLaunchConfig.ts +++ b/bindings/DelegatorLaunchConfig.ts @@ -38,4 +38,8 @@ prompt_prefix: string | null, /** * Prompt text to append after the generated step prompt */ -prompt_suffix: string | null, }; +prompt_suffix: string | null, +/** + * Override global relay auto-inject MCP setting per-delegator (None = use global setting) + */ +operator_relay: boolean | null, }; diff --git a/bindings/DelegatorLaunchConfigDto.ts b/bindings/DelegatorLaunchConfigDto.ts index aa17fb4..b660677 100644 --- a/bindings/DelegatorLaunchConfigDto.ts +++ b/bindings/DelegatorLaunchConfigDto.ts @@ -38,4 +38,8 @@ prompt_prefix: string | null, /** * Prompt text to append after the generated step prompt */ -prompt_suffix: string | null, }; +prompt_suffix: string | null, +/** + * Override global relay auto-inject MCP setting per-delegator (None = use global setting) + */ +operator_relay: boolean | null, }; diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index 9b433a0..6ec8a08 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -24,4 +24,10 @@ collection_name: string | null, * Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). * Multiple kanban types can map to the same operator template. */ -type_mappings: { [key in string]?: string }, }; +type_mappings: { [key in string]?: string }, +/** + * When true, operator pushes status changes and activity logs back to this kanban project. + * Ticket state changes (todo→doing, doing→done) and step completions with delegator info + * are reflected upstream. Default: false. + */ +bidirectional: boolean, }; diff --git a/bindings/RelayConfig.ts b/bindings/RelayConfig.ts new file mode 100644 index 0000000..265b4ab --- /dev/null +++ b/bindings/RelayConfig.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Relay MCP injection configuration + */ +export type RelayConfig = { +/** + * When true, automatically inject the relay MCP server for all delegators. + * When false (default), relay injection is opt-in per delegator. + */ +auto_inject_mcp: boolean, }; diff --git a/config/default.toml b/config/default.toml index a100a6b..61ee22c 100644 --- a/config/default.toml +++ b/config/default.toml @@ -156,3 +156,6 @@ url = "https://operator.untra.io/VERSION" # Timeout for version check HTTP request (seconds) timeout_secs = 3 + +[relay] +auto_inject_mcp = false diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..514785e --- /dev/null +++ b/deny.toml @@ -0,0 +1,39 @@ +[advisories] +version = 2 +db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +unmaintained = "all" +yanked = "deny" +ignore = [ + # instant: unmaintained, pulled in by notify-types → notify; no upgrade path available + { id = "RUSTSEC-2024-0384" }, + # paste: unmaintained, pulled in by ratatui; no upgrade path available + { id = "RUSTSEC-2024-0436" }, +] + +[licenses] +version = 2 +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Unicode-3.0", + "Zlib", + "CC0-1.0", + "CDLA-Permissive-2.0", +] +confidence-threshold = 0.8 + +[bans] +multiple-versions = "warn" +wildcards = "warn" +deny = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 8fc1c55..ce02fc3 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -327,6 +327,9 @@ enabled = true url = "https://operator.untra.io/VERSION" timeout_secs = 3 +[relay] +auto_inject_mcp = false + ``` ## Configuration Files diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 3171a96..35b2c74 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -76,6 +76,21 @@ prompt_suffix = "\n\nBe concise." `inherit` means the global config value is used. +### Relay MCP injection + +Set `operator_relay = true` to enable the relay MCP server for Claude Code +launches from this delegator. Use `false` to disable it even when the global +default is `true`. Omit the field to use the global `relay.auto_inject_mcp` +default. + +```toml +[delegators.coordination.launch_config] +operator_relay = true # coordination-heavy delegators: enable relay + +[delegators.task.launch_config] +operator_relay = false # single-agent task delegators: disable relay +``` + ## Using a custom model server To route a delegator through a local Ollama instance: diff --git a/docs/getting-started/agents/claude.md b/docs/getting-started/agents/claude.md index 296fb29..e715317 100644 --- a/docs/getting-started/agents/claude.md +++ b/docs/getting-started/agents/claude.md @@ -46,30 +46,19 @@ claude auth login ## Multi-agent relay -Agents launched by Operator automatically participate in the relay hub when the hub is running. Operator: +Agents launched by Operator can participate in the relay hub when the hub is running. +As long as the delegator (or global config) has enabled relay MCP injection. -1. Injects `RELAY_HUB_SOCKET` and `RELAY_AGENT_NAME` (the ticket ID, e.g. `FEAT-042`) into the session environment. -2. Writes a per-session `relay-mcp.json` config and passes `--mcp-config ` to Claude Code, so the `relay-channel` MCP server starts automatically alongside the agent. +When relay is enabled for a delegator, Operator: -This means Claude agents can use `relay_peers`, `relay_ask`, `relay_reply`, `relay_broadcast`, and `relay_rename` tools out of the box — no manual MCP server configuration needed. +1. Injects `RELAY_HUB_SOCKET` and `RELAY_AGENT_NAME` (the ticket ID, + e.g. `FEAT-042`) into the session environment. +2. Writes a per-session `relay-mcp.json` config and passes + `--mcp-config ` to Claude Code, so the `relay` MCP server starts alongside the agent. -See [Relay](/docs/relay/) for the full architecture. - -## Troubleshooting - -### Claude not found - -Ensure Claude is in your PATH: - -```bash -which claude -``` - -### Authentication errors +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. -Re-authenticate with: +See [Relay](/docs/relay/) for the full architecture. -```bash -claude auth logout -claude auth login -``` diff --git a/docs/relay/index.md b/docs/relay/index.md index e94d165..0f64293 100644 --- a/docs/relay/index.md +++ b/docs/relay/index.md @@ -4,17 +4,54 @@ description: "Multi-agent peer-to-peer communication hub embedded in Operator." layout: doc --- -Operator! embeds a relay hub that lets agents launched for different tickets discover and message each other in real time. When the hub is running and `RELAY_HUB_SOCKET` is set, Operator automatically injects the `relay-channel` MCP server into Claude Code launches so agents can use the relay tools without manual configuration. Codex and other tools receive the env vars but require manual MCP configuration. +Operator! embeds a relay hub that lets agents launched for different tickets discover and message each other in real time. When a delegator sets `operator_relay = true` in its `launch_config`, Operator injects the `relay` MCP server into Claude Code launches for that delegator — provided the relay hub socket is available. Injection does **not** happen automatically for all launches; the global default is `relay.auto_inject_mcp = false`. -## How it works +The MCP server runs as `opr8r relay` (a subcommand of the signed `opr8r` binary) so no additional executable needs to be signed or distributed. Codex and other tools receive the env vars but require manual MCP configuration. + +## Tools shipped by Operator + +Operator ships two complementary executables for agent orchestration: + +### opr8r — step wrapper and API client + +`opr8r` wraps LLM tool invocations (Claude Code, Codex, Gemini CLI) inside multi-step ticket workflows. It runs as the **parent process** of the LLM tool, intercepts its exit code, and reports step completion to the Operator REST API. The API then decides what happens next — another step, a review gate, or workflow completion. + +``` +opr8r --ticket-id FEAT-042 --step build -- claude --prompt "implement the feature" + ↓ launches + claude (child process) + ↓ on exit, opr8r reports to + Operator API → next step / review / done +``` + +See the [opr8r CLI reference](/docs/cli/) for full flag documentation. + +### relay — MCP client for the relay hub + +`relay` is the MCP stdio server that Operator ships so agents can communicate with each other. It runs as a **child process** of the LLM tool (spawned by the MCP host), connects to the relay hub over a Unix socket, and exposes five relay tools via the MCP protocol: + +| Tool | Description | +|------|-------------| +| `relay_peers` | List other active agent sessions on this machine | +| `relay_ask` | Send a question to a named peer and await a reply | +| `relay_reply` | Reply to a pending ask | +| `relay_broadcast` | Send a message to all connected peers | +| `relay_rename` | Change this session's peer name | + +`relay` is a subcommand of `opr8r` (`opr8r relay`) so it is distributed and code-signed as part of the same binary. No separate download is needed. + +## How the hub works The relay hub is a Unix socket server that runs inside the Operator process. Each agent registers as a named peer when it connects. Operator assigns each agent its ticket ID as its peer name, so agents can address each other by ticket. ``` operator process -└── RelayHub (Unix socket) - ├── Claude agent "FEAT-001" ──── ask ────→ Claude agent "FEAT-002" - └── Claude agent "FEAT-002" ◄─── reply ─── Claude agent "FEAT-001" +└── RelayHub (Unix socket at $RELAY_HUB_SOCKET) + ├── opr8r relay ← Claude agent "FEAT-001" + │ relay_ask("FEAT-002", "have you finished the auth module?") + │ ↓ + └── opr8r relay ← Claude agent "FEAT-002" + relay_reply(ask_id, "yes, pushed to feat/auth") ``` The hub runs for the lifetime of the Operator process. Unlike the standalone `claude-relay` tool, there is no idle-shutdown timer — the hub stays up as long as Operator is running. @@ -29,7 +66,7 @@ The hub binds to a Unix domain socket. The path is resolved in this priority ord | 2 | `$CLAUDE_PLUGIN_DATA/hub.sock` | — | | 3 | fallback | `~/.claude-relay/hub.sock` | -Operator exports `RELAY_HUB_SOCKET` automatically at startup, so every child process it spawns can find the hub. For Claude Code, Operator also writes a per-session `relay-mcp.json` and passes `--mcp-config ` at launch time, so the relay-channel MCP server is active without any manual setup. For other tools, the socket env var is exported but MCP wiring requires manual configuration. +Operator exports `RELAY_HUB_SOCKET` automatically at startup, so every child process it spawns can find the hub. For Claude Code, Operator also writes a per-session `relay-mcp.json` and passes `--mcp-config ` at launch time, so `relay` starts automatically alongside the agent — no manual setup needed. For other tools, the socket env var is exported but MCP wiring requires manual configuration. ## Agent naming @@ -42,7 +79,20 @@ export RELAY_AGENT_NAME=FEAT-042 The agent connects to the hub and registers under the name `FEAT-042`. Any other agent that knows the ticket ID can address it directly. -For agents running standalone (not launched by Operator), the relay channel binary reads `~/.claude/sessions/{ppid}.json` and uses whatever name the user sets with `/rename` in Claude Code. +For agents running standalone (not launched by Operator), `relay` reads `~/.claude/sessions/{ppid}.json` and uses whatever name the user sets with `/rename` in Claude Code. + +## Environment variables + +These variables are shared with the claude-relay ecosystem and intentionally do not use the `OPERATOR_` prefix, so existing tools pick them up automatically. + +| Variable | Set by | Purpose | +|----------|--------|---------| +| `RELAY_HUB_SOCKET` | Operator at startup | Unix socket path for the relay hub | +| `RELAY_AGENT_NAME` | Operator at agent launch | Peer name registered for this session (ticket ID) | +| `CLAUDE_PLUGIN_DATA` | Claude Code | Secondary socket path fallback | +| `OPERATOR_RELAY` | User override | Path to `opr8r` binary when auto-discovery fails | + +`RELAY_HUB_SOCKET` and `RELAY_AGENT_NAME` are the entry points for hooking into the relay system from outside Operator. Any process that sets these variables and speaks the relay wire protocol can participate as a peer. ## Wire compatibility @@ -53,16 +103,6 @@ The protocol is byte-compatible with TypeScript claude-relay. Existing TS channe - Same error codes: `peer_not_found`, `name_taken`, `timeout`, etc. - Same line-delimited JSON framing over Unix socket -## Environment variables - -| Variable | Set by | Purpose | -|----------|--------|---------| -| `RELAY_HUB_SOCKET` | Operator at startup | Unix socket path for the hub | -| `RELAY_AGENT_NAME` | Operator at launch time | Peer name for the agent (ticket ID) | -| `CLAUDE_PLUGIN_DATA` | Claude Code | Secondary socket path fallback | - -These variables intentionally do not use the `OPERATOR_` prefix — they are shared with the claude-relay ecosystem so existing tools pick them up automatically. - ## See also - [Claude agent setup](/docs/getting-started/agents/claude/) diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 98ed274..1f3f22e 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -1471,6 +1471,13 @@ }, "description": "Additional CLI flags" }, + "operator_relay": { + "type": [ + "boolean", + "null" + ], + "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)" + }, "permission_mode": { "type": [ "string", diff --git a/opr8r/config/operator-relay.json b/opr8r/config/operator-relay.json new file mode 100644 index 0000000..f37fbf4 --- /dev/null +++ b/opr8r/config/operator-relay.json @@ -0,0 +1,62 @@ +{ + "serverInfo": { + "name": "relay" + }, + "instructions": "Always reply to messages via relay_reply BEFORE other work. Use relay_peers to pick targets. Use relay_ask for one peer, relay_broadcast for all. Surface ask errors to the user.", + "tools": [ + { + "name": "relay_peers", + "description": "List OTHER active sessions on this machine. Returns {me, peers} where me is your own session name and peers is every other session (excluding you). Each peer has cwd and git_branch for disambiguation.", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, + { + "name": "relay_ask", + "description": "Ask a specific peer a question. Non-blocking: returns immediately with {ok, ask_id}; the reply arrives later as a channel notification whose meta carries the same ask_id. Errors tied to this ask (peer_not_found, peer_gone, timeout) also arrive as channel notifications. Correlate by ask_id. If multiple peers may share a similar name, call relay_peers first and match by cwd or git_branch to pick the right target.", + "inputSchema": { + "type": "object", + "properties": { + "to": { "type": "string", "description": "Name of the peer to ask" }, + "question": { "type": "string", "description": "The question to send" }, + "thread_id": { "type": "string", "description": "Optional thread identifier to correlate multi-turn exchanges. If you received an ask with a thread_id and are replying or continuing, pass the same thread_id." }, + "timeout_ms": { "type": "number", "description": "Timeout in milliseconds (default: 120000)" } + }, + "required": ["to", "question"] + } + }, + { + "name": "relay_reply", + "description": "Reply to an incoming ask by its ask_id. text is a plain string. Replies are one-shot — no streaming, no cancellation, no structured payload. If you need structured data, serialize JSON inside the string; the asker parses it.", + "inputSchema": { + "type": "object", + "properties": { + "ask_id": { "type": "string", "description": "The ask_id from the incoming ask notification" }, + "text": { "type": "string", "description": "The reply text" } + }, + "required": ["ask_id", "text"] + } + }, + { + "name": "relay_broadcast", + "description": "Broadcast a question to ALL other peers on this machine, including sessions on unrelated projects. Use ONLY when the user explicitly wants every session asked. Do NOT use as a fallback when relay_ask returns an error (peer_not_found, peer_gone, timeout); surface the error to the user and let them decide. If you want to reach a specific peer, use relay_ask.", + "inputSchema": { + "type": "object", + "properties": { + "question": { "type": "string", "description": "The question to broadcast" }, + "exclude_self": { "type": "boolean", "description": "If true (default), the sender is excluded from recipients." } + }, + "required": ["question"] + } + }, + { + "name": "relay_rename", + "description": "Rename this session's registered name.", + "inputSchema": { + "type": "object", + "properties": { + "new_name": { "type": "string", "description": "New peer name (alphanumeric, ., -, _ only)" } + }, + "required": ["new_name"] + } + } + ] +} diff --git a/opr8r/src/cli.rs b/opr8r/src/cli.rs index 687a340..8514c22 100644 --- a/opr8r/src/cli.rs +++ b/opr8r/src/cli.rs @@ -5,12 +5,12 @@ use clap::Parser; /// Wraps LLM commands (claude, gemini, codex), passes through output, /// and orchestrates step transitions via the Operator API. /// -/// Run `opr8r relay-channel` to start as an MCP stdio relay-channel server. +/// Run `opr8r relay` to start as an MCP stdio relay server. #[derive(Parser, Debug)] #[command(name = "opr8r")] #[command(version, about, long_about = None)] pub struct Args { - /// Subcommand (e.g., relay-channel). If absent, runs in step-wrapper mode. + /// Subcommand (e.g., relay). If absent, runs in step-wrapper mode. #[command(subcommand)] pub subcommand: Option, @@ -51,12 +51,12 @@ pub struct Args { /// Available subcommands for opr8r. #[derive(clap::Subcommand, Debug, PartialEq)] pub enum Cmd { - /// Run as an MCP stdio relay-channel server. + /// Run as an MCP stdio relay server. /// /// Connects to the relay hub and exposes relay tools (relay_peers, /// relay_ask, relay_reply, relay_broadcast, relay_rename) to LLM agents /// via the MCP stdio protocol. - RelayChannel, + Relay, } impl Args { @@ -95,14 +95,14 @@ mod tests { use super::*; #[test] - fn test_relay_channel_subcommand_parses() { - let result = Args::try_parse_from(["opr8r", "relay-channel"]); + fn test_relay_subcommand_parses() { + let result = Args::try_parse_from(["opr8r", "relay"]); assert!( result.is_ok(), - "relay-channel subcommand should parse successfully" + "relay subcommand should parse successfully" ); let args = result.unwrap(); - assert!(matches!(args.subcommand, Some(Cmd::RelayChannel))); + assert!(matches!(args.subcommand, Some(Cmd::Relay))); } #[test] diff --git a/opr8r/src/main.rs b/opr8r/src/main.rs index 4f9057d..8f0bd7e 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -1,7 +1,7 @@ mod api; mod cli; +mod operator_relay; mod output_parser; -mod relay_server; mod runner; mod transition; @@ -54,9 +54,9 @@ fn build_step_complete_request( async fn main() -> ExitCode { let args = Args::parse_args(); - // Dispatch relay-channel subcommand before any step-wrapper logic - if args.subcommand == Some(Cmd::RelayChannel) { - return relay_server::run().await; + // Dispatch relay subcommand before any step-wrapper logic + if args.subcommand == Some(Cmd::Relay) { + return operator_relay::run().await; } // Step-wrapper mode: validate required fields diff --git a/opr8r/src/relay_server.rs b/opr8r/src/operator_relay.rs similarity index 74% rename from opr8r/src/relay_server.rs rename to opr8r/src/operator_relay.rs index e054042..cc0f727 100644 --- a/opr8r/src/relay_server.rs +++ b/opr8r/src/operator_relay.rs @@ -1,16 +1,15 @@ -//! MCP stdio relay-channel server mode for opr8r. +//! relay: MCP stdio server that Operator ships to connect LLM agents to the relay hub. //! -//! Invoked via `opr8r relay-channel`. Connects to the relay hub and exposes +//! Invoked via `opr8r relay`. Connects to the relay hub and exposes //! relay tools (relay_peers, relay_ask, relay_reply, relay_broadcast, //! relay_rename) to LLM agents via the MCP stdio protocol. //! -//! This is the distribution vehicle for relay-channel: since opr8r is signed, -//! notarized, and released on all platforms, relay functionality is available -//! to agents on any machine with a standard operator install. +//! Since opr8r is signed, notarized, and released on all platforms, relay +//! functionality is available to agents on any machine with a standard operator install. use std::io::{BufRead, Write}; use std::process::ExitCode; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use operator_relay::channel_session::ChannelSession; use operator_relay::protocol::ServerMsg; @@ -19,65 +18,19 @@ use serde_json::{json, Value}; use tokio::sync::mpsc; use uuid::Uuid; -// ── Tool schemas ────────────────────────────────────────────────────────────── +// ── Config loader ───────────────────────────────────────────────────────────── -fn tools_list() -> Value { - json!([ - { - "name": "relay_peers", - "description": "List OTHER active sessions on this machine. Returns {me, peers} where me is your own session name and peers is every other session (excluding you). Each peer has cwd and git_branch for disambiguation.", - "inputSchema": { "type": "object", "properties": {}, "required": [] } - }, - { - "name": "relay_ask", - "description": "Ask a specific peer a question. Non-blocking: returns immediately with {ok, ask_id}; the reply arrives later as a channel notification whose meta carries the same ask_id. Errors tied to this ask (peer_not_found, peer_gone, timeout) also arrive as channel notifications. Correlate by ask_id. If multiple peers may share a similar name, call relay_peers first and match by cwd or git_branch to pick the right target.", - "inputSchema": { - "type": "object", - "properties": { - "to": { "type": "string", "description": "Name of the peer to ask" }, - "question": { "type": "string", "description": "The question to send" }, - "thread_id": { "type": "string", "description": "Optional thread identifier to correlate multi-turn exchanges. If you received an ask with a thread_id and are replying or continuing, pass the same thread_id." }, - "timeout_ms": { "type": "number", "description": "Timeout in milliseconds (default: 120000)" } - }, - "required": ["to", "question"] - } - }, - { - "name": "relay_reply", - "description": "Reply to an incoming ask by its ask_id. text is a plain string. Replies are one-shot — no streaming, no cancellation, no structured payload. If you need structured data, serialize JSON inside the string; the asker parses it.", - "inputSchema": { - "type": "object", - "properties": { - "ask_id": { "type": "string", "description": "The ask_id from the incoming ask notification" }, - "text": { "type": "string", "description": "The reply text" } - }, - "required": ["ask_id", "text"] - } - }, - { - "name": "relay_broadcast", - "description": "Broadcast a question to ALL other peers on this machine, including sessions on unrelated projects. Use ONLY when the user explicitly wants every session asked. Do NOT use as a fallback when relay_ask returns an error (peer_not_found, peer_gone, timeout); surface the error to the user and let them decide. If you want to reach a specific peer, use relay_ask.", - "inputSchema": { - "type": "object", - "properties": { - "question": { "type": "string", "description": "The question to broadcast" }, - "exclude_self": { "type": "boolean", "description": "If true (default), the sender is excluded from recipients." } - }, - "required": ["question"] - } - }, - { - "name": "relay_rename", - "description": "Rename this session's registered name.", - "inputSchema": { - "type": "object", - "properties": { - "new_name": { "type": "string", "description": "New peer name (alphanumeric, ., -, _ only)" } - }, - "required": ["new_name"] - } - } - ]) +static CONFIG: OnceLock = OnceLock::new(); + +fn config() -> &'static Value { + CONFIG.get_or_init(|| { + serde_json::from_str(include_str!("../config/operator-relay.json")) + .expect("config/operator-relay.json must be valid JSON") + }) +} + +fn tools_list() -> &'static Value { + &config()["tools"] } // ── Stdout helpers ──────────────────────────────────────────────────────────── @@ -108,7 +61,7 @@ fn write_notification(method: &str, params: Value) { // ── Entry point ─────────────────────────────────────────────────────────────── -/// Run the relay-channel MCP stdio server. +/// Run the relay MCP stdio server. pub async fn run() -> ExitCode { let socket_path = hub_socket_path(); @@ -130,7 +83,7 @@ pub async fn run() -> ExitCode { match ChannelSession::connect(&socket_path, name, cwd, git_branch).await { Ok(pair) => pair, Err(e) => { - eprintln!("[opr8r relay-channel] Failed to connect to relay hub: {e}"); + eprintln!("[opr8r relay] Failed to connect to relay hub: {e}"); return ExitCode::from(1); } }; @@ -151,7 +104,7 @@ pub async fn run() -> ExitCode { }) .await { - eprintln!("[opr8r relay-channel] Warning: could not watch for session renames: {e}"); + eprintln!("[opr8r relay] Warning: could not watch for session renames: {e}"); } } @@ -249,6 +202,7 @@ async fn handle_request(line: &str, session: &Arc) { match method { "initialize" => { + let cfg = config(); write_response( id, json!({ @@ -257,11 +211,11 @@ async fn handle_request(line: &str, session: &Arc) { "tools": {}, "experimental": { "claude/channel": {} } }, - "serverInfo": { "name": "relay-channel", "version": "0.1.0" }, - "instructions": "Always reply to messages via relay_reply BEFORE \ - other work. Use relay_peers to pick targets. Use relay_ask \ - for one peer, relay_broadcast for all. Surface ask errors \ - to the user." + "serverInfo": { + "name": cfg["serverInfo"]["name"], + "version": env!("CARGO_PKG_VERSION") + }, + "instructions": cfg["instructions"] }), ); } @@ -426,3 +380,77 @@ async fn dispatch_tool( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_loads_and_has_required_fields() { + let cfg = config(); + assert!( + cfg.get("serverInfo").is_some(), + "config must have serverInfo" + ); + assert!( + cfg.get("instructions").is_some(), + "config must have instructions" + ); + assert!(cfg.get("tools").is_some(), "config must have tools"); + } + + #[test] + fn test_tools_list_has_five_tools() { + let tools = tools_list(); + let arr = tools.as_array().expect("tools must be an array"); + assert_eq!(arr.len(), 5, "expected 5 relay tools, got {}", arr.len()); + } + + #[test] + fn test_tools_list_each_tool_has_required_mcp_fields() { + let tools = tools_list(); + let arr = tools.as_array().unwrap(); + for tool in arr { + let name = tool.get("name").and_then(|v| v.as_str()).unwrap_or(""); + assert!(!name.is_empty(), "tool must have non-empty name: {tool}"); + assert!( + tool.get("description").is_some(), + "tool '{name}' must have description" + ); + assert!( + tool.get("inputSchema").is_some(), + "tool '{name}' must have inputSchema" + ); + } + } + + #[test] + fn test_tools_list_names_are_expected() { + let tools = tools_list(); + let names: Vec<&str> = tools + .as_array() + .unwrap() + .iter() + .filter_map(|t| t.get("name")?.as_str()) + .collect(); + for expected in [ + "relay_peers", + "relay_ask", + "relay_reply", + "relay_broadcast", + "relay_rename", + ] { + assert!( + names.contains(&expected), + "missing tool '{expected}', got: {names:?}" + ); + } + } + + #[test] + fn test_server_info_name_is_relay() { + let cfg = config(); + let name = cfg["serverInfo"]["name"].as_str().unwrap_or(""); + assert_eq!(name, "relay", "serverInfo.name must be 'relay'"); + } +} diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index f217552..190c7d5 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -76,6 +76,7 @@ pub(crate) fn apply_delegator_launch_config( 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; } } @@ -421,6 +422,7 @@ mod tests { docker: Some(true), prompt_prefix: Some("PREFIX".to_string()), prompt_suffix: Some("SUFFIX".to_string()), + operator_relay: None, }), }); diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index dbe39d5..5da2545 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -156,6 +156,7 @@ pub fn launch_in_cmux_with_options( &prompt_file, Some(ticket), Some(project_path), + options.operator_relay, )?; if options.yolo_mode { @@ -304,6 +305,7 @@ pub fn launch_in_cmux_with_relaunch_options( &prompt_file, Some(ticket), Some(project_path), + options.launch_options.operator_relay, )?; if is_resume { diff --git a/src/agents/launcher/interpolation.rs b/src/agents/launcher/interpolation.rs index 233efe3..89a61eb 100644 --- a/src/agents/launcher/interpolation.rs +++ b/src/agents/launcher/interpolation.rs @@ -293,6 +293,7 @@ mod tests { step: "plan".to_string(), content: "# Feature Description\n\nThis is the feature content.".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index e995d5f..45a0eef 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -30,6 +30,7 @@ pub fn build_llm_command_with_permissions_for_tool( prompt_file: &std::path::Path, ticket: Option<&Ticket>, project_path: Option<&str>, + operator_relay: Option, ) -> Result { // Find the specified tool let tool = get_detected_tool(config, tool_name).ok_or_else(|| { @@ -43,7 +44,14 @@ pub fn build_llm_command_with_permissions_for_tool( // Generate config flags from permissions let config_flags = if let (Some(ticket), Some(project_path)) = (ticket, project_path) { - generate_config_flags(config, &tool.name, ticket, project_path, session_id)? + generate_config_flags( + config, + &tool.name, + ticket, + project_path, + session_id, + operator_relay, + )? } else { String::new() }; @@ -126,6 +134,20 @@ pub fn build_docker_command( Ok(docker_args.join(" ")) } +/// Determine whether to inject the relay MCP config for this launch. +/// +/// - `delegator_setting`: per-ticket override from `LaunchOptions.operator_relay` +/// - `hub_available`: whether `RELAY_HUB_SOCKET` is set in the environment +/// - `global_default`: `config.relay.auto_inject_mcp` fallback +fn resolve_relay_injection( + delegator_setting: Option, + hub_available: bool, + global_default: bool, +) -> bool { + let effective = delegator_setting.unwrap_or(global_default); + effective && hub_available +} + /// Generate config flags for the LLM command based on step permissions fn generate_config_flags( config: &Config, @@ -133,6 +155,7 @@ fn generate_config_flags( ticket: &Ticket, project_path: &str, session_id: &str, + operator_relay: Option, ) -> Result { // Load project permissions let project_perms = load_project_permissions(config, project_path)?; @@ -196,8 +219,9 @@ fn generate_config_flags( cli_flags.push("--add-dir".to_string()); cli_flags.push(project_path.to_string()); - // Inject relay-channel MCP server when hub is running - if std::env::var("RELAY_HUB_SOCKET").is_ok() { + // Inject relay MCP server based on effective relay setting + let hub_available = std::env::var("RELAY_HUB_SOCKET").is_ok(); + if resolve_relay_injection(operator_relay, hub_available, config.relay.auto_inject_mcp) { if let Some(config_path) = relay_mcp_config_flag(&session_dir) { cli_flags.push("--mcp-config".to_string()); cli_flags.push(config_path); @@ -279,34 +303,34 @@ fn relay_mcp_config_flag_with_command( Some(config_path.display().to_string()) } -/// Locate the relay-channel command, returning `(binary_path, extra_args)`. +/// Locate the relay command, returning `(binary_path, extra_args)`. /// /// Discovery order: -/// 1. `opr8r` alongside the running operator binary → returns `(opr8r, ["relay-channel"])` -/// 2. `RELAY_CHANNEL` env var → returns `(path, ["relay-channel"])` (treated as opr8r) -/// 3. `opr8r` on PATH → returns `(opr8r, ["relay-channel"])` -/// 4. Legacy `relay-channel` standalone binary alongside operator → returns `(relay-channel, [])` -/// 5. Legacy `relay-channel` on PATH → returns `(relay-channel, [])` +/// 1. `opr8r` alongside the running operator binary → returns `(opr8r, ["relay"])` +/// 2. `OPERATOR_RELAY` env var → returns `(path, ["relay"])` (treated as opr8r) +/// 3. `opr8r` on PATH → returns `(opr8r, ["relay"])` +/// 4. Legacy `operator-relay` standalone binary alongside operator → returns `(operator-relay, [])` +/// 5. Legacy `operator-relay` on PATH → returns `(operator-relay, [])` fn locate_relay_command() -> Option<(PathBuf, Vec)> { // 1. opr8r alongside the running operator binary (primary: signed distribution) 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, vec!["relay-channel".to_string()])); + return Some((candidate, vec!["relay".to_string()])); } - // 4. Legacy standalone relay-channel alongside operator - let legacy = parent.join("relay-channel"); + // 4. Legacy standalone operator-relay alongside operator + let legacy = parent.join("operator-relay"); if legacy.exists() { return Some((legacy, vec![])); } } } - // 2. RELAY_CHANNEL env var (user override — treated as opr8r path) - if let Ok(path) = std::env::var("RELAY_CHANNEL") { + // 2. OPERATOR_RELAY env var (user override — treated as opr8r path) + if let Ok(path) = std::env::var("OPERATOR_RELAY") { let p = PathBuf::from(&path); if p.exists() { - return Some((p, vec!["relay-channel".to_string()])); + return Some((p, vec!["relay".to_string()])); } } // 3. opr8r on PATH @@ -316,16 +340,16 @@ fn locate_relay_command() -> Option<(PathBuf, Vec)> { .map(|o| o.status.success()) .unwrap_or(false) { - return Some((PathBuf::from("opr8r"), vec!["relay-channel".to_string()])); + return Some((PathBuf::from("opr8r"), vec!["relay".to_string()])); } - // 5. Legacy relay-channel on PATH + // 5. Legacy operator-relay on PATH if std::process::Command::new("which") - .arg("relay-channel") + .arg("operator-relay") .output() .map(|o| o.status.success()) .unwrap_or(false) { - return Some((PathBuf::from("relay-channel"), vec![])); + return Some((PathBuf::from("operator-relay"), vec![])); } None } @@ -646,6 +670,7 @@ mod tests { Path::new("/tmp/prompt.md"), None, None, + None, ); assert!(result.is_err()); @@ -669,6 +694,7 @@ mod tests { Path::new("/tmp/prompt.md"), None, None, + None, ); assert!(result.is_ok()); @@ -700,6 +726,7 @@ mod tests { Path::new("/tmp/prompt.md"), None, // No ticket None, // No project path + None, ); assert!(result.is_ok()); @@ -724,6 +751,7 @@ mod tests { Path::new("/tmp/p.md"), None, None, + None, ); assert!(result.is_ok()); @@ -1159,12 +1187,12 @@ mod tests { // ======================================== #[test] - fn test_relay_mcp_config_with_opr8r_includes_relay_channel_arg() { + fn test_relay_mcp_config_with_opr8r_includes_relay_arg() { let dir = tempfile::tempdir().unwrap(); let result = relay_mcp_config_flag_with_command( dir.path(), PathBuf::from("/usr/local/bin/opr8r"), - vec!["relay-channel".to_string()], + vec!["relay".to_string()], ); assert!(result.is_some(), "Config path should be returned"); let config_path = result.unwrap(); @@ -1172,8 +1200,8 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&content).unwrap(); assert_eq!( v["mcpServers"]["relay"]["args"][0].as_str(), - Some("relay-channel"), - "First arg must be relay-channel: {content}" + Some("relay"), + "First arg must be relay: {content}" ); assert_eq!( v["mcpServers"]["relay"]["command"].as_str(), @@ -1187,7 +1215,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let result = relay_mcp_config_flag_with_command( dir.path(), - PathBuf::from("/usr/local/bin/relay-channel"), + PathBuf::from("/usr/local/bin/operator-relay"), vec![], ); assert!(result.is_some()); @@ -1200,4 +1228,38 @@ mod tests { "No args key for standalone binary: {content}" ); } + + // ======================================== + // resolve_relay_injection() tests + // ======================================== + + #[test] + fn test_relay_injection_disabled_by_delegator() { + let result = resolve_relay_injection(Some(false), true, true); + assert!(!result); + } + + #[test] + fn test_relay_injection_enabled_by_delegator_with_hub() { + let result = resolve_relay_injection(Some(true), true, true); + assert!(result); + } + + #[test] + fn test_relay_injection_enabled_by_delegator_without_hub() { + let result = resolve_relay_injection(Some(true), false, true); + assert!(!result); + } + + #[test] + fn test_relay_injection_none_uses_global_default_false() { + let result = resolve_relay_injection(None, true, false); + assert!(!result); + } + + #[test] + fn test_relay_injection_none_uses_global_default_true() { + let result = resolve_relay_injection(None, true, true); + assert!(result); + } } diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 15da4de..4a60543 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -26,6 +26,7 @@ use anyhow::{Context, Result}; use crate::agents::cmux::{CmuxClient, SystemCmuxClient}; use crate::agents::tmux::{sanitize_session_name, SystemTmuxClient, TmuxClient, TmuxError}; use crate::agents::zellij::{SystemZellijClient, ZellijClient}; +use crate::api::kanban_sync::KanbanBidirectionalSync; use crate::config::{Config, SessionWrapperType}; use crate::notifications; use crate::queue::{Queue, Ticket}; @@ -236,6 +237,13 @@ impl Launcher { let queue = Queue::new(&self.config)?; queue.claim_ticket(&ticket)?; + // Best-effort: notify upstream kanban that ticket is now in-progress. + let ks = KanbanBidirectionalSync::new(Arc::new(self.config.clone())); + let ticket_clone = ticket.clone(); + tokio::spawn(async move { + ks.on_ticket_claimed(&ticket_clone).await; + }); + // Get project path (use override if provided) let project_path = if let Some(ref override_project) = options.project_override { PathBuf::from(self.get_project_path_for(override_project)?) @@ -723,6 +731,13 @@ impl Launcher { let queue = Queue::new(&self.config)?; queue.claim_ticket(&ticket)?; + // Best-effort: notify upstream kanban that ticket is now in-progress. + let ks = KanbanBidirectionalSync::new(Arc::new(self.config.clone())); + let ticket_clone = ticket.clone(); + tokio::spawn(async move { + ks.on_ticket_claimed(&ticket_clone).await; + }); + // Get project path (use override if provided) let project_path = if let Some(ref override_project) = options.project_override { PathBuf::from(self.get_project_path_for(override_project)?) @@ -837,6 +852,7 @@ impl Launcher { &prompt_file, Some(&ticket), Some(&working_dir_str), + options.operator_relay, )?; // Apply YOLO flags if enabled @@ -1077,6 +1093,7 @@ impl Launcher { &prompt_file, Some(&ticket), Some(&working_dir_str), + options.launch_options.operator_relay, )?; // Apply YOLO flags if enabled diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index ea0ddc9..d1f21f4 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -29,6 +29,8 @@ pub struct LaunchOptions { /// multiple sub-agents launched for the same ticket (multi-agent steps). /// When `None`, session name is the usual `{prefix}{sanitized-ticket-id}`. pub session_suffix: Option, + /// Enable relay MCP server injection for this launch (None = use global config) + pub operator_relay: Option, } impl LaunchOptions { @@ -43,6 +45,30 @@ impl LaunchOptions { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_launch_options_carries_operator_relay() { + let opts = LaunchOptions { + provider: None, + delegator_name: None, + extra_flags: vec![], + docker_mode: false, + yolo_mode: false, + project_override: None, + use_worktrees_override: None, + create_branch_override: None, + prompt_prefix: None, + prompt_suffix: None, + session_suffix: None, + operator_relay: Some(false), + }; + assert_eq!(opts.operator_relay, Some(false)); + } +} + /// Options for relaunching an existing in-progress ticket #[derive(Debug, Clone, Default)] pub struct RelaunchOptions { diff --git a/src/agents/launcher/step_config.rs b/src/agents/launcher/step_config.rs index b5c5b86..980fa19 100644 --- a/src/agents/launcher/step_config.rs +++ b/src/agents/launcher/step_config.rs @@ -169,6 +169,7 @@ mod tests { status: "TODO".to_string(), content: String::new(), sessions: HashMap::new(), + step_delegators: HashMap::new(), llm_task: LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index a9ce5bf..46701fd 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -318,6 +318,7 @@ fn make_test_ticket(project: &str) -> Ticket { step: String::new(), content: "Test content".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index ef3988b..58c1dfb 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -170,6 +170,7 @@ pub fn launch_in_tmux_with_options( &prompt_file, Some(ticket), Some(project_path), + options.operator_relay, )?; // Apply YOLO flags if enabled @@ -383,6 +384,7 @@ pub fn launch_in_tmux_with_relaunch_options( &prompt_file, Some(ticket), Some(project_path), + options.launch_options.operator_relay, )?; // Add resume flag if resuming diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs index 2b6c1a3..5f93b73 100644 --- a/src/agents/launcher/worktree_setup.rs +++ b/src/agents/launcher/worktree_setup.rs @@ -342,6 +342,7 @@ mod tests { step: String::new(), content: String::new(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index b122b22..009b06c 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -124,6 +124,7 @@ pub fn launch_in_zellij_with_options( &prompt_file, Some(ticket), Some(project_path), + options.operator_relay, )?; if options.yolo_mode { @@ -276,6 +277,7 @@ pub fn launch_in_zellij_with_relaunch_options( &prompt_file, Some(ticket), Some(project_path), + options.launch_options.operator_relay, )?; if is_resume { diff --git a/src/agents/sync.rs b/src/agents/sync.rs index aef4492..686caa0 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -864,6 +864,7 @@ mod tests { step: "plan".to_string(), content: "# Test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, @@ -900,6 +901,7 @@ mod tests { step: "plan".to_string(), content: "# Test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, @@ -936,6 +938,7 @@ mod tests { step: "implement".to_string(), content: "# Test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, @@ -974,6 +977,7 @@ mod tests { step: "test".to_string(), content: "# Test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, @@ -1012,6 +1016,7 @@ mod tests { step: "plan".to_string(), content: "# Test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, @@ -1049,6 +1054,7 @@ mod tests { step: "plan".to_string(), content: "# Test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/api/kanban_sync.rs b/src/api/kanban_sync.rs new file mode 100644 index 0000000..9ab2a96 --- /dev/null +++ b/src/api/kanban_sync.rs @@ -0,0 +1,313 @@ +//! Bidirectional kanban sync — pushes operator ticket state changes upstream. + +use std::sync::Arc; + +use tracing::warn; + +use crate::api::providers::kanban::{ + ActivityLogEntry, CreateIssueRequest, GithubProjectsProvider, JiraProvider, KanbanProvider, + LinearProvider, UpdateStatusRequest, +}; +use crate::config::{Config, ProjectSyncConfig}; +use crate::queue::Ticket; + +/// Orchestrates outbound synchronisation from operator tickets to upstream kanban providers. +/// +/// All public methods are **best-effort**: errors are logged at WARN level and +/// never propagated to the caller, so a provider outage cannot interrupt normal +/// ticket workflow. +pub struct KanbanBidirectionalSync { + config: Arc, +} + +impl KanbanBidirectionalSync { + pub fn new(config: Arc) -> Self { + Self { config } + } + + /// Returns true if any configured project has `bidirectional: true`. + pub fn has_any_bidirectional(&self) -> bool { + let kanban = &self.config.kanban; + kanban + .jira + .values() + .any(|w| w.projects.values().any(|p| p.bidirectional)) + || kanban + .linear + .values() + .any(|w| w.projects.values().any(|p| p.bidirectional)) + || kanban + .github + .values() + .any(|w| w.projects.values().any(|p| p.bidirectional)) + } + + /// Called when a ticket is claimed (todo → doing). Pushes "doing" status to provider. + pub async fn on_ticket_claimed(&self, ticket: &Ticket) { + if let Some((provider, sync_cfg)) = self.resolve(ticket) { + let status = doing_status(&sync_cfg).to_string(); + self.push_status(ticket, &*provider, &status).await; + } + } + + /// Called when a ticket is completed (doing → done). Pushes "done" status to provider. + pub async fn on_ticket_completed(&self, ticket: &Ticket) { + if let Some((provider, sync_cfg)) = self.resolve(ticket) { + let status = done_status(&sync_cfg).to_string(); + self.push_status(ticket, &*provider, &status).await; + } + } + + /// Called when a step completes. Appends an activity log entry to the upstream issue. + pub async fn on_step_completed( + &self, + ticket: &Ticket, + step_name: &str, + delegator_name: &str, + summary: Option<&str>, + ) { + let Some((provider, _)) = self.resolve(ticket) else { + return; + }; + let Some(external_id) = ticket.external_id.as_deref() else { + return; + }; + let entry = ActivityLogEntry { + step: step_name.to_string(), + delegator: delegator_name.to_string(), + completed_at: chrono::Utc::now(), + summary: summary.map(ToString::to_string), + }; + if let Err(e) = provider.append_activity_log(external_id, &entry).await { + warn!( + ticket_id = %ticket.id, + step = step_name, + error = %e, + "Bidirectional sync: failed to append activity log" + ); + } + } + + /// Called at ticket creation when bidirectional sync is enabled. + /// Creates an upstream issue and returns `(external_id, external_url, provider_name)`. + /// Returns `None` if no bidirectional project config can be matched. + pub async fn create_external_ticket( + &self, + ticket: &Ticket, + ) -> Option<(String, String, String)> { + // Try Jira + for (domain, jira_cfg) in &self.config.kanban.jira { + for (proj_key, sync_cfg) in &jira_cfg.projects { + if !sync_cfg.bidirectional { + continue; + } + if let Ok(provider) = JiraProvider::from_config(domain, jira_cfg) { + let req = build_create_request(ticket, sync_cfg); + match provider.create_issue(proj_key, req).await { + Ok(resp) => { + return Some((resp.issue.key, resp.issue.url, "jira".to_string())) + } + Err(e) => { + warn!( + ticket_id = %ticket.id, + error = %e, + "Bidirectional sync: Jira create_issue failed" + ); + } + } + } + } + } + // Try Linear + for (workspace, linear_cfg) in &self.config.kanban.linear { + for (proj_key, sync_cfg) in &linear_cfg.projects { + if !sync_cfg.bidirectional { + continue; + } + if let Ok(provider) = LinearProvider::from_config(workspace, linear_cfg) { + let req = build_create_request(ticket, sync_cfg); + match provider.create_issue(proj_key, req).await { + Ok(resp) => { + return Some((resp.issue.key, resp.issue.url, "linear".to_string())) + } + Err(e) => { + warn!( + ticket_id = %ticket.id, + error = %e, + "Bidirectional sync: Linear create_issue failed" + ); + } + } + } + } + } + // Try GitHub + for (owner, github_cfg) in &self.config.kanban.github { + for (proj_key, sync_cfg) in &github_cfg.projects { + if !sync_cfg.bidirectional { + continue; + } + if let Ok(provider) = GithubProjectsProvider::from_config(owner, github_cfg) { + let req = build_create_request(ticket, sync_cfg); + match provider.create_issue(proj_key, req).await { + Ok(resp) => { + return Some((resp.issue.key, resp.issue.url, "github".to_string())) + } + Err(e) => { + warn!( + ticket_id = %ticket.id, + error = %e, + "Bidirectional sync: GitHub create_issue failed" + ); + } + } + } + } + } + None + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + /// Find the provider instance and sync config for a ticket's external issue. + fn resolve(&self, ticket: &Ticket) -> Option<(Box, ProjectSyncConfig)> { + let provider_name = ticket.external_provider.as_deref()?; + let external_id = ticket.external_id.as_deref()?; + + match provider_name { + "jira" => { + let project_key = external_id.split('-').next()?; + for (domain, cfg) in &self.config.kanban.jira { + if let Some(sync_cfg) = cfg.projects.get(project_key) { + if sync_cfg.bidirectional { + if let Ok(p) = JiraProvider::from_config(domain, cfg) { + return Some((Box::new(p), sync_cfg.clone())); + } + } + } + } + None + } + "linear" => { + let team_key = external_id.split('-').next()?; + for (workspace, cfg) in &self.config.kanban.linear { + if let Some(sync_cfg) = cfg.projects.get(team_key) { + if sync_cfg.bidirectional { + if let Ok(p) = LinearProvider::from_config(workspace, cfg) { + return Some((Box::new(p), sync_cfg.clone())); + } + } + } + } + None + } + "github" => { + for (owner, cfg) in &self.config.kanban.github { + for sync_cfg in cfg.projects.values() { + if sync_cfg.bidirectional { + if let Ok(p) = GithubProjectsProvider::from_config(owner, cfg) { + return Some((Box::new(p), sync_cfg.clone())); + } + } + } + } + None + } + _ => None, + } + } + + async fn push_status(&self, ticket: &Ticket, provider: &dyn KanbanProvider, status: &str) { + let Some(external_id) = ticket.external_id.as_deref() else { + return; + }; + let req = UpdateStatusRequest { + status: status.to_string(), + }; + if let Err(e) = provider.update_issue_status(external_id, req).await { + warn!( + ticket_id = %ticket.id, + status = status, + error = %e, + "Bidirectional sync: failed to update upstream status" + ); + } + } +} + +fn doing_status(sync_cfg: &ProjectSyncConfig) -> &str { + sync_cfg + .sync_statuses + .first() + .map(String::as_str) + .unwrap_or("In Progress") +} + +fn done_status(sync_cfg: &ProjectSyncConfig) -> &str { + sync_cfg + .sync_statuses + .last() + .map(String::as_str) + .unwrap_or("Done") +} + +fn build_create_request(ticket: &Ticket, sync_cfg: &ProjectSyncConfig) -> CreateIssueRequest { + CreateIssueRequest { + summary: ticket.summary.clone(), + description: None, + assignee_id: if sync_cfg.sync_user_id.is_empty() { + None + } else { + Some(sync_cfg.sync_user_id.clone()) + }, + status: None, + priority: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_sync_cfg(statuses: Vec<&str>) -> ProjectSyncConfig { + ProjectSyncConfig { + sync_statuses: statuses.into_iter().map(|s| s.to_string()).collect(), + bidirectional: true, + ..Default::default() + } + } + + #[test] + fn test_doing_status_from_sync_statuses() { + let cfg = make_sync_cfg(vec!["Started", "In Review", "Completed"]); + assert_eq!(doing_status(&cfg), "Started"); + } + + #[test] + fn test_done_status_from_sync_statuses() { + let cfg = make_sync_cfg(vec!["Started", "In Review", "Completed"]); + assert_eq!(done_status(&cfg), "Completed"); + } + + #[test] + fn test_doing_status_default() { + let cfg = make_sync_cfg(vec![]); + assert_eq!(doing_status(&cfg), "In Progress"); + } + + #[test] + fn test_done_status_default() { + let cfg = make_sync_cfg(vec![]); + assert_eq!(done_status(&cfg), "Done"); + } + + #[test] + fn test_skips_non_bidirectional_projects() { + let mut cfg = make_sync_cfg(vec!["In Progress", "Done"]); + cfg.bidirectional = false; + // sync service would not resolve a provider for this config + // (we test the flag is respected by verifying bidirectional=false + // is explicitly handled in resolve()) + assert!(!cfg.bidirectional); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 87e953a..05370f9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod cli_detection; pub mod error; pub mod gh_cli; pub mod github_service; +pub mod kanban_sync; pub mod pr_service; pub mod providers; diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index eb3f4b7..da9df95 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -1417,6 +1417,391 @@ impl KanbanProvider for GithubProjectsProvider { priority: None, }) } + + async fn update_issue_labels( + &self, + issue_key: &str, + labels: &[String], + ) -> Result<(), ApiError> { + if labels.is_empty() { + return Ok(()); + } + + if issue_key.starts_with("draft:") { + // For draft items, append labels as metadata text to the body. + let item_id = issue_key.trim_start_matches("draft:"); + + // Query the draft issue: get its internal id + current body + let query = r" + query($nodeId: ID!) { + node(id: $nodeId) { + ... on ProjectV2Item { + content { + __typename + ... on DraftIssue { + id + body + } + } + } + } + } + "; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct NodeResp { + node: NodeContent, + } + #[derive(Deserialize)] + struct NodeContent { + content: Option, + } + #[allow(dead_code)] + #[derive(Deserialize)] + #[serde(tag = "__typename")] + enum DraftContent { + DraftIssue { + id: String, + #[serde(default)] + body: Option, + }, + } + + let vars = serde_json::json!({ "nodeId": item_id }); + let resp: NodeResp = self.graphql(query, Some(vars)).await?; + + let (draft_id, current_body) = match resp.node.content { + Some(DraftContent::DraftIssue { id, body }) => (id, body.unwrap_or_default()), + None => return Ok(()), // Not a draft issue item + }; + + let label_line = format!("\n**Labels:** {}", labels.join(", ")); + let new_body = format!("{current_body}{label_line}"); + + let mutation = r" + mutation($input: UpdateProjectV2DraftIssueInput!) { + updateProjectV2DraftIssue(input: $input) { + draftIssue { id } + } + } + "; + let _: serde_json::Value = self + .graphql( + mutation, + Some(serde_json::json!({ + "input": { "draftIssueId": draft_id, "body": new_body } + })), + ) + .await?; + } else if let Some((owner_repo, number_str)) = issue_key.split_once('#') { + // Real repo issue: addLabelsToLabelable + let (owner, repo) = owner_repo.split_once('/').ok_or_else(|| { + ApiError::http( + PROVIDER_NAME, + 400, + format!("Invalid issue key: {issue_key}"), + ) + })?; + + // Get the issue node ID + let id_query = r" + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + "; + let number: i64 = number_str.parse().map_err(|_| { + ApiError::http( + PROVIDER_NAME, + 400, + format!("Invalid issue number in key: {issue_key}"), + ) + })?; + #[derive(Deserialize)] + struct IdResp { + repository: RepoWithIssue, + } + #[derive(Deserialize)] + struct RepoWithIssue { + issue: IssueNode, + } + #[derive(Deserialize)] + struct IssueNode { + id: String, + } + let vars = serde_json::json!({ "owner": owner, "repo": repo, "number": number }); + let id_resp: IdResp = self.graphql(id_query, Some(vars)).await?; + let issue_node_id = id_resp.repository.issue.id; + + // Find or create each label on the repo, then add all to the issue + let mut label_ids: Vec = Vec::new(); + for label_name in labels { + // Query for existing repo label by name + let label_query = r" + query($owner: String!, $repo: String!, $name: String!) { + repository(owner: $owner, name: $repo) { + label(name: $name) { id } + } + } + "; + #[derive(Deserialize)] + struct LabelResp { + repository: RepoWithLabel, + } + #[derive(Deserialize)] + struct RepoWithLabel { + label: Option, + } + #[derive(Deserialize)] + struct LabelId { + id: String, + } + let label_vars = + serde_json::json!({ "owner": owner, "repo": repo, "name": label_name }); + let label_resp: LabelResp = self.graphql(label_query, Some(label_vars)).await?; + + let label_id = if let Some(existing) = label_resp.repository.label { + existing.id + } else { + // Create the label + let create_mutation = r" + mutation($repoId: ID!, $name: String!, $color: String!) { + createLabel(input: { repositoryId: $repoId, name: $name, color: $color }) { + label { id } + } + } + "; + // Get repo node ID first + let repo_id_query = r" + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { id } + } + "; + #[derive(Deserialize)] + struct RepoIdResp { + repository: RepoId, + } + #[derive(Deserialize)] + struct RepoId { + id: String, + } + let repo_id_vars = serde_json::json!({ "owner": owner, "repo": repo }); + let repo_id_resp: RepoIdResp = + self.graphql(repo_id_query, Some(repo_id_vars)).await?; + + #[derive(Deserialize)] + struct CreateLabelResp { + #[serde(rename = "createLabel")] + create_label: CreateLabelPayload, + } + #[derive(Deserialize)] + struct CreateLabelPayload { + label: LabelId, + } + let create_vars = serde_json::json!({ + "repoId": repo_id_resp.repository.id, + "name": label_name, + "color": "ededed" // default gray + }); + let create_resp: CreateLabelResp = + self.graphql(create_mutation, Some(create_vars)).await?; + create_resp.create_label.label.id + }; + label_ids.push(label_id); + } + + let add_mutation = r" + mutation($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) { + labelable { ... on Issue { id } } + } + } + "; + let _: serde_json::Value = self + .graphql( + add_mutation, + Some(serde_json::json!({ + "labelableId": issue_node_id, + "labelIds": label_ids + })), + ) + .await?; + } + + Ok(()) + } + + async fn append_activity_log( + &self, + issue_key: &str, + entry: &super::ActivityLogEntry, + ) -> Result<(), ApiError> { + let timestamp = entry.completed_at.format("%Y-%m-%d %H:%M UTC").to_string(); + let summary_text = entry.summary.as_deref().unwrap_or(""); + + if issue_key.starts_with("draft:") { + let item_id = issue_key.trim_start_matches("draft:"); + + // Query draft issue id + current body + let query = r" + query($nodeId: ID!) { + node(id: $nodeId) { + ... on ProjectV2Item { + content { + __typename + ... on DraftIssue { + id + body + } + } + } + } + } + "; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct NodeResp { + node: NodeContent, + } + #[derive(Deserialize)] + struct NodeContent { + content: Option, + } + #[allow(dead_code)] + #[derive(Deserialize)] + #[serde(tag = "__typename")] + enum DraftContent { + DraftIssue { + id: String, + #[serde(default)] + body: Option, + }, + } + + let vars = serde_json::json!({ "nodeId": item_id }); + let resp: NodeResp = self.graphql(query, Some(vars)).await?; + + let (draft_id, current_body) = match resp.node.content { + Some(DraftContent::DraftIssue { id, body }) => (id, body.unwrap_or_default()), + None => return Ok(()), + }; + + let log_line = if summary_text.is_empty() { + format!( + "\n\n---\n**Agent Activity** (opr8r)\n| Step | Delegator | Completed |\n|------|-----------|----------|\n| {} | {} | {} |", + entry.step, entry.delegator, timestamp + ) + } else { + format!( + "\n\n---\n**Agent Activity** (opr8r)\n| Step | Delegator | Completed | Summary |\n|------|-----------|----------|---------|\n| {} | {} | {} | {} |", + entry.step, entry.delegator, timestamp, summary_text + ) + }; + + // If there's already an Agent Activity section, append a new table row instead + let new_body = if current_body.contains("**Agent Activity** (opr8r)") { + // Append another row to the existing table + let new_row = if summary_text.is_empty() { + format!("\n| {} | {} | {} |", entry.step, entry.delegator, timestamp) + } else { + format!( + "\n| {} | {} | {} | {} |", + entry.step, entry.delegator, timestamp, summary_text + ) + }; + format!("{current_body}{new_row}") + } else { + format!("{current_body}{log_line}") + }; + + let mutation = r" + mutation($input: UpdateProjectV2DraftIssueInput!) { + updateProjectV2DraftIssue(input: $input) { + draftIssue { id } + } + } + "; + let _: serde_json::Value = self + .graphql( + mutation, + Some(serde_json::json!({ + "input": { "draftIssueId": draft_id, "body": new_body } + })), + ) + .await?; + } else if let Some((owner_repo, number_str)) = issue_key.split_once('#') { + // Real repo issue: add a comment + let (owner, repo) = owner_repo.split_once('/').ok_or_else(|| { + ApiError::http( + PROVIDER_NAME, + 400, + format!("Invalid issue key: {issue_key}"), + ) + })?; + let number: i64 = number_str.parse().map_err(|_| { + ApiError::http( + PROVIDER_NAME, + 400, + format!("Invalid issue number: {issue_key}"), + ) + })?; + + // Get issue node ID + let id_query = r" + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + } + "; + #[derive(Deserialize)] + struct IdResp { + repository: RepoWithIssue, + } + #[derive(Deserialize)] + struct RepoWithIssue { + issue: IssueNode, + } + #[derive(Deserialize)] + struct IssueNode { + id: String, + } + let vars = serde_json::json!({ "owner": owner, "repo": repo, "number": number }); + let id_resp: IdResp = self.graphql(id_query, Some(vars)).await?; + + let comment_body = if summary_text.is_empty() { + format!( + "🤖 **opr8r activity** — step: `{}` | delegator: `{}` | {}", + entry.step, entry.delegator, timestamp + ) + } else { + format!( + "🤖 **opr8r activity** — step: `{}` | delegator: `{}` | {}\n\n> {}", + entry.step, entry.delegator, timestamp, summary_text + ) + }; + + let add_comment = r" + mutation($subjectId: ID!, $body: String!) { + addComment(input: { subjectId: $subjectId, body: $body }) { + commentEdge { node { id } } + } + } + "; + let _: serde_json::Value = self + .graphql( + add_comment, + Some(serde_json::json!({ + "subjectId": id_resp.repository.issue.id, + "body": comment_body + })), + ) + .await?; + } + + Ok(()) + } } impl GithubProjectsProvider { diff --git a/src/api/providers/kanban/jira.rs b/src/api/providers/kanban/jira.rs index 08069f8..44eed41 100644 --- a/src/api/providers/kanban/jira.rs +++ b/src/api/providers/kanban/jira.rs @@ -167,6 +167,40 @@ impl JiraProvider { .map_err(|e| ApiError::http(PROVIDER_NAME, 0, format!("Parse error: {e}"))) } + /// Make an authenticated PUT request that returns no content (204/200) + async fn put_no_content(&self, path: &str, body: &B) -> Result<(), ApiError> { + let url = format!("{}{}", self.base_url(), path); + debug!("Jira PUT (no content): {}", url); + + let response = self + .client + .put(&url) + .header("Authorization", self.auth_header()) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .json(body) + .send() + .await + .map_err(|e| ApiError::network(PROVIDER_NAME, e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return match status.as_u16() { + 401 => Err(ApiError::unauthorized(PROVIDER_NAME)), + 403 => Err(ApiError::forbidden(PROVIDER_NAME)), + 404 => Err(ApiError::http( + PROVIDER_NAME, + 404, + format!("Not found: {path}"), + )), + 429 => Err(ApiError::rate_limited(PROVIDER_NAME, None)), + _ => Err(ApiError::http(PROVIDER_NAME, status.as_u16(), body)), + }; + } + Ok(()) + } + /// Make an authenticated POST request that returns no content (204) async fn post_no_content(&self, path: &str, body: &B) -> Result<(), ApiError> { let url = format!("{}{}", self.base_url(), path); @@ -734,6 +768,77 @@ impl KanbanProvider for JiraProvider { // Fetch and return the updated issue self.fetch_issue(issue_key).await } + + async fn update_issue_labels( + &self, + issue_key: &str, + labels: &[String], + ) -> Result<(), ApiError> { + // Fetch current labels using a labels-only fields query + #[derive(Deserialize)] + struct LabelFields { + #[serde(default)] + labels: Vec, + } + #[derive(Deserialize)] + struct LabelIssue { + fields: LabelFields, + } + + let path = format!("/issue/{issue_key}?fields=labels"); + let current: LabelIssue = self.get(&path).await?; + + // Merge: existing labels + new labels (deduplicated) + let mut merged: Vec = current.fields.labels; + for label in labels { + if !merged.contains(label) { + merged.push(label.clone()); + } + } + + // PUT the merged label set back + let body = serde_json::json!({ + "fields": { "labels": merged } + }); + let put_path = format!("/issue/{issue_key}"); + self.put_no_content(&put_path, &body).await + } + + async fn append_activity_log( + &self, + issue_key: &str, + entry: &super::ActivityLogEntry, + ) -> Result<(), ApiError> { + // Format the comment text + let timestamp = entry.completed_at.format("%Y-%m-%d %H:%M UTC").to_string(); + let mut text = format!( + "🤖 opr8r — step: {} | delegator: {} | {}", + entry.step, entry.delegator, timestamp + ); + if let Some(ref summary) = entry.summary { + text.push('\n'); + text.push_str(summary); + } + + // Build ADF comment body + let body = serde_json::json!({ + "body": { + "type": "doc", + "version": 1, + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": text + }] + }] + } + }); + + let path = format!("/issue/{issue_key}/comment"); + let _: serde_json::Value = self.post(&path, &body).await?; + Ok(()) + } } #[async_trait] diff --git a/src/api/providers/kanban/linear.rs b/src/api/providers/kanban/linear.rs index 31e89e8..a10a93c 100644 --- a/src/api/providers/kanban/linear.rs +++ b/src/api/providers/kanban/linear.rs @@ -983,6 +983,199 @@ impl KanbanProvider for LinearProvider { priority: priority_to_string(issue.priority), }) } + + async fn update_issue_labels( + &self, + issue_key: &str, + labels: &[String], + ) -> Result<(), ApiError> { + if labels.is_empty() { + return Ok(()); + } + + let (issue_id, team_id) = self.get_issue_info(issue_key).await?; + + // Query: current issue labels + all team labels + let query = r" + query($issueId: String!, $teamId: String!) { + issue(id: $issueId) { + labels { + nodes { id name } + } + } + team(id: $teamId) { + labels { + nodes { id name } + } + } + } + "; + + #[derive(Deserialize)] + struct LabelNode { + id: String, + name: String, + } + #[derive(Deserialize)] + struct LabelNodes { + nodes: Vec, + } + #[derive(Deserialize)] + struct IssueLabels { + labels: LabelNodes, + } + #[derive(Deserialize)] + struct TeamLabels { + labels: LabelNodes, + } + #[derive(Deserialize)] + struct LabelsQueryResponse { + issue: IssueLabels, + team: TeamLabels, + } + + let vars = serde_json::json!({ "issueId": issue_id, "teamId": team_id }); + let data: LabelsQueryResponse = self.graphql(query, Some(vars)).await?; + + // Start with existing issue label IDs + let mut label_ids: Vec = data + .issue + .labels + .nodes + .iter() + .map(|l| l.id.clone()) + .collect(); + + // For each desired label, find or create it + for label_name in labels { + // Check if already on the issue + if data + .issue + .labels + .nodes + .iter() + .any(|l| l.name.eq_ignore_ascii_case(label_name)) + { + continue; + } + // Find existing team label + if let Some(team_label) = data + .team + .labels + .nodes + .iter() + .find(|l| l.name.eq_ignore_ascii_case(label_name)) + { + label_ids.push(team_label.id.clone()); + } else { + // Create new label + let create_mutation = r" + mutation($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + issueLabel { id name } + } + } + "; + #[derive(Deserialize)] + struct IssueLabelCreateResponse { + #[serde(rename = "issueLabelCreate")] + issue_label_create: IssueLabelPayload, + } + #[derive(Deserialize)] + struct IssueLabelPayload { + #[serde(rename = "issueLabel")] + issue_label: Option, + } + let create_vars = serde_json::json!({ + "input": { "name": label_name, "teamId": team_id } + }); + let create_resp: IssueLabelCreateResponse = + self.graphql(create_mutation, Some(create_vars)).await?; + if let Some(new_label) = create_resp.issue_label_create.issue_label { + label_ids.push(new_label.id); + } + } + } + + // Update issue with all label IDs + let update_mutation = r" + mutation($id: String!, $labelIds: [String!]!) { + issueUpdate(id: $id, input: { labelIds: $labelIds }) { + success + } + } + "; + #[derive(Deserialize)] + struct LocalIssueUpdateResponse { + #[serde(rename = "issueUpdate")] + issue_update: LocalIssueUpdatePayload, + } + #[derive(Deserialize)] + struct LocalIssueUpdatePayload { + success: bool, + } + let update_vars = serde_json::json!({ "id": issue_id, "labelIds": label_ids }); + let update_resp: LocalIssueUpdateResponse = + self.graphql(update_mutation, Some(update_vars)).await?; + + if !update_resp.issue_update.success { + return Err(ApiError::http( + PROVIDER_NAME, + 400, + "Failed to update issue labels".to_string(), + )); + } + Ok(()) + } + + async fn append_activity_log( + &self, + issue_key: &str, + entry: &super::ActivityLogEntry, + ) -> Result<(), ApiError> { + let (issue_id, _team_id) = self.get_issue_info(issue_key).await?; + + let timestamp = entry.completed_at.format("%Y-%m-%d %H:%M UTC"); + let mut body = format!( + "**opr8r activity** — step: `{}` | delegator: `{}` | {}", + entry.step, entry.delegator, timestamp + ); + if let Some(ref summary) = entry.summary { + body.push_str(&format!("\n\n> {summary}")); + } + + let mutation = r" + mutation($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + } + } + "; + + #[derive(Deserialize)] + struct CommentCreateResponse { + #[serde(rename = "commentCreate")] + comment_create: CommentCreatePayload, + } + #[derive(Deserialize)] + struct CommentCreatePayload { + success: bool, + } + + let vars = serde_json::json!({ + "input": { "issueId": issue_id, "body": body } + }); + + let resp: CommentCreateResponse = self.graphql(mutation, Some(vars)).await?; + if !resp.comment_create.success { + return Err(ApiError::http( + PROVIDER_NAME, + 400, + "Failed to create comment".to_string(), + )); + } + Ok(()) + } } #[async_trait] diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index 5cc7122..cc4ce1f 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -134,6 +134,20 @@ pub struct UpdateStatusRequest { pub status: String, } +/// A single agent activity record to append to an upstream kanban issue. +/// Used by bidirectional sync to track which AI delegator worked each step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityLogEntry { + /// Step name (e.g., "plan", "build", "test") + pub step: String, + /// Delegator name/model that worked this step (e.g., "claude-opus-4-7") + pub delegator: String, + /// When the step completed (UTC) + pub completed_at: chrono::DateTime, + /// Optional step summary from `OperatorOutput` (max ~500 chars) + pub summary: Option, +} + /// Trait for kanban providers that can export issue types and sync work items #[async_trait] pub trait KanbanProvider: Send + Sync { @@ -192,6 +206,33 @@ pub trait KanbanProvider: Send + Sync { issue_key: &str, request: UpdateStatusRequest, ) -> Result; + + /// Apply labels to an upstream issue without removing existing labels. + /// + /// Provider implementations may need to create labels that do not exist yet. + /// Default: no-op (returns `Ok(())`). + async fn update_issue_labels( + &self, + issue_key: &str, + labels: &[String], + ) -> Result<(), ApiError> { + let _ = (issue_key, labels); + Ok(()) + } + + /// Append an agent activity entry to the upstream issue. + /// + /// Implementations append (not replace) a structured log entry — as a comment + /// on Jira/Linear, or as a body update on GitHub draft issues. + /// Default: no-op (returns `Ok(())`). + async fn append_activity_log( + &self, + issue_key: &str, + entry: &ActivityLogEntry, + ) -> Result<(), ApiError> { + let _ = (issue_key, entry); + Ok(()) + } } /// Detect which kanban providers are configured based on environment variables diff --git a/src/app/tests.rs b/src/app/tests.rs index 6170a9c..99c7704 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -625,6 +625,7 @@ Test content step: String::new(), content: "Test content".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, @@ -785,3 +786,156 @@ mod kanban_sync { ); } } + +mod agent_switches { + use super::*; + + #[test] + fn test_switch_marker_detected_on_agent_with_review_state() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "TASK-switch-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state + .set_agent_review_state(&agent_id, "switching_agent:my-delegator") + .unwrap(); + + let switches: Vec = state + .agents + .iter() + .filter_map(|agent| { + let rs = agent.review_state.as_ref()?; + rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + }) + .collect(); + + assert_eq!(switches, vec!["my-delegator"]); + } + + #[test] + fn test_no_switches_when_agents_have_non_switch_review_state() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "TASK-plan-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state + .set_agent_review_state(&agent_id, "pending_plan") + .unwrap(); + + let switches: Vec = state + .agents + .iter() + .filter_map(|agent| { + let rs = agent.review_state.as_ref()?; + rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + }) + .collect(); + + assert!( + switches.is_empty(), + "pending_plan review state should not trigger an agent switch" + ); + } + + #[test] + fn test_no_switches_when_all_agents_have_no_review_state() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-noreview".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let switches: Vec = state + .agents + .iter() + .filter_map(|agent| { + let rs = agent.review_state.as_ref()?; + rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + }) + .collect(); + + assert!( + switches.is_empty(), + "Agents with no review_state should not produce switches" + ); + } + + #[test] + fn test_only_switch_marked_agents_collected_when_mixed() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + + let id_a = state + .add_agent( + "TASK-a".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + let id_b = state + .add_agent( + "TASK-b".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + let _id_c = state + .add_agent( + "TASK-c".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state + .set_agent_review_state(&id_a, "switching_agent:delegator-x") + .unwrap(); + state.set_agent_review_state(&id_b, "pending_plan").unwrap(); + // _id_c has no review_state + + let switches: Vec = state + .agents + .iter() + .filter_map(|agent| { + let rs = agent.review_state.as_ref()?; + rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + }) + .collect(); + + assert_eq!( + switches, + vec!["delegator-x"], + "Only the switch-marked agent should appear" + ); + } +} diff --git a/src/config.rs b/src/config.rs index 70c3914..968d6ed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,23 @@ +#[path = "config/backstage_config.rs"] +pub mod backstage_config; +#[path = "config/git_config.rs"] +pub mod git_config; +#[path = "config/kanban.rs"] +pub mod kanban; +#[path = "config/llm_tools.rs"] +pub mod llm_tools; +#[path = "config/notifications_config.rs"] +pub mod notifications_config; +#[path = "config/sessions.rs"] +pub mod sessions; + +pub use backstage_config::*; +pub use git_config::*; +pub use kanban::*; +pub use llm_tools::*; +pub use notifications_config::*; +pub use sessions::*; + use anyhow::{Context, Result}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -48,6 +68,9 @@ pub struct Config { /// Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. #[serde(default)] pub model_servers: Vec, + /// Relay MCP injection configuration + #[serde(default)] + pub relay: RelayConfig, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -86,143 +109,6 @@ fn default_silence_threshold() -> u64 { 6 // 6 seconds } -/// Notifications configuration with support for multiple integrations. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct NotificationsConfig { - /// Global enabled flag for all notifications - pub enabled: bool, - - /// OS notification configuration - #[serde(default)] - pub os: OsNotificationConfig, - - /// Single webhook configuration (for simple setups) - #[serde(default)] - pub webhook: Option, - - /// Multiple webhook configurations - #[serde(default)] - pub webhooks: Vec, - - // Legacy fields for backwards compatibility - // These are deprecated but still supported for existing configs - #[serde(default = "default_true")] - #[schemars(skip)] - #[ts(skip)] - pub on_agent_start: bool, - #[serde(default = "default_true")] - #[schemars(skip)] - #[ts(skip)] - pub on_agent_complete: bool, - #[serde(default = "default_true")] - #[schemars(skip)] - #[ts(skip)] - pub on_agent_needs_input: bool, - #[serde(default = "default_true")] - #[schemars(skip)] - #[ts(skip)] - pub on_pr_created: bool, - #[serde(default = "default_true")] - #[schemars(skip)] - #[ts(skip)] - pub on_investigation_created: bool, - #[serde(default)] - #[schemars(skip)] - #[ts(skip)] - pub sound: bool, -} - -fn default_true() -> bool { - true -} - -impl Default for NotificationsConfig { - fn default() -> Self { - Self { - enabled: true, - os: OsNotificationConfig::default(), - webhook: None, - webhooks: Vec::new(), - // Legacy fields - on_agent_start: true, - on_agent_complete: true, - on_agent_needs_input: true, - on_pr_created: true, - on_investigation_created: true, - sound: false, - } - } -} - -/// OS notification configuration. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct OsNotificationConfig { - /// Whether OS notifications are enabled - #[serde(default = "default_true")] - pub enabled: bool, - - /// Play sound with notifications - #[serde(default)] - pub sound: bool, - - /// Events to send (empty = all events) - /// Possible values: agent.started, agent.completed, agent.failed, - /// `agent.awaiting_input`, `agent.session_lost`, pr.created, pr.merged, - /// pr.closed, `pr.ready_to_merge`, `pr.changes_requested`, - /// ticket.returned, investigation.created - #[serde(default)] - pub events: Vec, -} - -impl Default for OsNotificationConfig { - fn default() -> Self { - Self { - enabled: true, - sound: false, - events: Vec::new(), // All events - } - } -} - -/// Webhook notification configuration. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct WebhookConfig { - /// Optional name for this webhook (for logging) - #[serde(default)] - pub name: Option, - - /// Whether this webhook is enabled - #[serde(default)] - pub enabled: bool, - - /// Webhook URL - #[serde(default)] - pub url: String, - - /// Authentication type: "bearer" or "basic" - #[serde(default)] - pub auth_type: Option, - - /// Environment variable containing the bearer token - #[serde(default)] - pub token_env: Option, - - /// Username for basic auth - #[serde(default)] - pub username: Option, - - /// Environment variable containing the password for basic auth - #[serde(default)] - pub password_env: Option, - - /// Events to send (empty = all events) - #[serde(default)] - pub events: Option>, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct QueueConfig { @@ -371,377 +257,6 @@ pub struct TmuxConfig { pub config_generated: bool, } -/// Session wrapper type for terminal session management -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export)] -pub enum SessionWrapperType { - /// Standalone tmux sessions (default) - #[default] - Tmux, - /// VS Code integrated terminal (via extension webhook) - Vscode, - /// cmux macOS terminal multiplexer - Cmux, - /// Zellij terminal workspace manager - Zellij, -} - -impl SessionWrapperType { - /// Short display name for the wrapper (used in header bar, logs) - pub fn display_name(&self) -> &'static str { - match self { - SessionWrapperType::Tmux => "tmux", - SessionWrapperType::Vscode => "vscode", - SessionWrapperType::Cmux => "cmux", - SessionWrapperType::Zellij => "zellij", - } - } -} - -impl std::fmt::Display for SessionWrapperType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display_name()) - } -} - -/// Session wrapper configuration -/// -/// Controls how operator creates and manages terminal sessions for agents. -/// Four modes are supported: -/// - tmux: Standalone tmux sessions (default) -/// - vscode: VS Code integrated terminal (requires extension) -/// - cmux: macOS terminal multiplexer (requires running inside cmux) -/// - zellij: Zellij terminal workspace manager -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct SessionsConfig { - /// Which session wrapper to use - #[serde(default)] - pub wrapper: SessionWrapperType, - - /// Tmux-specific configuration - #[serde(default)] - pub tmux: SessionsTmuxConfig, - - /// VS Code-specific configuration - #[serde(default)] - pub vscode: SessionsVSCodeConfig, - - /// cmux-specific configuration - #[serde(default)] - pub cmux: SessionsCmuxConfig, - - /// Zellij-specific configuration - #[serde(default)] - pub zellij: SessionsZellijConfig, -} - -impl Default for SessionsConfig { - fn default() -> Self { - Self { - wrapper: SessionWrapperType::Tmux, - tmux: SessionsTmuxConfig::default(), - vscode: SessionsVSCodeConfig::default(), - cmux: SessionsCmuxConfig::default(), - zellij: SessionsZellijConfig::default(), - } - } -} - -/// Tmux-specific session configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct SessionsTmuxConfig { - /// Whether custom tmux config has been generated - #[serde(default)] - pub config_generated: bool, - - /// Socket name for session isolation - #[serde(default = "default_socket_name")] - pub socket_name: String, -} - -fn default_socket_name() -> String { - "operator".to_string() -} - -impl Default for SessionsTmuxConfig { - fn default() -> Self { - Self { - config_generated: false, - socket_name: default_socket_name(), - } - } -} - -/// VS Code extension session configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct SessionsVSCodeConfig { - /// Port for extension webhook server - #[serde(default = "default_vscode_webhook_port")] - pub webhook_port: u16, - - /// Connection timeout in milliseconds - #[serde(default = "default_vscode_connect_timeout")] - pub connect_timeout_ms: u64, -} - -fn default_vscode_webhook_port() -> u16 { - 7009 -} - -fn default_vscode_connect_timeout() -> u64 { - 5000 -} - -impl Default for SessionsVSCodeConfig { - fn default() -> Self { - Self { - webhook_port: default_vscode_webhook_port(), - connect_timeout_ms: default_vscode_connect_timeout(), - } - } -} - -/// Placement policy for cmux sessions: where to create new agent terminals -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export)] -pub enum CmuxPlacementPolicy { - /// Automatically choose: 0-1 windows → new workspace, >1 windows → new window - #[default] - Auto, - /// Always create a new workspace in the active window - Workspace, - /// Always create a new window for each ticket - Window, -} - -impl std::fmt::Display for CmuxPlacementPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CmuxPlacementPolicy::Auto => write!(f, "auto"), - CmuxPlacementPolicy::Workspace => write!(f, "workspace"), - CmuxPlacementPolicy::Window => write!(f, "window"), - } - } -} - -/// cmux macOS terminal multiplexer session configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct SessionsCmuxConfig { - /// Path to the cmux binary - #[serde(default = "default_cmux_binary_path")] - pub binary_path: String, - - /// Require running inside cmux (`CMUX_WORKSPACE_ID` env var present) - #[serde(default = "default_true_val")] - pub require_in_cmux: bool, - - /// Where to place new agent sessions: "auto", "workspace", or "window" - #[serde(default)] - pub placement: CmuxPlacementPolicy, -} - -fn default_cmux_binary_path() -> String { - "/Applications/cmux.app/Contents/Resources/bin/cmux".to_string() -} - -fn default_true_val() -> bool { - true -} - -impl Default for SessionsCmuxConfig { - fn default() -> Self { - Self { - binary_path: default_cmux_binary_path(), - require_in_cmux: default_true_val(), - placement: CmuxPlacementPolicy::default(), - } - } -} - -/// Zellij terminal workspace manager session configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct SessionsZellijConfig { - /// Require running inside Zellij (ZELLIJ env var present) - #[serde(default = "default_true_val")] - pub require_in_zellij: bool, -} - -impl Default for SessionsZellijConfig { - fn default() -> Self { - Self { - require_in_zellij: default_true_val(), - } - } -} - -/// Backstage integration configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct BackstageConfig { - /// Whether Backstage integration is enabled - #[serde(default = "default_backstage_enabled")] - pub enabled: bool, - /// Whether to show Backstage in the Connections status section - #[serde(default)] - pub display: bool, - /// Port for the Backstage server - #[serde(default = "default_backstage_port")] - pub port: u16, - /// Auto-start Backstage server when TUI launches - #[serde(default)] - pub auto_start: bool, - /// Subdirectory within `state_path` for Backstage installation - #[serde(default = "default_backstage_subpath")] - pub subpath: String, - /// Subdirectory within backstage path for branding customization - #[serde(default = "default_branding_subpath")] - pub branding_subpath: String, - /// Base URL for downloading backstage-server binary - #[serde(default = "default_backstage_release_url")] - pub release_url: String, - /// Optional local path to backstage-server binary - /// If set, this is used instead of downloading from `release_url` - #[serde(default)] - pub local_binary_path: Option, - /// Branding and theming configuration - #[serde(default)] - pub branding: BrandingConfig, -} - -/// Branding configuration for Backstage portal -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct BrandingConfig { - /// App title shown in header - #[serde(default = "default_app_title")] - pub app_title: String, - /// Organization name - #[serde(default = "default_org_name")] - pub org_name: String, - /// Path to logo SVG (relative to branding path) - #[serde(default)] - pub logo_path: Option, - /// Theme colors (uses Operator defaults if not set) - #[serde(default)] - pub colors: ThemeColors, -} - -fn default_app_title() -> String { - "Operator Portal".to_string() -} - -fn default_org_name() -> String { - "Operator".to_string() -} - -impl Default for BrandingConfig { - fn default() -> Self { - Self { - app_title: default_app_title(), - org_name: default_org_name(), - logo_path: Some("logo.svg".to_string()), - colors: ThemeColors::default(), - } - } -} - -/// Theme color configuration for Backstage -/// Default colors match Operator's tmux theme -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct ThemeColors { - /// Primary/accent color (default: salmon #cc6c55) - #[serde(default = "default_color_primary")] - pub primary: String, - /// Secondary color (default: dark teal #114145) - #[serde(default = "default_color_secondary")] - pub secondary: String, - /// Accent/highlight color (default: cream #f4dbb7) - #[serde(default = "default_color_accent")] - pub accent: String, - /// Warning/error color (default: coral #d46048) - #[serde(default = "default_color_warning")] - pub warning: String, - /// Muted text color (default: darker salmon #8a4a3a) - #[serde(default = "default_color_muted")] - pub muted: String, -} - -fn default_color_primary() -> String { - "#cc6c55".to_string() // salmon -} - -fn default_color_secondary() -> String { - "#114145".to_string() // dark teal -} - -fn default_color_accent() -> String { - "#f4dbb7".to_string() // cream -} - -fn default_color_warning() -> String { - "#d46048".to_string() // coral -} - -fn default_color_muted() -> String { - "#8a4a3a".to_string() // darker salmon -} - -impl Default for ThemeColors { - fn default() -> Self { - Self { - primary: default_color_primary(), - secondary: default_color_secondary(), - accent: default_color_accent(), - warning: default_color_warning(), - muted: default_color_muted(), - } - } -} - -fn default_backstage_enabled() -> bool { - true -} - -fn default_backstage_port() -> u16 { - 7007 -} - -fn default_backstage_subpath() -> String { - "backstage".to_string() -} - -fn default_branding_subpath() -> String { - "branding".to_string() -} - -fn default_backstage_release_url() -> String { - "https://github.com/untra/operator/releases/latest/download".to_string() -} - -impl Default for BackstageConfig { - fn default() -> Self { - Self { - enabled: default_backstage_enabled(), - display: false, - port: default_backstage_port(), - auto_start: false, - subpath: default_backstage_subpath(), - branding_subpath: default_branding_subpath(), - release_url: default_backstage_release_url(), - local_binary_path: None, - branding: BrandingConfig::default(), - } - } -} - /// REST API server configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -775,243 +290,6 @@ impl Default for RestApiConfig { } } -/// LLM CLI tools configuration -#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS)] -#[ts(export)] -pub struct LlmToolsConfig { - /// Detected CLI tools (populated on first startup) - #[serde(default)] - pub detected: Vec, - - /// Available {tool, model} pairs for launching tickets - /// Built from detected tools + their model aliases - #[serde(default)] - pub providers: Vec, - - /// Whether detection has been completed - #[serde(default)] - pub detection_complete: bool, - - /// User's preferred default LLM tool (e.g., "claude") - #[serde(default)] - pub default_tool: Option, - - /// User's preferred default model alias (e.g., "opus") - #[serde(default)] - pub default_model: Option, - - /// Per-tool overrides for skill directories (keyed by `tool_name`) - #[serde(default)] - pub skill_directory_overrides: std::collections::HashMap, -} - -/// A detected CLI tool (e.g., claude binary) -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] -#[ts(export)] -pub struct DetectedTool { - /// Tool name (e.g., "claude") - pub name: String, - /// Path to the binary - pub path: String, - /// Version string - pub version: String, - /// Minimum required version for Operator compatibility - #[serde(default)] - pub min_version: Option, - /// Whether the installed version meets the minimum requirement - #[serde(default)] - pub version_ok: bool, - /// Available model aliases (e.g., ["opus", "sonnet", "haiku"]) - #[serde(default)] - pub model_aliases: Vec, - /// Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders - #[serde(default)] - pub command_template: String, - /// Tool capabilities - #[serde(default)] - pub capabilities: ToolCapabilities, - /// CLI flags for YOLO (auto-accept) mode - #[serde(default)] - pub yolo_flags: Vec, -} - -/// Tool capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS, utoipa::ToSchema)] -#[ts(export)] -pub struct ToolCapabilities { - /// Whether the tool supports session continuity via UUID - #[serde(default)] - pub supports_sessions: bool, - /// Whether the tool can run in headless/non-interactive mode - #[serde(default)] - pub supports_headless: bool, -} - -/// A {tool, model} pair that can be selected when launching tickets. -/// Includes optional variant fields adopted from vibe-kanban's profile system. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, TS)] -#[ts(export)] -pub struct LlmProvider { - /// CLI tool name (e.g., "claude", "codex", "gemini") - pub tool: String, - /// Model alias or name (e.g., "opus", "sonnet", "gpt-4.1") - pub model: String, - /// Optional display name for UI (e.g., "Claude Opus", "Codex High") - #[serde(default)] - pub display_name: Option, - - // ─── Variant fields (all optional) ─────────────────────────────── - /// Additional CLI flags for this provider (e.g., ["--dangerously-skip-permissions"]) - #[serde(default)] - pub flags: Vec, - - /// Environment variables to set when launching - #[serde(default)] - pub env: std::collections::HashMap, - - /// Whether this provider requires approval gates - #[serde(default)] - pub approvals: bool, - - /// Whether to run in plan-only mode - #[serde(default)] - pub plan_only: bool, - - /// Reasoning effort level (Codex: "low", "medium", "high") - #[serde(default)] - pub reasoning_effort: Option, - - /// Sandbox mode (Codex: "danger-full-access", "workspace-write") - #[serde(default)] - pub sandbox: Option, -} - -/// Per-tool skill directory overrides -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct SkillDirectoriesOverride { - /// Additional global skill directories - #[serde(default)] - pub global: Vec, - /// Additional project-relative skill directories - #[serde(default)] - pub project: Vec, -} - -/// Agent delegator configuration for autonomous ticket launching -/// -/// A delegator is a named {tool, model} pairing with optional launch configuration -/// that can be used to launch agents for tickets. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct Delegator { - /// Unique name for this delegator (e.g., "claude-opus-auto") - pub name: String, - /// LLM tool name (must match a detected tool, e.g., "claude", "codex") - pub llm_tool: String, - /// Model alias (e.g., "opus", "sonnet", "gpt-4o") - pub model: String, - /// Optional display name for UI - #[serde(default)] - pub display_name: Option, - /// Arbitrary model properties (e.g., `reasoning_effort`, sandbox) - #[serde(default)] - pub model_properties: std::collections::HashMap, - /// Optional launch configuration - #[serde(default)] - pub launch_config: Option, - /// Name of a declared `ModelServer` (from `Config.model_servers`). - /// `None` means use the `llm_tool`'s implicit vendor default - /// (claude → anthropic-api, codex → openai-api, gemini → google-api). - #[serde(default)] - pub model_server: Option, -} - -/// A named host that serves models via an inference API. -/// -/// Model servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI -/// (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint -/// (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). -/// -/// Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are -/// returned by [`implicit_model_server_for_tool`] and do not need to be declared -/// in config. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct ModelServer { - /// Unique name (e.g., "ollama-local", "vllm-gpu1") - pub name: String, - /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" - pub kind: String, - /// Base URL of the inference endpoint (e.g., `http://localhost:11434`). - /// `None` for implicit vendor servers means use the SDK default. - #[serde(default)] - pub base_url: Option, - /// Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) - #[serde(default)] - pub api_key_env: Option, - /// Additional environment variables set when spawning agents that use this server - #[serde(default)] - pub extra_env: std::collections::HashMap, - /// Optional display name for UI - #[serde(default)] - pub display_name: Option, -} - -/// Returns the implicit builtin `ModelServer` associated with a given `llm_tool`. -/// -/// Used when a `Delegator` has no explicit `model_server`. Unknown tools -/// fall back to an `"openai-api"` server so arbitrary future tools still resolve. -pub fn implicit_model_server_for_tool(tool: &str) -> ModelServer { - let (name, kind) = match tool { - "claude" => ("anthropic-api", "anthropic-api"), - "codex" => ("openai-api", "openai-api"), - "gemini" => ("google-api", "google-api"), - _ => ("openai-api", "openai-api"), - }; - ModelServer { - name: name.to_string(), - kind: kind.to_string(), - base_url: None, - api_key_env: None, - extra_env: std::collections::HashMap::new(), - display_name: None, - } -} - -/// Launch configuration for a delegator -/// -/// Controls how the delegator launches agents. Optional fields use tri-state -/// semantics: `None` = inherit from global config, `Some(true/false)` = override. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct DelegatorLaunchConfig { - /// Run in YOLO (auto-accept) mode - #[serde(default)] - pub yolo: bool, - /// Permission mode override - #[serde(default)] - pub permission_mode: Option, - /// Additional CLI flags - #[serde(default)] - pub flags: Vec, - /// Override global `git.use_worktrees` per-delegator (None = use global setting) - #[serde(default)] - pub use_worktrees: Option, - /// 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`) - #[serde(default)] - pub docker: Option, - /// Prompt text to prepend before the generated step prompt - #[serde(default)] - pub prompt_prefix: Option, - /// Prompt text to append after the generated step prompt - #[serde(default)] - pub prompt_suffix: Option, -} - /// Predefined issue type collections #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -1051,476 +329,105 @@ impl CollectionPreset { pub fn display_name(&self) -> &'static str { match self { CollectionPreset::Simple => "Simple (TASK only)", - CollectionPreset::DevKanban => "Dev Kanban (TASK, FEAT, FIX)", - CollectionPreset::DevopsKanban => "DevOps Kanban (TASK, SPIKE, INV, FEAT, FIX)", - CollectionPreset::Custom => "Custom", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct TemplatesConfig { - /// Named preset for issue type collection - /// Options: simple, `dev_kanban`, `devops_kanban`, custom - #[serde(default)] - pub preset: CollectionPreset, - /// Custom issuetype collection (only used when preset = custom) - /// List of issue type keys: TASK, FEAT, FIX, SPIKE, INV - #[serde(default)] - pub collection: Vec, - /// Active collection name (overrides preset if set) - /// Can be a builtin preset name or a user-defined collection - #[serde(default)] - pub active_collection: Option, -} - -impl Default for TemplatesConfig { - fn default() -> Self { - Self { - preset: CollectionPreset::DevKanban, - collection: Vec::new(), - active_collection: None, - } - } -} - -/// Logging configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct LoggingConfig { - /// Log level filter (trace, debug, info, warn, error) - #[serde(default = "default_log_level")] - pub level: String, - - /// Whether to log to file in TUI mode (false = stderr for debugging) - #[serde(default = "default_log_to_file")] - pub to_file: bool, -} - -fn default_log_level() -> String { - "info".to_string() -} - -fn default_log_to_file() -> bool { - true -} - -impl Default for LoggingConfig { - fn default() -> Self { - Self { - level: default_log_level(), - to_file: default_log_to_file(), - } - } -} - -/// API integrations configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct ApiConfig { - /// Interval in seconds between PR status checks (default: 60) - #[serde(default = "default_pr_check_interval")] - pub pr_check_interval_secs: u64, - /// Interval in seconds between rate limit checks (default: 300) - #[serde(default = "default_rate_limit_check_interval")] - pub rate_limit_check_interval_secs: u64, - /// Show warning when rate limit remaining is below this percentage (default: 0.2) - #[serde(default = "default_rate_limit_warning_threshold")] - pub rate_limit_warning_threshold: f32, -} - -fn default_pr_check_interval() -> u64 { - 60 // 1 minute -} - -fn default_rate_limit_check_interval() -> u64 { - 300 // 5 minutes -} - -fn default_rate_limit_warning_threshold() -> f32 { - 0.2 // 20% -} - -impl Default for ApiConfig { - fn default() -> Self { - Self { - pr_check_interval_secs: default_pr_check_interval(), - rate_limit_check_interval_secs: default_rate_limit_check_interval(), - rate_limit_warning_threshold: default_rate_limit_warning_threshold(), - } - } -} - -// ─── Kanban Provider Configuration ───────────────────────────────────────── - -/// Kanban provider configuration for syncing issues from external systems -/// -/// Providers are keyed by domain/workspace: -/// - Jira: keyed by domain (e.g., "foobar.atlassian.net") -/// - Linear: keyed by workspace slug (e.g., "myworkspace") -/// - GitHub Projects: keyed by owner login (e.g., "my-org") -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] -#[ts(export)] -pub struct KanbanConfig { - /// Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") - #[serde(default)] - pub jira: std::collections::HashMap, - /// Linear instances keyed by workspace slug - #[serde(default)] - pub linear: std::collections::HashMap, - /// 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. - #[serde(default)] - pub github: std::collections::HashMap, -} - -/// Jira Cloud provider configuration -/// -/// The domain is specified as the `HashMap` key in KanbanConfig.jira -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct JiraConfig { - /// Whether this provider is enabled - #[serde(default)] - pub enabled: bool, - /// Environment variable name containing the API key (default: `OPERATOR_JIRA_API_KEY`) - #[serde(default = "default_jira_api_key_env")] - pub api_key_env: String, - /// Atlassian account email for authentication - #[serde(default)] - pub email: String, - /// Per-project sync configuration - #[serde(default)] - pub projects: std::collections::HashMap, -} - -fn default_jira_api_key_env() -> String { - "OPERATOR_JIRA_API_KEY".to_string() -} - -impl Default for JiraConfig { - fn default() -> Self { - Self { - enabled: false, - api_key_env: default_jira_api_key_env(), - email: String::new(), - projects: std::collections::HashMap::new(), + CollectionPreset::DevKanban => "Dev Kanban (TASK, FEAT, FIX)", + CollectionPreset::DevopsKanban => "DevOps Kanban (TASK, SPIKE, INV, FEAT, FIX)", + CollectionPreset::Custom => "Custom", } } } -/// Linear provider configuration -/// -/// The workspace slug is specified as the `HashMap` key in KanbanConfig.linear #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] -pub struct LinearConfig { - /// Whether this provider is enabled +pub struct TemplatesConfig { + /// Named preset for issue type collection + /// Options: simple, `dev_kanban`, `devops_kanban`, custom #[serde(default)] - pub enabled: bool, - /// Environment variable name containing the API key (default: `OPERATOR_LINEAR_API_KEY`) - #[serde(default = "default_linear_api_key_env")] - pub api_key_env: String, - /// Per-team sync configuration + pub preset: CollectionPreset, + /// Custom issuetype collection (only used when preset = custom) + /// List of issue type keys: TASK, FEAT, FIX, SPIKE, INV #[serde(default)] - pub projects: std::collections::HashMap, -} - -fn default_linear_api_key_env() -> String { - "OPERATOR_LINEAR_API_KEY".to_string() + pub collection: Vec, + /// Active collection name (overrides preset if set) + /// Can be a builtin preset name or a user-defined collection + #[serde(default)] + pub active_collection: Option, } -impl Default for LinearConfig { +impl Default for TemplatesConfig { fn default() -> Self { Self { - enabled: false, - api_key_env: default_linear_api_key_env(), - projects: std::collections::HashMap::new(), + preset: CollectionPreset::DevKanban, + collection: Vec::new(), + active_collection: None, } } } -/// GitHub Projects v2 (kanban) provider configuration -/// -/// The owner login (user or org) is specified as the `HashMap` key in -/// `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node -/// IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly -/// by every GitHub Projects v2 mutation without needing a lookup. -/// -/// **Distinct from `GitHubConfig`** (the git provider used for PR/branch -/// operations). They live in different parts of the config tree, use -/// different env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and -/// require different OAuth scopes (`project` vs `repo`). See -/// `docs/getting-started/kanban/github.md` for the full rationale. +/// Logging configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] -pub struct GithubProjectsConfig { - /// Whether this provider is enabled - #[serde(default)] - pub enabled: bool, - /// Environment variable name containing the GitHub token (default: - /// `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or - /// `read:project`) scope, NOT just `repo` — see the disambiguation - /// guide in the kanban github docs. - #[serde(default = "default_github_projects_api_key_env")] - pub api_key_env: String, - /// Per-project sync configuration. Keys are `GraphQL` project node IDs. - #[serde(default)] - pub projects: std::collections::HashMap, -} - -fn default_github_projects_api_key_env() -> String { - "OPERATOR_GITHUB_TOKEN".to_string() -} - -impl Default for GithubProjectsConfig { - fn default() -> Self { - Self { - enabled: false, - api_key_env: default_github_projects_api_key_env(), - projects: std::collections::HashMap::new(), - } - } -} - -impl KanbanConfig { - /// Insert or update a Jira project entry in the config. - /// - /// If the workspace (keyed by domain) doesn't exist, it is created with - /// `enabled = true` and the provided email + `api_key_env`. If it already - /// exists, the email and `api_key_env` are updated and the project is - /// upserted into its `projects` map without clobbering sibling projects. - pub fn upsert_jira_project( - &mut self, - domain: &str, - email: &str, - api_key_env: &str, - project_key: &str, - sync_user_id: &str, - ) { - let entry = self.jira.entry(domain.to_string()).or_default(); - entry.enabled = true; - entry.email = email.to_string(); - entry.api_key_env = api_key_env.to_string(); - entry.projects.insert( - project_key.to_string(), - ProjectSyncConfig { - sync_user_id: sync_user_id.to_string(), - sync_statuses: Vec::new(), - collection_name: None, - type_mappings: std::collections::HashMap::new(), - }, - ); - } - - /// Insert or update a Linear team entry in the config. - /// - /// If the workspace (keyed by workspace slug) doesn't exist, it is - /// created with `enabled = true` and the provided `api_key_env`. If it - /// already exists, the `api_key_env` is updated and the project/team is - /// upserted into its `projects` map without clobbering siblings. - pub fn upsert_linear_project( - &mut self, - workspace: &str, - api_key_env: &str, - project_key: &str, - sync_user_id: &str, - ) { - let entry = self.linear.entry(workspace.to_string()).or_default(); - entry.enabled = true; - entry.api_key_env = api_key_env.to_string(); - entry.projects.insert( - project_key.to_string(), - ProjectSyncConfig { - sync_user_id: sync_user_id.to_string(), - sync_statuses: Vec::new(), - collection_name: None, - type_mappings: std::collections::HashMap::new(), - }, - ); - } - - /// Insert or update a GitHub Projects v2 entry in the config. - /// - /// If the owner (keyed by login) doesn't exist, it is created with - /// `enabled = true` and the provided `api_key_env`. If it already - /// exists, the `api_key_env` is updated and the project is upserted - /// into its `projects` map without clobbering siblings. - /// - /// `project_key` is the `GraphQL` project node ID (e.g., `PVT_kwDO...`) - /// and `sync_user_id` is the user's numeric GitHub `databaseId`. - pub fn upsert_github_project( - &mut self, - owner: &str, - api_key_env: &str, - project_key: &str, - sync_user_id: &str, - ) { - let entry = self.github.entry(owner.to_string()).or_default(); - entry.enabled = true; - entry.api_key_env = api_key_env.to_string(); - entry.projects.insert( - project_key.to_string(), - ProjectSyncConfig { - sync_user_id: sync_user_id.to_string(), - sync_statuses: Vec::new(), - collection_name: None, - type_mappings: std::collections::HashMap::new(), - }, - ); - } - - /// Provider-neutral upsert dispatcher. - /// - /// Delegates to the provider-specific upsert method based on the - /// `WorkspaceExtra` variant in the validated workspace. - #[allow(dead_code)] // Will be used by onboarding service in Phase 1b - pub fn upsert_project( - &mut self, - workspace: &crate::api::providers::kanban::ValidatedWorkspace, - project: &crate::api::providers::kanban::DiscoveredProject, - ) { - use crate::api::providers::kanban::WorkspaceExtra; - match &workspace.extra { - WorkspaceExtra::Jira { email } => self.upsert_jira_project( - &workspace.workspace_key, - email, - &workspace.api_key_env, - &project.project_key, - &workspace.sync_user_id, - ), - WorkspaceExtra::Linear => self.upsert_linear_project( - &workspace.workspace_key, - &workspace.api_key_env, - &project.project_key, - &workspace.sync_user_id, - ), - WorkspaceExtra::Github => self.upsert_github_project( - &workspace.workspace_key, - &workspace.api_key_env, - &project.project_key, - &workspace.sync_user_id, - ), - } - } -} +pub struct LoggingConfig { + /// Log level filter (trace, debug, info, warn, error) + #[serde(default = "default_log_level")] + pub level: String, -/// Per-project/team sync configuration for a kanban provider -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct ProjectSyncConfig { - /// User ID to sync issues for (provider-specific format) - /// - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") - /// - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") - /// - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") - #[serde(default)] - pub sync_user_id: String, - /// Workflow statuses to sync (empty = default/first status only) - #[serde(default)] - pub sync_statuses: Vec, - /// Optional `IssueTypeCollection` name this project maps to. - /// Not required for kanban onboarding or sync. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub collection_name: Option, - /// Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). - /// Multiple kanban types can map to the same operator template. - #[serde(default)] - pub type_mappings: std::collections::HashMap, + /// Whether to log to file in TUI mode (false = stderr for debugging) + #[serde(default = "default_log_to_file")] + pub to_file: bool, } -// ─── Git Provider Configuration ──────────────────────────────────────────── - -/// Git provider configuration for PR/MR operations -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct GitConfig { - /// Active provider (auto-detected from remote URL if not specified) - #[serde(default)] - pub provider: Option, - /// GitHub-specific configuration - #[serde(default)] - pub github: GitHubConfig, - /// GitLab-specific configuration (planned) - #[serde(default)] - pub gitlab: GitLabConfig, - /// Branch naming format (e.g., "{type}/{ticket_id}-{slug}") - #[serde(default = "default_branch_format")] - pub branch_format: String, - /// Whether to use git worktrees for per-ticket isolation (default: false) - /// When false, tickets work directly in the project directory with branches - #[serde(default)] - pub use_worktrees: bool, +fn default_log_level() -> String { + "info".to_string() } -fn default_branch_format() -> String { - "{type}/{ticket_id}".to_string() +fn default_log_to_file() -> bool { + true } -impl Default for GitConfig { +impl Default for LoggingConfig { fn default() -> Self { Self { - provider: None, - github: GitHubConfig::default(), - gitlab: GitLabConfig::default(), - branch_format: default_branch_format(), - use_worktrees: false, + level: default_log_level(), + to_file: default_log_to_file(), } } } -/// Git provider selection +/// API integrations configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] #[ts(export)] -pub enum GitProviderConfig { - /// GitHub (github.com) - GitHub, - /// GitLab (gitlab.com or self-hosted) - GitLab, - /// Bitbucket (bitbucket.org) - Bitbucket, - /// Azure DevOps (dev.azure.com) - AzureDevOps, +pub struct ApiConfig { + /// Interval in seconds between PR status checks (default: 60) + #[serde(default = "default_pr_check_interval")] + pub pr_check_interval_secs: u64, + /// Interval in seconds between rate limit checks (default: 300) + #[serde(default = "default_rate_limit_check_interval")] + pub rate_limit_check_interval_secs: u64, + /// Show warning when rate limit remaining is below this percentage (default: 0.2) + #[serde(default = "default_rate_limit_warning_threshold")] + pub rate_limit_warning_threshold: f32, } -/// GitHub-specific configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] -#[ts(export)] -pub struct GitHubConfig { - /// Whether GitHub integration is enabled - #[serde(default = "default_true")] - pub enabled: bool, - /// Environment variable containing the GitHub token (default: `GITHUB_TOKEN`) - #[serde(default = "default_github_token_env")] - pub token_env: String, +fn default_pr_check_interval() -> u64 { + 60 // 1 minute } -fn default_github_token_env() -> String { - "GITHUB_TOKEN".to_string() +fn default_rate_limit_check_interval() -> u64 { + 300 // 5 minutes } -/// GitLab-specific configuration (planned) -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] -#[ts(export)] -pub struct GitLabConfig { - /// Whether GitLab integration is enabled - #[serde(default)] - pub enabled: bool, - /// Environment variable containing the GitLab token (default: `GITLAB_TOKEN`) - #[serde(default = "default_gitlab_token_env")] - pub token_env: String, - /// GitLab host (default: gitlab.com, can be self-hosted) - #[serde(default)] - pub host: Option, +fn default_rate_limit_warning_threshold() -> f32 { + 0.2 // 20% } -fn default_gitlab_token_env() -> String { - "GITLAB_TOKEN".to_string() +impl Default for ApiConfig { + fn default() -> Self { + Self { + pr_check_interval_secs: default_pr_check_interval(), + rate_limit_check_interval_secs: default_rate_limit_check_interval(), + rate_limit_warning_threshold: default_rate_limit_warning_threshold(), + } + } } // ─── Version Check Configuration ──────────────────────────────────────────── @@ -1542,6 +449,10 @@ pub struct VersionCheckConfig { pub timeout_secs: u64, } +fn default_true() -> bool { + true +} + fn default_version_check_url() -> Option { Some("https://operator.untra.io/VERSION".to_string()) } @@ -1560,6 +471,18 @@ impl Default for VersionCheckConfig { } } +// ─── Relay Configuration ───────────────────────────────────────────────────── + +/// Relay MCP injection configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct RelayConfig { + /// When true, automatically inject the relay MCP server for all delegators. + /// When false (default), relay injection is opt-in per delegator. + #[serde(default)] + pub auto_inject_mcp: bool, +} + impl Config { /// Path to the operator config file within .tickets/ pub fn operator_config_path() -> PathBuf { @@ -1717,7 +640,7 @@ impl Config { } /// Get absolute path to Backstage branding directory - #[allow(dead_code)] + #[allow(dead_code)] // For future branding customization support pub fn backstage_branding_path(&self) -> PathBuf { self.backstage_path().join(&self.backstage.branding_subpath) } @@ -1804,6 +727,7 @@ impl Default for Config { version_check: VersionCheckConfig::default(), delegators: Vec::new(), model_servers: Vec::new(), + relay: RelayConfig::default(), } } } @@ -1812,291 +736,7 @@ impl Default for Config { mod tests { use super::*; - #[test] - fn test_default_preset_is_dev_kanban() { - assert_eq!(CollectionPreset::default(), CollectionPreset::DevKanban); - } - - #[test] - fn test_templates_config_default_uses_dev_kanban() { - let config = TemplatesConfig::default(); - assert_eq!(config.preset, CollectionPreset::DevKanban); - } - - #[test] - fn test_dev_kanban_has_three_issue_types() { - let types = CollectionPreset::DevKanban.issue_types(); - assert_eq!(types.len(), 3); - assert!(types.contains(&"TASK".to_string())); - assert!(types.contains(&"FEAT".to_string())); - assert!(types.contains(&"FIX".to_string())); - } - - #[test] - fn test_delegator_serde_roundtrip() { - let delegator = Delegator { - name: "claude-opus-auto".to_string(), - llm_tool: "claude".to_string(), - model: "opus".to_string(), - display_name: Some("Claude Opus Auto".to_string()), - model_properties: std::collections::HashMap::new(), - launch_config: Some(DelegatorLaunchConfig { - yolo: true, - permission_mode: Some("delegate".to_string()), - flags: vec!["--verbose".to_string()], - ..Default::default() - }), - model_server: None, - }; - - let json = serde_json::to_string(&delegator).unwrap(); - let parsed: Delegator = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.name, "claude-opus-auto"); - assert_eq!(parsed.llm_tool, "claude"); - assert_eq!(parsed.model, "opus"); - assert!(parsed.launch_config.unwrap().yolo); - assert!(parsed.model_server.is_none()); - } - - #[test] - fn test_model_server_toml_roundtrip() { - let toml_str = r#" - name = "ollama-local" - kind = "ollama" - base_url = "http://localhost:11434" - display_name = "Ollama (local)" - "#; - let server: ModelServer = toml::from_str(toml_str).unwrap(); - assert_eq!(server.name, "ollama-local"); - assert_eq!(server.kind, "ollama"); - assert_eq!(server.base_url.as_deref(), Some("http://localhost:11434")); - assert_eq!(server.display_name.as_deref(), Some("Ollama (local)")); - assert!(server.extra_env.is_empty()); - assert!(server.api_key_env.is_none()); - } - - #[test] - fn test_delegator_with_model_server_ref_roundtrip() { - let toml_str = r#" - name = "codex-local-qwen" - llm_tool = "codex" - model = "qwen2.5-coder" - model_server = "ollama-local" - "#; - let d: Delegator = toml::from_str(toml_str).unwrap(); - assert_eq!(d.name, "codex-local-qwen"); - assert_eq!(d.model_server.as_deref(), Some("ollama-local")); - } - - #[test] - fn test_delegator_without_model_server_field_still_parses() { - let toml_str = r#" - name = "claude-opus-auto" - llm_tool = "claude" - model = "opus" - "#; - let d: Delegator = toml::from_str(toml_str).unwrap(); - assert_eq!(d.name, "claude-opus-auto"); - assert!(d.model_server.is_none()); - } - - #[test] - fn test_implicit_model_server_for_known_tools() { - assert_eq!( - implicit_model_server_for_tool("claude").kind, - "anthropic-api" - ); - assert_eq!(implicit_model_server_for_tool("codex").kind, "openai-api"); - assert_eq!(implicit_model_server_for_tool("gemini").kind, "google-api"); - assert_eq!(implicit_model_server_for_tool("unknown").kind, "openai-api"); - } - - #[test] - fn test_config_without_model_servers_field_still_parses() { - let toml_str = r#" - [agents] - max_parallel = 1 - cores_reserved = 0 - health_check_interval = 5 - [notifications] - enabled = false - [queue] - auto_assign = true - priority_order = [] - poll_interval_ms = 1000 - [paths] - tickets = ".tickets" - projects = "." - state = ".tickets/operator" - worktrees = ".worktrees" - [ui] - refresh_rate_ms = 100 - completed_history_hours = 1 - summary_max_length = 40 - [launch] - confirm_autonomous = false - confirm_paired = false - launch_delay_ms = 0 - [templates] - "#; - let cfg: Config = toml::from_str(toml_str).unwrap(); - assert!(cfg.model_servers.is_empty()); - } - - #[test] - fn test_skill_directories_override_default() { - let override_config = SkillDirectoriesOverride::default(); - assert!(override_config.global.is_empty()); - assert!(override_config.project.is_empty()); - } - - #[test] - fn test_session_wrapper_type_cmux_display() { - assert_eq!(SessionWrapperType::Cmux.to_string(), "cmux"); - } - - #[test] - fn test_session_wrapper_type_cmux_serde_roundtrip() { - let json = serde_json::to_string(&SessionWrapperType::Cmux).unwrap(); - let parsed: SessionWrapperType = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, SessionWrapperType::Cmux); - } - - #[test] - fn test_sessions_cmux_config_defaults() { - let config = SessionsCmuxConfig::default(); - assert_eq!( - config.binary_path, - "/Applications/cmux.app/Contents/Resources/bin/cmux" - ); - assert!(config.require_in_cmux); - assert_eq!(config.placement, CmuxPlacementPolicy::Auto); - } - - #[test] - fn test_cmux_placement_policy_display() { - assert_eq!(CmuxPlacementPolicy::Auto.to_string(), "auto"); - assert_eq!(CmuxPlacementPolicy::Workspace.to_string(), "workspace"); - assert_eq!(CmuxPlacementPolicy::Window.to_string(), "window"); - } - - #[test] - fn test_config_deserialize_with_cmux_wrapper() { - let toml_str = r#" - wrapper = "cmux" - [cmux] - binary_path = "/usr/local/bin/cmux" - require_in_cmux = false - placement = "window" - "#; - let config: SessionsConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.wrapper, SessionWrapperType::Cmux); - assert_eq!(config.cmux.binary_path, "/usr/local/bin/cmux"); - assert!(!config.cmux.require_in_cmux); - assert_eq!(config.cmux.placement, CmuxPlacementPolicy::Window); - } - - #[test] - fn test_devops_kanban_has_five_issue_types() { - let types = CollectionPreset::DevopsKanban.issue_types(); - assert_eq!(types.len(), 5); - assert!(types.contains(&"TASK".to_string())); - assert!(types.contains(&"FEAT".to_string())); - assert!(types.contains(&"FIX".to_string())); - assert!(types.contains(&"SPIKE".to_string())); - assert!(types.contains(&"INV".to_string())); - } - - // --- effective_max_agents tests --- - - #[test] - fn test_effective_max_agents_never_returns_zero() { - let mut config = Config::default(); - config.agents.max_parallel = 0; - config.agents.cores_reserved = 100; - assert!(config.effective_max_agents() >= 1); - } - - #[test] - fn test_effective_max_agents_respects_max_parallel() { - let mut config = Config::default(); - config.agents.max_parallel = 2; - config.agents.cores_reserved = 0; - assert!(config.effective_max_agents() <= 2); - } - - #[test] - fn test_effective_max_agents_reserves_cores() { - let config = Config::default(); - let cpu_count = sysinfo::System::new_all().cpus().len(); - let effective = config.effective_max_agents(); - assert!(effective <= cpu_count.saturating_sub(config.agents.cores_reserved)); - } - - // --- Path resolution tests --- - - #[test] - fn test_tickets_path_absolute_passthrough() { - let mut config = Config::default(); - config.paths.tickets = "/absolute/path/tickets".to_string(); - assert_eq!( - config.tickets_path(), - std::path::PathBuf::from("/absolute/path/tickets") - ); - } - - #[test] - fn test_tickets_path_relative_resolves() { - let config = Config::default(); - let path = config.tickets_path(); - assert!(path.is_absolute()); - assert!(path.ends_with(".tickets")); - } - - #[test] - fn test_projects_path_absolute_passthrough() { - let mut config = Config::default(); - config.paths.projects = "/my/projects".to_string(); - assert_eq!( - config.projects_path(), - std::path::PathBuf::from("/my/projects") - ); - } - - #[test] - fn test_state_path_relative_resolves() { - let config = Config::default(); - let path = config.state_path(); - assert!(path.is_absolute()); - assert!(path.ends_with("operator")); - } - - // --- priority_index tests --- - - #[test] - fn test_priority_index_known_types() { - let config = Config::default(); - assert_eq!(config.priority_index("INV"), 0); - assert_eq!(config.priority_index("FIX"), 1); - assert_eq!(config.priority_index("TASK"), 2); - assert_eq!(config.priority_index("FEAT"), 3); - assert_eq!(config.priority_index("SPIKE"), 4); - } - - #[test] - fn test_priority_index_unknown_returns_max() { - let config = Config::default(); - assert_eq!(config.priority_index("UNKNOWN"), usize::MAX); - } - - #[test] - fn test_priority_index_empty_order() { - let mut config = Config::default(); - config.queue.priority_order.clear(); - assert_eq!(config.priority_index("INV"), usize::MAX); - } - - // --- Default value function tests --- + // --- Default value function tests (private functions — must stay inline) --- #[test] fn test_default_generation_timeout_is_300() { @@ -2123,246 +763,8 @@ mod tests { let dir = default_worktrees_dir(); assert!(dir.contains("worktrees")); } - - #[test] - fn test_upsert_jira_project_inserts_new_workspace() { - let mut kanban = KanbanConfig::default(); - kanban.upsert_jira_project( - "acme.atlassian.net", - "user@acme.com", - "OPERATOR_JIRA_API_KEY", - "PROJ", - "acct-123", - ); - - let ws = kanban - .jira - .get("acme.atlassian.net") - .expect("workspace should be inserted"); - assert!(ws.enabled); - assert_eq!(ws.email, "user@acme.com"); - assert_eq!(ws.api_key_env, "OPERATOR_JIRA_API_KEY"); - - let project = ws.projects.get("PROJ").expect("project should exist"); - assert_eq!(project.sync_user_id, "acct-123"); - } - - #[test] - fn test_upsert_jira_project_adds_to_existing_workspace_without_clobber() { - let mut kanban = KanbanConfig::default(); - // Seed with an existing workspace and project - kanban.upsert_jira_project( - "acme.atlassian.net", - "user@acme.com", - "OPERATOR_JIRA_API_KEY", - "EXISTING", - "acct-existing", - ); - - // Add a second project to the same workspace - kanban.upsert_jira_project( - "acme.atlassian.net", - "user@acme.com", - "OPERATOR_JIRA_API_KEY", - "NEWONE", - "acct-new", - ); - - let ws = kanban.jira.get("acme.atlassian.net").unwrap(); - assert_eq!(ws.projects.len(), 2, "both projects should be preserved"); - assert_eq!(ws.projects["EXISTING"].sync_user_id, "acct-existing"); - assert_eq!(ws.projects["NEWONE"].sync_user_id, "acct-new"); - } - - #[test] - fn test_upsert_jira_project_replaces_existing_project_entry() { - let mut kanban = KanbanConfig::default(); - kanban.upsert_jira_project( - "acme.atlassian.net", - "user@acme.com", - "OPERATOR_JIRA_API_KEY", - "PROJ", - "acct-old", - ); - // Upsert same project with new sync_user_id - kanban.upsert_jira_project( - "acme.atlassian.net", - "user@acme.com", - "OPERATOR_JIRA_API_KEY", - "PROJ", - "acct-new", - ); - - let ws = kanban.jira.get("acme.atlassian.net").unwrap(); - assert_eq!(ws.projects.len(), 1); - assert_eq!(ws.projects["PROJ"].sync_user_id, "acct-new"); - } - - #[test] - fn test_upsert_linear_project_inserts_new_workspace() { - let mut kanban = KanbanConfig::default(); - kanban.upsert_linear_project( - "myworkspace", - "OPERATOR_LINEAR_API_KEY", - "ENG", - "user-uuid-1", - ); - - let ws = kanban.linear.get("myworkspace").unwrap(); - assert!(ws.enabled); - assert_eq!(ws.api_key_env, "OPERATOR_LINEAR_API_KEY"); - assert_eq!(ws.projects["ENG"].sync_user_id, "user-uuid-1"); - } - - #[test] - fn test_upsert_linear_project_adds_to_existing_workspace_without_clobber() { - let mut kanban = KanbanConfig::default(); - kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "ENG", "user-a"); - kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "DESIGN", "user-b"); - - let ws = kanban.linear.get("myworkspace").unwrap(); - assert_eq!(ws.projects.len(), 2); - assert_eq!(ws.projects["ENG"].sync_user_id, "user-a"); - assert_eq!(ws.projects["DESIGN"].sync_user_id, "user-b"); - } - - #[test] - fn test_upsert_jira_does_not_touch_other_workspaces() { - let mut kanban = KanbanConfig::default(); - kanban.upsert_jira_project( - "first.atlassian.net", - "u1@first.com", - "OPERATOR_JIRA_API_KEY", - "FIRST", - "acct-1", - ); - kanban.upsert_jira_project( - "second.atlassian.net", - "u2@second.com", - "OPERATOR_JIRA_SECOND_API_KEY", - "SECOND", - "acct-2", - ); - - assert_eq!(kanban.jira.len(), 2); - assert_eq!(kanban.jira["first.atlassian.net"].email, "u1@first.com"); - assert_eq!( - kanban.jira["second.atlassian.net"].api_key_env, - "OPERATOR_JIRA_SECOND_API_KEY" - ); - } - - #[test] - fn test_upsert_project_jira() { - use crate::api::providers::kanban::{ - DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, - }; - - let mut kanban = KanbanConfig::default(); - let ws = ValidatedWorkspace { - provider_kind: KanbanProviderType::Jira, - workspace_key: "acme.atlassian.net".to_string(), - workspace_display_name: "Acme Corp".to_string(), - sync_user_id: "acct-123".to_string(), - sync_user_display_name: "Alice".to_string(), - api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), - prefetched_projects: None, - extra: WorkspaceExtra::Jira { - email: "alice@acme.com".to_string(), - }, - }; - let project = DiscoveredProject { - workspace_key: "acme.atlassian.net".to_string(), - project_key: "PROJ".to_string(), - project_display_name: "My Project".to_string(), - provider_url: None, - provider_native_id: None, - }; - - kanban.upsert_project(&ws, &project); - - let entry = kanban - .jira - .get("acme.atlassian.net") - .expect("workspace should be created"); - assert!(entry.enabled); - assert_eq!(entry.email, "alice@acme.com"); - assert_eq!(entry.api_key_env, "OPERATOR_JIRA_API_KEY"); - let proj = entry.projects.get("PROJ").expect("project should exist"); - assert_eq!(proj.sync_user_id, "acct-123"); - } - - #[test] - fn test_upsert_project_linear() { - use crate::api::providers::kanban::{ - DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, - }; - - let mut kanban = KanbanConfig::default(); - let ws = ValidatedWorkspace { - provider_kind: KanbanProviderType::Linear, - workspace_key: "acme".to_string(), - workspace_display_name: "Acme Inc".to_string(), - sync_user_id: "user-uuid-1".to_string(), - sync_user_display_name: "Bob".to_string(), - api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), - prefetched_projects: None, - extra: WorkspaceExtra::Linear, - }; - let project = DiscoveredProject { - workspace_key: "acme".to_string(), - project_key: "ENG".to_string(), - project_display_name: "Engineering".to_string(), - provider_url: None, - provider_native_id: None, - }; - - kanban.upsert_project(&ws, &project); - - let entry = kanban - .linear - .get("acme") - .expect("workspace should be created"); - assert!(entry.enabled); - assert_eq!(entry.api_key_env, "OPERATOR_LINEAR_API_KEY"); - let proj = entry.projects.get("ENG").expect("project should exist"); - assert_eq!(proj.sync_user_id, "user-uuid-1"); - } - - #[test] - fn test_upsert_project_github() { - use crate::api::providers::kanban::{ - DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, - }; - - let mut kanban = KanbanConfig::default(); - let ws = ValidatedWorkspace { - provider_kind: KanbanProviderType::Github, - workspace_key: "my-org".to_string(), - workspace_display_name: "github.com".to_string(), - sync_user_id: "12345678".to_string(), - sync_user_display_name: "octocat".to_string(), - api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), - prefetched_projects: None, - extra: WorkspaceExtra::Github, - }; - let project = DiscoveredProject { - workspace_key: "my-org".to_string(), - project_key: "PVT_abc".to_string(), - project_display_name: "My Board".to_string(), - provider_url: None, - provider_native_id: None, - }; - - kanban.upsert_project(&ws, &project); - - let entry = kanban - .github - .get("my-org") - .expect("workspace should be created"); - assert!(entry.enabled); - assert_eq!(entry.api_key_env, "OPERATOR_GITHUB_TOKEN"); - let proj = entry.projects.get("PVT_abc").expect("project should exist"); - assert_eq!(proj.sync_user_id, "12345678"); - } } + +#[cfg(test)] +#[path = "config/config_tests.rs"] +mod config_tests; diff --git a/src/config/backstage_config.rs b/src/config/backstage_config.rs new file mode 100644 index 0000000..a39aa98 --- /dev/null +++ b/src/config/backstage_config.rs @@ -0,0 +1,164 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Backstage integration configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct BackstageConfig { + /// Whether Backstage integration is enabled + #[serde(default = "default_backstage_enabled")] + pub enabled: bool, + /// Whether to show Backstage in the Connections status section + #[serde(default)] + pub display: bool, + /// Port for the Backstage server + #[serde(default = "default_backstage_port")] + pub port: u16, + /// Auto-start Backstage server when TUI launches + #[serde(default)] + pub auto_start: bool, + /// Subdirectory within `state_path` for Backstage installation + #[serde(default = "default_backstage_subpath")] + pub subpath: String, + /// Subdirectory within backstage path for branding customization + #[serde(default = "default_branding_subpath")] + pub branding_subpath: String, + /// Base URL for downloading backstage-server binary + #[serde(default = "default_backstage_release_url")] + pub release_url: String, + /// Optional local path to backstage-server binary + /// If set, this is used instead of downloading from `release_url` + #[serde(default)] + pub local_binary_path: Option, + /// Branding and theming configuration + #[serde(default)] + pub branding: BrandingConfig, +} + +/// Branding configuration for Backstage portal +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct BrandingConfig { + /// App title shown in header + #[serde(default = "default_app_title")] + pub app_title: String, + /// Organization name + #[serde(default = "default_org_name")] + pub org_name: String, + /// Path to logo SVG (relative to branding path) + #[serde(default)] + pub logo_path: Option, + /// Theme colors (uses Operator defaults if not set) + #[serde(default)] + pub colors: ThemeColors, +} + +fn default_app_title() -> String { + "Operator Portal".to_string() +} + +fn default_org_name() -> String { + "Operator".to_string() +} + +impl Default for BrandingConfig { + fn default() -> Self { + Self { + app_title: default_app_title(), + org_name: default_org_name(), + logo_path: Some("logo.svg".to_string()), + colors: ThemeColors::default(), + } + } +} + +/// Theme color configuration for Backstage +/// Default colors match Operator's tmux theme +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ThemeColors { + /// Primary/accent color (default: salmon #cc6c55) + #[serde(default = "default_color_primary")] + pub primary: String, + /// Secondary color (default: dark teal #114145) + #[serde(default = "default_color_secondary")] + pub secondary: String, + /// Accent/highlight color (default: cream #f4dbb7) + #[serde(default = "default_color_accent")] + pub accent: String, + /// Warning/error color (default: coral #d46048) + #[serde(default = "default_color_warning")] + pub warning: String, + /// Muted text color (default: darker salmon #8a4a3a) + #[serde(default = "default_color_muted")] + pub muted: String, +} + +fn default_color_primary() -> String { + "#cc6c55".to_string() // salmon +} + +fn default_color_secondary() -> String { + "#114145".to_string() // dark teal +} + +fn default_color_accent() -> String { + "#f4dbb7".to_string() // cream +} + +fn default_color_warning() -> String { + "#d46048".to_string() // coral +} + +fn default_color_muted() -> String { + "#8a4a3a".to_string() // darker salmon +} + +impl Default for ThemeColors { + fn default() -> Self { + Self { + primary: default_color_primary(), + secondary: default_color_secondary(), + accent: default_color_accent(), + warning: default_color_warning(), + muted: default_color_muted(), + } + } +} + +fn default_backstage_enabled() -> bool { + true +} + +fn default_backstage_port() -> u16 { + 7007 +} + +fn default_backstage_subpath() -> String { + "backstage".to_string() +} + +fn default_branding_subpath() -> String { + "branding".to_string() +} + +fn default_backstage_release_url() -> String { + "https://github.com/untra/operator/releases/latest/download".to_string() +} + +impl Default for BackstageConfig { + fn default() -> Self { + Self { + enabled: default_backstage_enabled(), + display: false, + port: default_backstage_port(), + auto_start: false, + subpath: default_backstage_subpath(), + branding_subpath: default_branding_subpath(), + release_url: default_backstage_release_url(), + local_binary_path: None, + branding: BrandingConfig::default(), + } + } +} diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs new file mode 100644 index 0000000..5c78471 --- /dev/null +++ b/src/config/config_tests.rs @@ -0,0 +1,546 @@ +use super::*; + +#[test] +fn test_default_preset_is_dev_kanban() { + assert_eq!(CollectionPreset::default(), CollectionPreset::DevKanban); +} + +#[test] +fn test_templates_config_default_uses_dev_kanban() { + let config = TemplatesConfig::default(); + assert_eq!(config.preset, CollectionPreset::DevKanban); +} + +#[test] +fn test_dev_kanban_has_three_issue_types() { + let types = CollectionPreset::DevKanban.issue_types(); + assert_eq!(types.len(), 3); + assert!(types.contains(&"TASK".to_string())); + assert!(types.contains(&"FEAT".to_string())); + assert!(types.contains(&"FIX".to_string())); +} + +#[test] +fn test_delegator_serde_roundtrip() { + let delegator = Delegator { + name: "claude-opus-auto".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Claude Opus Auto".to_string()), + model_properties: std::collections::HashMap::new(), + launch_config: Some(DelegatorLaunchConfig { + yolo: true, + permission_mode: Some("delegate".to_string()), + flags: vec!["--verbose".to_string()], + ..Default::default() + }), + model_server: None, + }; + + let json = serde_json::to_string(&delegator).unwrap(); + let parsed: Delegator = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "claude-opus-auto"); + assert_eq!(parsed.llm_tool, "claude"); + assert_eq!(parsed.model, "opus"); + assert!(parsed.launch_config.unwrap().yolo); + assert!(parsed.model_server.is_none()); +} + +#[test] +fn test_model_server_toml_roundtrip() { + let toml_str = r#" + name = "ollama-local" + kind = "ollama" + base_url = "http://localhost:11434" + display_name = "Ollama (local)" + "#; + let server: ModelServer = toml::from_str(toml_str).unwrap(); + assert_eq!(server.name, "ollama-local"); + assert_eq!(server.kind, "ollama"); + assert_eq!(server.base_url.as_deref(), Some("http://localhost:11434")); + assert_eq!(server.display_name.as_deref(), Some("Ollama (local)")); + assert!(server.extra_env.is_empty()); + assert!(server.api_key_env.is_none()); +} + +#[test] +fn test_delegator_with_model_server_ref_roundtrip() { + let toml_str = r#" + name = "codex-local-qwen" + llm_tool = "codex" + model = "qwen2.5-coder" + model_server = "ollama-local" + "#; + let d: Delegator = toml::from_str(toml_str).unwrap(); + assert_eq!(d.name, "codex-local-qwen"); + assert_eq!(d.model_server.as_deref(), Some("ollama-local")); +} + +#[test] +fn test_delegator_without_model_server_field_still_parses() { + let toml_str = r#" + name = "claude-opus-auto" + llm_tool = "claude" + model = "opus" + "#; + let d: Delegator = toml::from_str(toml_str).unwrap(); + assert_eq!(d.name, "claude-opus-auto"); + assert!(d.model_server.is_none()); +} + +#[test] +fn test_implicit_model_server_for_known_tools() { + assert_eq!( + implicit_model_server_for_tool("claude").kind, + "anthropic-api" + ); + assert_eq!(implicit_model_server_for_tool("codex").kind, "openai-api"); + assert_eq!(implicit_model_server_for_tool("gemini").kind, "google-api"); + assert_eq!(implicit_model_server_for_tool("unknown").kind, "openai-api"); +} + +#[test] +fn test_config_without_model_servers_field_still_parses() { + let toml_str = r#" + [agents] + max_parallel = 1 + cores_reserved = 0 + health_check_interval = 5 + [notifications] + enabled = false + [queue] + auto_assign = true + priority_order = [] + poll_interval_ms = 1000 + [paths] + tickets = ".tickets" + projects = "." + state = ".tickets/operator" + worktrees = ".worktrees" + [ui] + refresh_rate_ms = 100 + completed_history_hours = 1 + summary_max_length = 40 + [launch] + confirm_autonomous = false + confirm_paired = false + launch_delay_ms = 0 + [templates] + "#; + let cfg: Config = toml::from_str(toml_str).unwrap(); + assert!(cfg.model_servers.is_empty()); +} + +#[test] +fn test_skill_directories_override_default() { + let override_config = SkillDirectoriesOverride::default(); + assert!(override_config.global.is_empty()); + assert!(override_config.project.is_empty()); +} + +#[test] +fn test_session_wrapper_type_cmux_display() { + assert_eq!(SessionWrapperType::Cmux.to_string(), "cmux"); +} + +#[test] +fn test_session_wrapper_type_cmux_serde_roundtrip() { + let json = serde_json::to_string(&SessionWrapperType::Cmux).unwrap(); + let parsed: SessionWrapperType = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, SessionWrapperType::Cmux); +} + +#[test] +fn test_sessions_cmux_config_defaults() { + let config = SessionsCmuxConfig::default(); + assert_eq!( + config.binary_path, + "/Applications/cmux.app/Contents/Resources/bin/cmux" + ); + assert!(config.require_in_cmux); + assert_eq!(config.placement, CmuxPlacementPolicy::Auto); +} + +#[test] +fn test_cmux_placement_policy_display() { + assert_eq!(CmuxPlacementPolicy::Auto.to_string(), "auto"); + assert_eq!(CmuxPlacementPolicy::Workspace.to_string(), "workspace"); + assert_eq!(CmuxPlacementPolicy::Window.to_string(), "window"); +} + +#[test] +fn test_config_deserialize_with_cmux_wrapper() { + let toml_str = r#" + wrapper = "cmux" + [cmux] + binary_path = "/usr/local/bin/cmux" + require_in_cmux = false + placement = "window" + "#; + let config: SessionsConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.wrapper, SessionWrapperType::Cmux); + assert_eq!(config.cmux.binary_path, "/usr/local/bin/cmux"); + assert!(!config.cmux.require_in_cmux); + assert_eq!(config.cmux.placement, CmuxPlacementPolicy::Window); +} + +#[test] +fn test_devops_kanban_has_five_issue_types() { + let types = CollectionPreset::DevopsKanban.issue_types(); + assert_eq!(types.len(), 5); + assert!(types.contains(&"TASK".to_string())); + assert!(types.contains(&"FEAT".to_string())); + assert!(types.contains(&"FIX".to_string())); + assert!(types.contains(&"SPIKE".to_string())); + assert!(types.contains(&"INV".to_string())); +} + +// --- effective_max_agents tests --- + +#[test] +fn test_effective_max_agents_never_returns_zero() { + let mut config = Config::default(); + config.agents.max_parallel = 0; + config.agents.cores_reserved = 100; + assert!(config.effective_max_agents() >= 1); +} + +#[test] +fn test_effective_max_agents_respects_max_parallel() { + let mut config = Config::default(); + config.agents.max_parallel = 2; + config.agents.cores_reserved = 0; + assert!(config.effective_max_agents() <= 2); +} + +#[test] +fn test_effective_max_agents_reserves_cores() { + let config = Config::default(); + let cpu_count = sysinfo::System::new_all().cpus().len(); + let effective = config.effective_max_agents(); + assert!(effective <= cpu_count.saturating_sub(config.agents.cores_reserved)); +} + +// --- Path resolution tests --- + +#[test] +fn test_tickets_path_absolute_passthrough() { + let mut config = Config::default(); + config.paths.tickets = "/absolute/path/tickets".to_string(); + assert_eq!( + config.tickets_path(), + std::path::PathBuf::from("/absolute/path/tickets") + ); +} + +#[test] +fn test_tickets_path_relative_resolves() { + let config = Config::default(); + let path = config.tickets_path(); + assert!(path.is_absolute()); + assert!(path.ends_with(".tickets")); +} + +#[test] +fn test_projects_path_absolute_passthrough() { + let mut config = Config::default(); + config.paths.projects = "/my/projects".to_string(); + assert_eq!( + config.projects_path(), + std::path::PathBuf::from("/my/projects") + ); +} + +#[test] +fn test_state_path_relative_resolves() { + let config = Config::default(); + let path = config.state_path(); + assert!(path.is_absolute()); + assert!(path.ends_with("operator")); +} + +// --- priority_index tests --- + +#[test] +fn test_priority_index_known_types() { + let config = Config::default(); + assert_eq!(config.priority_index("INV"), 0); + assert_eq!(config.priority_index("FIX"), 1); + assert_eq!(config.priority_index("TASK"), 2); + assert_eq!(config.priority_index("FEAT"), 3); + assert_eq!(config.priority_index("SPIKE"), 4); +} + +#[test] +fn test_priority_index_unknown_returns_max() { + let config = Config::default(); + assert_eq!(config.priority_index("UNKNOWN"), usize::MAX); +} + +#[test] +fn test_priority_index_empty_order() { + let mut config = Config::default(); + config.queue.priority_order.clear(); + assert_eq!(config.priority_index("INV"), usize::MAX); +} + +#[test] +fn test_upsert_jira_project_inserts_new_workspace() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-123", + ); + + let ws = kanban + .jira + .get("acme.atlassian.net") + .expect("workspace should be inserted"); + assert!(ws.enabled); + assert_eq!(ws.email, "user@acme.com"); + assert_eq!(ws.api_key_env, "OPERATOR_JIRA_API_KEY"); + + let project = ws.projects.get("PROJ").expect("project should exist"); + assert_eq!(project.sync_user_id, "acct-123"); +} + +#[test] +fn test_upsert_jira_project_adds_to_existing_workspace_without_clobber() { + let mut kanban = KanbanConfig::default(); + // Seed with an existing workspace and project + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "EXISTING", + "acct-existing", + ); + + // Add a second project to the same workspace + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "NEWONE", + "acct-new", + ); + + let ws = kanban.jira.get("acme.atlassian.net").unwrap(); + assert_eq!(ws.projects.len(), 2, "both projects should be preserved"); + assert_eq!(ws.projects["EXISTING"].sync_user_id, "acct-existing"); + assert_eq!(ws.projects["NEWONE"].sync_user_id, "acct-new"); +} + +#[test] +fn test_upsert_jira_project_replaces_existing_project_entry() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-old", + ); + // Upsert same project with new sync_user_id + kanban.upsert_jira_project( + "acme.atlassian.net", + "user@acme.com", + "OPERATOR_JIRA_API_KEY", + "PROJ", + "acct-new", + ); + + let ws = kanban.jira.get("acme.atlassian.net").unwrap(); + assert_eq!(ws.projects.len(), 1); + assert_eq!(ws.projects["PROJ"].sync_user_id, "acct-new"); +} + +#[test] +fn test_upsert_linear_project_inserts_new_workspace() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_linear_project( + "myworkspace", + "OPERATOR_LINEAR_API_KEY", + "ENG", + "user-uuid-1", + ); + + let ws = kanban.linear.get("myworkspace").unwrap(); + assert!(ws.enabled); + assert_eq!(ws.api_key_env, "OPERATOR_LINEAR_API_KEY"); + assert_eq!(ws.projects["ENG"].sync_user_id, "user-uuid-1"); +} + +#[test] +fn test_upsert_linear_project_adds_to_existing_workspace_without_clobber() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "ENG", "user-a"); + kanban.upsert_linear_project("myworkspace", "OPERATOR_LINEAR_API_KEY", "DESIGN", "user-b"); + + let ws = kanban.linear.get("myworkspace").unwrap(); + assert_eq!(ws.projects.len(), 2); + assert_eq!(ws.projects["ENG"].sync_user_id, "user-a"); + assert_eq!(ws.projects["DESIGN"].sync_user_id, "user-b"); +} + +#[test] +fn test_upsert_jira_does_not_touch_other_workspaces() { + let mut kanban = KanbanConfig::default(); + kanban.upsert_jira_project( + "first.atlassian.net", + "u1@first.com", + "OPERATOR_JIRA_API_KEY", + "FIRST", + "acct-1", + ); + kanban.upsert_jira_project( + "second.atlassian.net", + "u2@second.com", + "OPERATOR_JIRA_SECOND_API_KEY", + "SECOND", + "acct-2", + ); + + assert_eq!(kanban.jira.len(), 2); + assert_eq!(kanban.jira["first.atlassian.net"].email, "u1@first.com"); + assert_eq!( + kanban.jira["second.atlassian.net"].api_key_env, + "OPERATOR_JIRA_SECOND_API_KEY" + ); +} + +#[test] +fn test_upsert_project_jira() { + use crate::api::providers::kanban::{ + DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, + }; + + let mut kanban = KanbanConfig::default(); + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Jira, + workspace_key: "acme.atlassian.net".to_string(), + workspace_display_name: "Acme Corp".to_string(), + sync_user_id: "acct-123".to_string(), + sync_user_display_name: "Alice".to_string(), + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Jira { + email: "alice@acme.com".to_string(), + }, + }; + let project = DiscoveredProject { + workspace_key: "acme.atlassian.net".to_string(), + project_key: "PROJ".to_string(), + project_display_name: "My Project".to_string(), + provider_url: None, + provider_native_id: None, + }; + + kanban.upsert_project(&ws, &project); + + let entry = kanban + .jira + .get("acme.atlassian.net") + .expect("workspace should be created"); + assert!(entry.enabled); + assert_eq!(entry.email, "alice@acme.com"); + assert_eq!(entry.api_key_env, "OPERATOR_JIRA_API_KEY"); + let proj = entry.projects.get("PROJ").expect("project should exist"); + assert_eq!(proj.sync_user_id, "acct-123"); +} + +#[test] +fn test_upsert_project_linear() { + use crate::api::providers::kanban::{ + DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, + }; + + let mut kanban = KanbanConfig::default(); + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Linear, + workspace_key: "acme".to_string(), + workspace_display_name: "Acme Inc".to_string(), + sync_user_id: "user-uuid-1".to_string(), + sync_user_display_name: "Bob".to_string(), + api_key_env: "OPERATOR_LINEAR_API_KEY".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Linear, + }; + let project = DiscoveredProject { + workspace_key: "acme".to_string(), + project_key: "ENG".to_string(), + project_display_name: "Engineering".to_string(), + provider_url: None, + provider_native_id: None, + }; + + kanban.upsert_project(&ws, &project); + + let entry = kanban + .linear + .get("acme") + .expect("workspace should be created"); + assert!(entry.enabled); + assert_eq!(entry.api_key_env, "OPERATOR_LINEAR_API_KEY"); + let proj = entry.projects.get("ENG").expect("project should exist"); + assert_eq!(proj.sync_user_id, "user-uuid-1"); +} + +#[test] +fn test_upsert_project_github() { + use crate::api::providers::kanban::{ + DiscoveredProject, KanbanProviderType, ValidatedWorkspace, WorkspaceExtra, + }; + + let mut kanban = KanbanConfig::default(); + let ws = ValidatedWorkspace { + provider_kind: KanbanProviderType::Github, + workspace_key: "my-org".to_string(), + workspace_display_name: "github.com".to_string(), + sync_user_id: "12345678".to_string(), + sync_user_display_name: "octocat".to_string(), + api_key_env: "OPERATOR_GITHUB_TOKEN".to_string(), + prefetched_projects: None, + extra: WorkspaceExtra::Github, + }; + let project = DiscoveredProject { + workspace_key: "my-org".to_string(), + project_key: "PVT_abc".to_string(), + project_display_name: "My Board".to_string(), + provider_url: None, + provider_native_id: None, + }; + + kanban.upsert_project(&ws, &project); + + let entry = kanban + .github + .get("my-org") + .expect("workspace should be created"); + assert!(entry.enabled); + assert_eq!(entry.api_key_env, "OPERATOR_GITHUB_TOKEN"); + let proj = entry.projects.get("PVT_abc").expect("project should exist"); + assert_eq!(proj.sync_user_id, "12345678"); +} + +#[test] +fn test_relay_config_default_auto_inject_is_false() { + let config = Config::default(); + assert!(!config.relay.auto_inject_mcp); +} + +#[test] +fn test_delegator_launch_config_operator_relay_defaults_to_none() { + let toml_str = r#" + name = "test-delegator" + llm_tool = "claude" + model = "opus" + [launch_config] + yolo = false + "#; + let d: Delegator = toml::from_str(toml_str).unwrap(); + assert!(d.launch_config.as_ref().unwrap().operator_relay.is_none()); +} diff --git a/src/config/git_config.rs b/src/config/git_config.rs new file mode 100644 index 0000000..525fdaa --- /dev/null +++ b/src/config/git_config.rs @@ -0,0 +1,97 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +// ─── Git Provider Configuration ──────────────────────────────────────────── + +/// Git provider configuration for PR/MR operations +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct GitConfig { + /// Active provider (auto-detected from remote URL if not specified) + #[serde(default)] + pub provider: Option, + /// GitHub-specific configuration + #[serde(default)] + pub github: GitHubConfig, + /// GitLab-specific configuration (planned) + #[serde(default)] + pub gitlab: GitLabConfig, + /// Branch naming format (e.g., "{type}/{ticket_id}-{slug}") + #[serde(default = "default_branch_format")] + pub branch_format: String, + /// Whether to use git worktrees for per-ticket isolation (default: false) + /// When false, tickets work directly in the project directory with branches + #[serde(default)] + pub use_worktrees: bool, +} + +fn default_branch_format() -> String { + "{type}/{ticket_id}".to_string() +} + +impl Default for GitConfig { + fn default() -> Self { + Self { + provider: None, + github: GitHubConfig::default(), + gitlab: GitLabConfig::default(), + branch_format: default_branch_format(), + use_worktrees: false, + } + } +} + +/// Git provider selection +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum GitProviderConfig { + /// GitHub (github.com) + GitHub, + /// GitLab (gitlab.com or self-hosted) + GitLab, + /// Bitbucket (bitbucket.org) + Bitbucket, + /// Azure DevOps (dev.azure.com) + AzureDevOps, +} + +/// GitHub-specific configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] +#[ts(export)] +pub struct GitHubConfig { + /// Whether GitHub integration is enabled + #[serde(default = "default_true")] + pub enabled: bool, + /// Environment variable containing the GitHub token (default: `GITHUB_TOKEN`) + #[serde(default = "default_github_token_env")] + pub token_env: String, +} + +fn default_true() -> bool { + true +} + +fn default_github_token_env() -> String { + "GITHUB_TOKEN".to_string() +} + +/// GitLab-specific configuration (planned) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] +#[ts(export)] +pub struct GitLabConfig { + /// Whether GitLab integration is enabled + #[serde(default)] + pub enabled: bool, + /// Environment variable containing the GitLab token (default: `GITLAB_TOKEN`) + #[serde(default = "default_gitlab_token_env")] + pub token_env: String, + /// GitLab host (default: gitlab.com, can be self-hosted) + #[serde(default)] + pub host: Option, +} + +fn default_gitlab_token_env() -> String { + "GITLAB_TOKEN".to_string() +} diff --git a/src/config/kanban.rs b/src/config/kanban.rs new file mode 100644 index 0000000..da6827a --- /dev/null +++ b/src/config/kanban.rs @@ -0,0 +1,292 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +// ─── Kanban Provider Configuration ───────────────────────────────────────── + +/// Kanban provider configuration for syncing issues from external systems +/// +/// Providers are keyed by domain/workspace: +/// - Jira: keyed by domain (e.g., "foobar.atlassian.net") +/// - Linear: keyed by workspace slug (e.g., "myworkspace") +/// - GitHub Projects: keyed by owner login (e.g., "my-org") +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] +#[ts(export)] +pub struct KanbanConfig { + /// Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") + #[serde(default)] + pub jira: std::collections::HashMap, + /// Linear instances keyed by workspace slug + #[serde(default)] + pub linear: std::collections::HashMap, + /// 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. + #[serde(default)] + pub github: std::collections::HashMap, +} + +/// Jira Cloud provider configuration +/// +/// The domain is specified as the `HashMap` key in KanbanConfig.jira +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct JiraConfig { + /// Whether this provider is enabled + #[serde(default)] + pub enabled: bool, + /// Environment variable name containing the API key (default: `OPERATOR_JIRA_API_KEY`) + #[serde(default = "default_jira_api_key_env")] + pub api_key_env: String, + /// Atlassian account email for authentication + #[serde(default)] + pub email: String, + /// Per-project sync configuration + #[serde(default)] + pub projects: std::collections::HashMap, +} + +fn default_jira_api_key_env() -> String { + "OPERATOR_JIRA_API_KEY".to_string() +} + +impl Default for JiraConfig { + fn default() -> Self { + Self { + enabled: false, + api_key_env: default_jira_api_key_env(), + email: String::new(), + projects: std::collections::HashMap::new(), + } + } +} + +/// Linear provider configuration +/// +/// The workspace slug is specified as the `HashMap` key in KanbanConfig.linear +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct LinearConfig { + /// Whether this provider is enabled + #[serde(default)] + pub enabled: bool, + /// Environment variable name containing the API key (default: `OPERATOR_LINEAR_API_KEY`) + #[serde(default = "default_linear_api_key_env")] + pub api_key_env: String, + /// Per-team sync configuration + #[serde(default)] + pub projects: std::collections::HashMap, +} + +fn default_linear_api_key_env() -> String { + "OPERATOR_LINEAR_API_KEY".to_string() +} + +impl Default for LinearConfig { + fn default() -> Self { + Self { + enabled: false, + api_key_env: default_linear_api_key_env(), + projects: std::collections::HashMap::new(), + } + } +} + +/// GitHub Projects v2 (kanban) provider configuration +/// +/// The owner login (user or org) is specified as the `HashMap` key in +/// `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node +/// IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly +/// by every GitHub Projects v2 mutation without needing a lookup. +/// +/// **Distinct from `GitHubConfig`** (the git provider used for PR/branch +/// operations). They live in different parts of the config tree, use +/// different env vars (`OPERATOR_GITHUB_TOKEN` vs `GITHUB_TOKEN`), and +/// require different OAuth scopes (`project` vs `repo`). See +/// `docs/getting-started/kanban/github.md` for the full rationale. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct GithubProjectsConfig { + /// Whether this provider is enabled + #[serde(default)] + pub enabled: bool, + /// Environment variable name containing the GitHub token (default: + /// `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or + /// `read:project`) scope, NOT just `repo` — see the disambiguation + /// guide in the kanban github docs. + #[serde(default = "default_github_projects_api_key_env")] + pub api_key_env: String, + /// Per-project sync configuration. Keys are `GraphQL` project node IDs. + #[serde(default)] + pub projects: std::collections::HashMap, +} + +fn default_github_projects_api_key_env() -> String { + "OPERATOR_GITHUB_TOKEN".to_string() +} + +impl Default for GithubProjectsConfig { + fn default() -> Self { + Self { + enabled: false, + api_key_env: default_github_projects_api_key_env(), + projects: std::collections::HashMap::new(), + } + } +} + +impl KanbanConfig { + /// Insert or update a Jira project entry in the config. + /// + /// If the workspace (keyed by domain) doesn't exist, it is created with + /// `enabled = true` and the provided email + `api_key_env`. If it already + /// exists, the email and `api_key_env` are updated and the project is + /// upserted into its `projects` map without clobbering sibling projects. + pub fn upsert_jira_project( + &mut self, + domain: &str, + email: &str, + api_key_env: &str, + project_key: &str, + sync_user_id: &str, + ) { + let entry = self.jira.entry(domain.to_string()).or_default(); + entry.enabled = true; + entry.email = email.to_string(); + entry.api_key_env = api_key_env.to_string(); + entry.projects.insert( + project_key.to_string(), + ProjectSyncConfig { + sync_user_id: sync_user_id.to_string(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: std::collections::HashMap::new(), + bidirectional: false, + }, + ); + } + + /// Insert or update a Linear team entry in the config. + /// + /// If the workspace (keyed by workspace slug) doesn't exist, it is + /// created with `enabled = true` and the provided `api_key_env`. If it + /// already exists, the `api_key_env` is updated and the project/team is + /// upserted into its `projects` map without clobbering siblings. + pub fn upsert_linear_project( + &mut self, + workspace: &str, + api_key_env: &str, + project_key: &str, + sync_user_id: &str, + ) { + let entry = self.linear.entry(workspace.to_string()).or_default(); + entry.enabled = true; + entry.api_key_env = api_key_env.to_string(); + entry.projects.insert( + project_key.to_string(), + ProjectSyncConfig { + sync_user_id: sync_user_id.to_string(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: std::collections::HashMap::new(), + bidirectional: false, + }, + ); + } + + /// Insert or update a GitHub Projects v2 entry in the config. + /// + /// If the owner (keyed by login) doesn't exist, it is created with + /// `enabled = true` and the provided `api_key_env`. If it already + /// exists, the `api_key_env` is updated and the project is upserted + /// into its `projects` map without clobbering siblings. + /// + /// `project_key` is the `GraphQL` project node ID (e.g., `PVT_kwDO...`) + /// and `sync_user_id` is the user's numeric GitHub `databaseId`. + pub fn upsert_github_project( + &mut self, + owner: &str, + api_key_env: &str, + project_key: &str, + sync_user_id: &str, + ) { + let entry = self.github.entry(owner.to_string()).or_default(); + entry.enabled = true; + entry.api_key_env = api_key_env.to_string(); + entry.projects.insert( + project_key.to_string(), + ProjectSyncConfig { + sync_user_id: sync_user_id.to_string(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: std::collections::HashMap::new(), + bidirectional: false, + }, + ); + } + + /// Provider-neutral upsert dispatcher. + /// + /// Delegates to the provider-specific upsert method based on the + /// `WorkspaceExtra` variant in the validated workspace. + #[allow(dead_code)] // Will be used by onboarding service in Phase 1b + pub fn upsert_project( + &mut self, + workspace: &crate::api::providers::kanban::ValidatedWorkspace, + project: &crate::api::providers::kanban::DiscoveredProject, + ) { + use crate::api::providers::kanban::WorkspaceExtra; + match &workspace.extra { + WorkspaceExtra::Jira { email } => self.upsert_jira_project( + &workspace.workspace_key, + email, + &workspace.api_key_env, + &project.project_key, + &workspace.sync_user_id, + ), + WorkspaceExtra::Linear => self.upsert_linear_project( + &workspace.workspace_key, + &workspace.api_key_env, + &project.project_key, + &workspace.sync_user_id, + ), + WorkspaceExtra::Github => self.upsert_github_project( + &workspace.workspace_key, + &workspace.api_key_env, + &project.project_key, + &workspace.sync_user_id, + ), + } + } +} + +/// Per-project/team sync configuration for a kanban provider +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ProjectSyncConfig { + /// User ID to sync issues for (provider-specific format) + /// - Jira: accountId (e.g., "5e3f7acd9876543210abcdef") + /// - Linear: user ID (e.g., "abc12345-6789-0abc-def0-123456789abc") + /// - GitHub Projects: numeric GitHub `databaseId` (e.g., "12345678") + #[serde(default)] + pub sync_user_id: String, + /// Workflow statuses to sync (empty = default/first status only) + #[serde(default)] + pub sync_statuses: Vec, + /// Optional `IssueTypeCollection` name this project maps to. + /// Not required for kanban onboarding or sync. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collection_name: Option, + /// Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). + /// Multiple kanban types can map to the same operator template. + #[serde(default)] + pub type_mappings: std::collections::HashMap, + /// When true, operator pushes status changes and activity logs back to this kanban project. + /// Ticket state changes (todo→doing, doing→done) and step completions with delegator info + /// are reflected upstream. Default: false. + #[serde(default)] + pub bidirectional: bool, +} diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs new file mode 100644 index 0000000..d6c4619 --- /dev/null +++ b/src/config/llm_tools.rs @@ -0,0 +1,243 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// LLM CLI tools configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS)] +#[ts(export)] +pub struct LlmToolsConfig { + /// Detected CLI tools (populated on first startup) + #[serde(default)] + pub detected: Vec, + + /// Available {tool, model} pairs for launching tickets + /// Built from detected tools + their model aliases + #[serde(default)] + pub providers: Vec, + + /// Whether detection has been completed + #[serde(default)] + pub detection_complete: bool, + + /// User's preferred default LLM tool (e.g., "claude") + #[serde(default)] + pub default_tool: Option, + + /// User's preferred default model alias (e.g., "opus") + #[serde(default)] + pub default_model: Option, + + /// Per-tool overrides for skill directories (keyed by `tool_name`) + #[serde(default)] + pub skill_directory_overrides: std::collections::HashMap, +} + +/// A detected CLI tool (e.g., claude binary) +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct DetectedTool { + /// Tool name (e.g., "claude") + pub name: String, + /// Path to the binary + pub path: String, + /// Version string + pub version: String, + /// Minimum required version for Operator compatibility + #[serde(default)] + pub min_version: Option, + /// Whether the installed version meets the minimum requirement + #[serde(default)] + pub version_ok: bool, + /// Available model aliases (e.g., ["opus", "sonnet", "haiku"]) + #[serde(default)] + pub model_aliases: Vec, + /// Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders + #[serde(default)] + pub command_template: String, + /// Tool capabilities + #[serde(default)] + pub capabilities: ToolCapabilities, + /// CLI flags for YOLO (auto-accept) mode + #[serde(default)] + pub yolo_flags: Vec, +} + +/// Tool capabilities +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct ToolCapabilities { + /// Whether the tool supports session continuity via UUID + #[serde(default)] + pub supports_sessions: bool, + /// Whether the tool can run in headless/non-interactive mode + #[serde(default)] + pub supports_headless: bool, +} + +/// A {tool, model} pair that can be selected when launching tickets. +/// Includes optional variant fields adopted from vibe-kanban's profile system. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[ts(export)] +pub struct LlmProvider { + /// CLI tool name (e.g., "claude", "codex", "gemini") + pub tool: String, + /// Model alias or name (e.g., "opus", "sonnet", "gpt-4.1") + pub model: String, + /// Optional display name for UI (e.g., "Claude Opus", "Codex High") + #[serde(default)] + pub display_name: Option, + + // ─── Variant fields (all optional) ─────────────────────────────── + /// Additional CLI flags for this provider (e.g., ["--dangerously-skip-permissions"]) + #[serde(default)] + pub flags: Vec, + + /// Environment variables to set when launching + #[serde(default)] + pub env: std::collections::HashMap, + + /// Whether this provider requires approval gates + #[serde(default)] + pub approvals: bool, + + /// Whether to run in plan-only mode + #[serde(default)] + pub plan_only: bool, + + /// Reasoning effort level (Codex: "low", "medium", "high") + #[serde(default)] + pub reasoning_effort: Option, + + /// Sandbox mode (Codex: "danger-full-access", "workspace-write") + #[serde(default)] + pub sandbox: Option, +} + +/// Per-tool skill directory overrides +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SkillDirectoriesOverride { + /// Additional global skill directories + #[serde(default)] + pub global: Vec, + /// Additional project-relative skill directories + #[serde(default)] + pub project: Vec, +} + +/// Agent delegator configuration for autonomous ticket launching +/// +/// A delegator is a named {tool, model} pairing with optional launch configuration +/// that can be used to launch agents for tickets. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct Delegator { + /// Unique name for this delegator (e.g., "claude-opus-auto") + pub name: String, + /// LLM tool name (must match a detected tool, e.g., "claude", "codex") + pub llm_tool: String, + /// Model alias (e.g., "opus", "sonnet", "gpt-4o") + pub model: String, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, + /// Arbitrary model properties (e.g., `reasoning_effort`, sandbox) + #[serde(default)] + pub model_properties: std::collections::HashMap, + /// Optional launch configuration + #[serde(default)] + pub launch_config: Option, + /// Name of a declared `ModelServer` (from `Config.model_servers`). + /// `None` means use the `llm_tool`'s implicit vendor default + /// (claude → anthropic-api, codex → openai-api, gemini → google-api). + #[serde(default)] + pub model_server: Option, +} + +/// A named host that serves models via an inference API. +/// +/// Model servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI +/// (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint +/// (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). +/// +/// Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are +/// returned by [`implicit_model_server_for_tool`] and do not need to be declared +/// in config. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServer { + /// Unique name (e.g., "ollama-local", "vllm-gpu1") + pub name: String, + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint (e.g., `http://localhost:11434`). + /// `None` for implicit vendor servers means use the SDK default. + #[serde(default)] + pub base_url: Option, + /// Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + #[serde(default)] + pub api_key_env: Option, + /// Additional environment variables set when spawning agents that use this server + #[serde(default)] + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, +} + +/// Returns the implicit builtin `ModelServer` associated with a given `llm_tool`. +/// +/// Used when a `Delegator` has no explicit `model_server`. Unknown tools +/// fall back to an `"openai-api"` server so arbitrary future tools still resolve. +pub fn implicit_model_server_for_tool(tool: &str) -> ModelServer { + let (name, kind) = match tool { + "claude" => ("anthropic-api", "anthropic-api"), + "codex" => ("openai-api", "openai-api"), + "gemini" => ("google-api", "google-api"), + _ => ("openai-api", "openai-api"), + }; + ModelServer { + name: name.to_string(), + kind: kind.to_string(), + base_url: None, + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + } +} + +/// Launch configuration for a delegator +/// +/// Controls how the delegator launches agents. Optional fields use tri-state +/// semantics: `None` = inherit from global config, `Some(true/false)` = override. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorLaunchConfig { + /// Run in YOLO (auto-accept) mode + #[serde(default)] + pub yolo: bool, + /// Permission mode override + #[serde(default)] + pub permission_mode: Option, + /// Additional CLI flags + #[serde(default)] + pub flags: Vec, + /// Override global `git.use_worktrees` per-delegator (None = use global setting) + #[serde(default)] + pub use_worktrees: Option, + /// 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`) + #[serde(default)] + pub docker: Option, + /// Prompt text to prepend before the generated step prompt + #[serde(default)] + pub prompt_prefix: Option, + /// Prompt text to append after the generated step prompt + #[serde(default)] + pub prompt_suffix: Option, + /// Override global relay auto-inject MCP setting per-delegator (None = use global setting) + #[serde(default)] + pub operator_relay: Option, +} diff --git a/src/config/notifications_config.rs b/src/config/notifications_config.rs new file mode 100644 index 0000000..5c2d575 --- /dev/null +++ b/src/config/notifications_config.rs @@ -0,0 +1,140 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Notifications configuration with support for multiple integrations. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct NotificationsConfig { + /// Global enabled flag for all notifications + pub enabled: bool, + + /// OS notification configuration + #[serde(default)] + pub os: OsNotificationConfig, + + /// Single webhook configuration (for simple setups) + #[serde(default)] + pub webhook: Option, + + /// Multiple webhook configurations + #[serde(default)] + pub webhooks: Vec, + + // Legacy fields for backwards compatibility + // These are deprecated but still supported for existing configs + #[serde(default = "default_true")] + #[schemars(skip)] + #[ts(skip)] + pub on_agent_start: bool, + #[serde(default = "default_true")] + #[schemars(skip)] + #[ts(skip)] + pub on_agent_complete: bool, + #[serde(default = "default_true")] + #[schemars(skip)] + #[ts(skip)] + pub on_agent_needs_input: bool, + #[serde(default = "default_true")] + #[schemars(skip)] + #[ts(skip)] + pub on_pr_created: bool, + #[serde(default = "default_true")] + #[schemars(skip)] + #[ts(skip)] + pub on_investigation_created: bool, + #[serde(default)] + #[schemars(skip)] + #[ts(skip)] + pub sound: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + enabled: true, + os: OsNotificationConfig::default(), + webhook: None, + webhooks: Vec::new(), + // Legacy fields + on_agent_start: true, + on_agent_complete: true, + on_agent_needs_input: true, + on_pr_created: true, + on_investigation_created: true, + sound: false, + } + } +} + +/// OS notification configuration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct OsNotificationConfig { + /// Whether OS notifications are enabled + #[serde(default = "default_true")] + pub enabled: bool, + + /// Play sound with notifications + #[serde(default)] + pub sound: bool, + + /// Events to send (empty = all events) + /// Possible values: agent.started, agent.completed, agent.failed, + /// `agent.awaiting_input`, `agent.session_lost`, pr.created, pr.merged, + /// pr.closed, `pr.ready_to_merge`, `pr.changes_requested`, + /// ticket.returned, investigation.created + #[serde(default)] + pub events: Vec, +} + +impl Default for OsNotificationConfig { + fn default() -> Self { + Self { + enabled: true, + sound: false, + events: Vec::new(), // All events + } + } +} + +/// Webhook notification configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct WebhookConfig { + /// Optional name for this webhook (for logging) + #[serde(default)] + pub name: Option, + + /// Whether this webhook is enabled + #[serde(default)] + pub enabled: bool, + + /// Webhook URL + #[serde(default)] + pub url: String, + + /// Authentication type: "bearer" or "basic" + #[serde(default)] + pub auth_type: Option, + + /// Environment variable containing the bearer token + #[serde(default)] + pub token_env: Option, + + /// Username for basic auth + #[serde(default)] + pub username: Option, + + /// Environment variable containing the password for basic auth + #[serde(default)] + pub password_env: Option, + + /// Events to send (empty = all events) + #[serde(default)] + pub events: Option>, +} diff --git a/src/config/sessions.rs b/src/config/sessions.rs new file mode 100644 index 0000000..f88fe79 --- /dev/null +++ b/src/config/sessions.rs @@ -0,0 +1,213 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Session wrapper type for terminal session management +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum SessionWrapperType { + /// Standalone tmux sessions (default) + #[default] + Tmux, + /// VS Code integrated terminal (via extension webhook) + Vscode, + /// cmux macOS terminal multiplexer + Cmux, + /// Zellij terminal workspace manager + Zellij, +} + +impl SessionWrapperType { + /// Short display name for the wrapper (used in header bar, logs) + pub fn display_name(&self) -> &'static str { + match self { + SessionWrapperType::Tmux => "tmux", + SessionWrapperType::Vscode => "vscode", + SessionWrapperType::Cmux => "cmux", + SessionWrapperType::Zellij => "zellij", + } + } +} + +impl std::fmt::Display for SessionWrapperType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display_name()) + } +} + +/// Session wrapper configuration +/// +/// Controls how operator creates and manages terminal sessions for agents. +/// Four modes are supported: +/// - tmux: Standalone tmux sessions (default) +/// - vscode: VS Code integrated terminal (requires extension) +/// - cmux: macOS terminal multiplexer (requires running inside cmux) +/// - zellij: Zellij terminal workspace manager +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SessionsConfig { + /// Which session wrapper to use + #[serde(default)] + pub wrapper: SessionWrapperType, + + /// Tmux-specific configuration + #[serde(default)] + pub tmux: SessionsTmuxConfig, + + /// VS Code-specific configuration + #[serde(default)] + pub vscode: SessionsVSCodeConfig, + + /// cmux-specific configuration + #[serde(default)] + pub cmux: SessionsCmuxConfig, + + /// Zellij-specific configuration + #[serde(default)] + pub zellij: SessionsZellijConfig, +} + +impl Default for SessionsConfig { + fn default() -> Self { + Self { + wrapper: SessionWrapperType::Tmux, + tmux: SessionsTmuxConfig::default(), + vscode: SessionsVSCodeConfig::default(), + cmux: SessionsCmuxConfig::default(), + zellij: SessionsZellijConfig::default(), + } + } +} + +/// Tmux-specific session configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SessionsTmuxConfig { + /// Whether custom tmux config has been generated + #[serde(default)] + pub config_generated: bool, + + /// Socket name for session isolation + #[serde(default = "default_socket_name")] + pub socket_name: String, +} + +fn default_socket_name() -> String { + "operator".to_string() +} + +impl Default for SessionsTmuxConfig { + fn default() -> Self { + Self { + config_generated: false, + socket_name: default_socket_name(), + } + } +} + +/// VS Code extension session configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SessionsVSCodeConfig { + /// Port for extension webhook server + #[serde(default = "default_vscode_webhook_port")] + pub webhook_port: u16, + + /// Connection timeout in milliseconds + #[serde(default = "default_vscode_connect_timeout")] + pub connect_timeout_ms: u64, +} + +fn default_vscode_webhook_port() -> u16 { + 7009 +} + +fn default_vscode_connect_timeout() -> u64 { + 5000 +} + +impl Default for SessionsVSCodeConfig { + fn default() -> Self { + Self { + webhook_port: default_vscode_webhook_port(), + connect_timeout_ms: default_vscode_connect_timeout(), + } + } +} + +/// Placement policy for cmux sessions: where to create new agent terminals +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum CmuxPlacementPolicy { + /// Automatically choose: 0-1 windows → new workspace, >1 windows → new window + #[default] + Auto, + /// Always create a new workspace in the active window + Workspace, + /// Always create a new window for each ticket + Window, +} + +impl std::fmt::Display for CmuxPlacementPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CmuxPlacementPolicy::Auto => write!(f, "auto"), + CmuxPlacementPolicy::Workspace => write!(f, "workspace"), + CmuxPlacementPolicy::Window => write!(f, "window"), + } + } +} + +/// cmux macOS terminal multiplexer session configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SessionsCmuxConfig { + /// Path to the cmux binary + #[serde(default = "default_cmux_binary_path")] + pub binary_path: String, + + /// Require running inside cmux (`CMUX_WORKSPACE_ID` env var present) + #[serde(default = "default_true_val")] + pub require_in_cmux: bool, + + /// Where to place new agent sessions: "auto", "workspace", or "window" + #[serde(default)] + pub placement: CmuxPlacementPolicy, +} + +fn default_cmux_binary_path() -> String { + "/Applications/cmux.app/Contents/Resources/bin/cmux".to_string() +} + +fn default_true_val() -> bool { + true +} + +impl Default for SessionsCmuxConfig { + fn default() -> Self { + Self { + binary_path: default_cmux_binary_path(), + require_in_cmux: default_true_val(), + placement: CmuxPlacementPolicy::default(), + } + } +} + +/// Zellij terminal workspace manager session configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SessionsZellijConfig { + /// Require running inside Zellij (ZELLIJ env var present) + #[serde(default = "default_true_val")] + pub require_in_zellij: bool, +} + +impl Default for SessionsZellijConfig { + fn default() -> Self { + Self { + require_in_zellij: default_true_val(), + } + } +} diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index 0ae8be0..0517e12 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -66,6 +66,9 @@ pub struct Ticket { pub external_url: Option, /// Provider name for the external issue (e.g., "jira", "linear") pub external_provider: Option, + /// Delegator name used per completed step (`step_name` → `delegator_name`). + /// Populated when a step is launched; used for bidirectional kanban activity logs. + pub step_delegators: HashMap, } impl Ticket { @@ -90,13 +93,16 @@ impl Ticket { step, summary, sessions, + step_delegators, llm_task, worktree_path, branch, external_id, external_url, external_provider, - ) = if let Some((frontmatter, sessions, llm_task, body)) = extract_frontmatter(&content) { + ) = if let Some((frontmatter, sessions, step_delegators, llm_task, body)) = + extract_frontmatter(&content) + { let id = frontmatter .get("id") .cloned() @@ -126,6 +132,7 @@ impl Ticket { step, summary, sessions, + step_delegators, llm_task, worktree_path, branch, @@ -149,6 +156,7 @@ impl Ticket { step, summary, HashMap::new(), + HashMap::new(), LlmTask::default(), None, None, @@ -171,6 +179,7 @@ impl Ticket { step, content, sessions, + step_delegators, llm_task, worktree_path, branch, @@ -220,7 +229,7 @@ impl Ticket { /// Update a frontmatter field in the ticket file and save pub fn update_field(&mut self, field: &str, value: &str) -> Result<()> { // Parse frontmatter - if let Some((mut frontmatter, sessions, llm_task, body)) = + if let Some((mut frontmatter, sessions, step_delegators, llm_task, body)) = extract_frontmatter(&self.content) { frontmatter.insert(field.to_string(), value.to_string()); @@ -239,6 +248,14 @@ impl Ticket { } } + // Add step_delegators if present + if !step_delegators.is_empty() { + yaml_lines.push("step_delegators:".to_string()); + for (step_name, delegator_name) in &step_delegators { + yaml_lines.push(format!(" {step_name}: {delegator_name}")); + } + } + // Add llm_task if present if llm_task.id.is_some() || llm_task.status.is_some() || !llm_task.blocked_by.is_empty() { @@ -375,6 +392,13 @@ impl Ticket { self.save_sessions_to_frontmatter() } + /// Set the delegator name for a specific step and save to frontmatter + pub fn set_step_delegator(&mut self, step_name: &str, delegator_name: &str) -> Result<()> { + self.step_delegators + .insert(step_name.to_string(), delegator_name.to_string()); + self.save_step_delegators_to_frontmatter() + } + /// Set the LLM task ID and save to frontmatter pub fn set_llm_task_id(&mut self, id: &str) -> Result<()> { self.llm_task.id = Some(id.to_string()); @@ -577,6 +601,67 @@ impl Ticket { } lines.join("\n") } + + /// Save the `step_delegators` map to the ticket frontmatter + fn save_step_delegators_to_frontmatter(&mut self) -> Result<()> { + let content = self.content.trim_start(); + + if !content.starts_with("---") { + let step_delegators_yaml = self.format_step_delegators_yaml(); + let new_content = format!( + "---\nid: {}\nstatus: {}\npriority: {}\nstep: {}\n{}\n---\n{}", + self.id, self.status, self.priority, self.step, step_delegators_yaml, content + ); + self.content = new_content.clone(); + fs::write(&self.filepath, new_content).context("Failed to write ticket file")?; + return Ok(()); + } + + let after_open = &content[3..]; + if let Some(end_idx) = after_open.find("\n---") { + let yaml_str = &after_open[..end_idx]; + let rest = &after_open[end_idx + 4..]; + + let mut frontmatter: serde_yaml::Value = serde_yaml::from_str(yaml_str) + .unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + + if let serde_yaml::Value::Mapping(ref mut map) = frontmatter { + let mut delegators_map = serde_yaml::Mapping::new(); + for (step_name, delegator_name) in &self.step_delegators { + delegators_map.insert( + serde_yaml::Value::String(step_name.clone()), + serde_yaml::Value::String(delegator_name.clone()), + ); + } + map.insert( + serde_yaml::Value::String("step_delegators".to_string()), + serde_yaml::Value::Mapping(delegators_map), + ); + } + + let new_yaml = + serde_yaml::to_string(&frontmatter).context("Failed to serialize frontmatter")?; + + let new_content = format!("---\n{new_yaml}---{rest}"); + self.content = new_content.clone(); + fs::write(&self.filepath, new_content).context("Failed to write ticket file")?; + } + + Ok(()) + } + + /// Format `step_delegators` as YAML for frontmatter + fn format_step_delegators_yaml(&self) -> String { + if self.step_delegators.is_empty() { + return String::new(); + } + + let mut lines = vec!["step_delegators:".to_string()]; + for (step_name, delegator_name) in &self.step_delegators { + lines.push(format!(" {step_name}: {delegator_name}")); + } + lines.join("\n") + } } /// Extract sessions mapping from YAML frontmatter value @@ -596,6 +681,23 @@ fn extract_sessions_from_yaml( } } +/// Extract `step_delegators` mapping from YAML frontmatter value +fn extract_step_delegators_from_yaml( + frontmatter: &HashMap, +) -> HashMap { + if let Some(serde_yaml::Value::Mapping(map)) = frontmatter.get("step_delegators") { + map.iter() + .filter_map(|(k, v)| { + let key = k.as_str()?.to_string(); + let value = v.as_str()?.to_string(); + Some((key, value)) + }) + .collect() + } else { + HashMap::new() + } +} + /// Extract LLM task metadata from YAML frontmatter value fn extract_llm_task_from_yaml(frontmatter: &HashMap) -> LlmTask { if let Some(serde_yaml::Value::Mapping(map)) = frontmatter.get("llm_task") { @@ -630,11 +732,12 @@ fn extract_llm_task_from_yaml(frontmatter: &HashMap) } /// Extract YAML frontmatter from markdown content -/// Returns the parsed frontmatter as a `HashMap`, sessions `HashMap`, `LlmTask`, and the content after the frontmatter +/// Returns the parsed frontmatter as a `HashMap`, sessions `HashMap`, `step_delegators` `HashMap`, `LlmTask`, and the content after the frontmatter #[allow(clippy::type_complexity)] fn extract_frontmatter( content: &str, ) -> Option<( + HashMap, HashMap, HashMap, LlmTask, @@ -657,6 +760,9 @@ fn extract_frontmatter( // Extract sessions before converting to strings let sessions = extract_sessions_from_yaml(&frontmatter); + // Extract step delegators per step + let step_delegators = extract_step_delegators_from_yaml(&frontmatter); + // Extract LLM task metadata let llm_task = extract_llm_task_from_yaml(&frontmatter); @@ -677,7 +783,7 @@ fn extract_frontmatter( }) .collect(); - Some((string_map, sessions, llm_task, rest)) + Some((string_map, sessions, step_delegators, llm_task, rest)) } fn parse_filename(filename: &str) -> Result<(String, String, String)> { @@ -884,7 +990,8 @@ status: queued # Feature: Test feature "; - let (frontmatter, _sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); + let (frontmatter, _sessions, _step_delegators, _llm_task, _body) = + extract_frontmatter(content).unwrap(); let step = frontmatter.get("step").cloned().unwrap_or_default(); assert!( step.is_empty(), @@ -902,7 +1009,8 @@ status: queued # Feature: Test feature "; - let (frontmatter, _sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); + let (frontmatter, _sessions, _step_delegators, _llm_task, _body) = + extract_frontmatter(content).unwrap(); let step = frontmatter.get("step").cloned().unwrap_or_default(); assert!( step.is_empty(), @@ -959,7 +1067,8 @@ sessions: # Feature: Test feature "; - let (frontmatter, sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); + let (frontmatter, sessions, _step_delegators, _llm_task, _body) = + extract_frontmatter(content).unwrap(); assert_eq!(frontmatter.get("id").unwrap(), "FEAT-1234"); assert_eq!(sessions.len(), 2); assert_eq!( @@ -981,7 +1090,8 @@ status: queued # Feature: Test feature "; - let (_frontmatter, sessions, _llm_task, _body) = extract_frontmatter(content).unwrap(); + let (_frontmatter, sessions, _step_delegators, _llm_task, _body) = + extract_frontmatter(content).unwrap(); assert!(sessions.is_empty()); } @@ -1098,7 +1208,8 @@ llm_task: # Feature: Test feature "; - let (_frontmatter, _sessions, llm_task, _body) = extract_frontmatter(content).unwrap(); + let (_frontmatter, _sessions, _step_delegators, llm_task, _body) = + extract_frontmatter(content).unwrap(); assert_eq!( llm_task.id, Some("abc12345-6789-0abc-def0-123456789abc".to_string()) @@ -1118,7 +1229,8 @@ status: queued # Feature: Test feature "; - let (_frontmatter, _sessions, llm_task, _body) = extract_frontmatter(content).unwrap(); + let (_frontmatter, _sessions, _step_delegators, llm_task, _body) = + extract_frontmatter(content).unwrap(); assert_eq!(llm_task, LlmTask::default()); } diff --git a/src/rest/dto.rs b/src/rest/dto.rs deleted file mode 100644 index dbf384e..0000000 --- a/src/rest/dto.rs +++ /dev/null @@ -1,1712 +0,0 @@ -//! Data Transfer Objects for the REST API. - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ts_rs::TS; -use utoipa::ToSchema; - -// Note: ToSchema is derived on all DTOs for OpenAPI documentation generation - -use crate::issuetypes::schema::IssueTypeSource; -use crate::issuetypes::{IssueType, IssueTypeCollection}; -use crate::templates::schema::{ - ExecutionMode, FieldSchema, FieldType, PermissionMode, StepOutput, StepSchema, -}; - -// ============================================================================= -// Issue Type DTOs -// ============================================================================= - -/// Response for a single issue type -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct IssueTypeResponse { - pub key: String, - pub name: String, - pub description: String, - pub mode: String, - pub glyph: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub color: Option, - pub project_required: bool, - pub source: String, - pub fields: Vec, - pub steps: Vec, -} - -impl From<&IssueType> for IssueTypeResponse { - fn from(it: &IssueType) -> Self { - Self { - key: it.key.clone(), - name: it.name.clone(), - description: it.description.clone(), - mode: match it.mode { - ExecutionMode::Autonomous => "autonomous".to_string(), - ExecutionMode::Paired => "paired".to_string(), - }, - glyph: it.glyph.clone(), - color: it.color.clone(), - project_required: it.project_required, - source: it.source_display(), - fields: it.fields.iter().map(FieldResponse::from).collect(), - steps: it.steps.iter().map(StepResponse::from).collect(), - } - } -} - -/// Summary response for listing issue types -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -pub struct IssueTypeSummary { - pub key: String, - pub name: String, - pub description: String, - pub mode: String, - pub glyph: String, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub color: Option, - pub source: String, - pub step_count: usize, -} - -impl From<&IssueType> for IssueTypeSummary { - fn from(it: &IssueType) -> Self { - Self { - key: it.key.clone(), - name: it.name.clone(), - description: it.description.clone(), - mode: match it.mode { - ExecutionMode::Autonomous => "autonomous".to_string(), - ExecutionMode::Paired => "paired".to_string(), - }, - glyph: it.glyph.clone(), - color: it.color.clone(), - source: it.source_display(), - step_count: it.steps.len(), - } - } -} - -/// Request to create a new issue type -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CreateIssueTypeRequest { - pub key: String, - pub name: String, - pub description: String, - #[serde(default = "default_mode")] - pub mode: String, - pub glyph: String, - #[serde(default)] - pub color: Option, - #[serde(default = "default_true")] - pub project_required: bool, - #[serde(default)] - pub fields: Vec, - pub steps: Vec, -} - -fn default_mode() -> String { - "autonomous".to_string() -} - -fn default_true() -> bool { - true -} - -impl CreateIssueTypeRequest { - /// Convert request to `IssueType` - pub fn into_issue_type(self) -> IssueType { - IssueType { - key: self.key.to_uppercase(), - name: self.name, - description: self.description, - mode: if self.mode == "paired" { - ExecutionMode::Paired - } else { - ExecutionMode::Autonomous - }, - glyph: self.glyph, - color: self.color, - project_required: self.project_required, - fields: self - .fields - .into_iter() - .map(std::convert::Into::into) - .collect(), - steps: self - .steps - .into_iter() - .map(std::convert::Into::into) - .collect(), - agent_prompt: None, - agent: None, - source: IssueTypeSource::User, - external_id: None, - } - } -} - -/// Request to update an issue type -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct UpdateIssueTypeRequest { - #[serde(default)] - pub name: Option, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub mode: Option, - #[serde(default)] - pub glyph: Option, - #[serde(default)] - pub color: Option, - #[serde(default)] - pub project_required: Option, - #[serde(default)] - pub fields: Option>, - #[serde(default)] - pub steps: Option>, -} - -// ============================================================================= -// Field DTOs -// ============================================================================= - -/// Response for a field -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct FieldResponse { - pub name: String, - pub description: String, - pub field_type: String, - pub required: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub options: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub placeholder: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub max_length: Option, - pub user_editable: bool, -} - -impl From<&FieldSchema> for FieldResponse { - fn from(f: &FieldSchema) -> Self { - Self { - name: f.name.clone(), - description: f.description.clone(), - field_type: match f.field_type { - FieldType::String => "string".to_string(), - FieldType::Enum => "enum".to_string(), - FieldType::Bool => "bool".to_string(), - FieldType::Date => "date".to_string(), - FieldType::Text => "text".to_string(), - FieldType::Integer => "integer".to_string(), - }, - required: f.required, - default: f.default.clone(), - options: f.options.clone(), - placeholder: f.placeholder.clone(), - max_length: f.max_length, - user_editable: f.user_editable, - } - } -} - -/// Request to create a field -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CreateFieldRequest { - pub name: String, - pub description: String, - #[serde(default = "default_string_type")] - pub field_type: String, - #[serde(default)] - pub required: bool, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub options: Vec, - #[serde(default)] - pub placeholder: Option, - #[serde(default)] - pub max_length: Option, - #[serde(default = "default_true")] - pub user_editable: bool, -} - -fn default_string_type() -> String { - "string".to_string() -} - -impl From for FieldSchema { - fn from(f: CreateFieldRequest) -> Self { - Self { - name: f.name, - description: f.description, - field_type: match f.field_type.as_str() { - "enum" => FieldType::Enum, - "bool" => FieldType::Bool, - "date" => FieldType::Date, - "text" => FieldType::Text, - _ => FieldType::String, - }, - required: f.required, - default: f.default, - auto: None, - options: f.options, - placeholder: f.placeholder, - max_length: f.max_length, - display_order: None, - user_editable: f.user_editable, - } - } -} - -// ============================================================================= -// Step DTOs -// ============================================================================= - -/// Response for a step -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct StepResponse { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, - pub prompt: String, - pub outputs: Vec, - pub allowed_tools: Vec, - /// Type of review required: "none", "plan", "visual", "pr" - pub review_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_step: Option, - pub permission_mode: String, -} - -impl From<&StepSchema> for StepResponse { - fn from(s: &StepSchema) -> Self { - Self { - name: s.name.clone(), - display_name: s.display_name.clone(), - prompt: s.prompt.clone(), - outputs: s - .outputs - .iter() - .map(|o| match o { - StepOutput::Plan => "plan".to_string(), - StepOutput::Code => "code".to_string(), - StepOutput::Test => "test".to_string(), - StepOutput::Pr => "pr".to_string(), - StepOutput::Ticket => "ticket".to_string(), - StepOutput::Review => "review".to_string(), - StepOutput::Report => "report".to_string(), - StepOutput::Documentation => "documentation".to_string(), - }) - .collect(), - allowed_tools: s.allowed_tools.clone(), - review_type: match s.review_type { - crate::templates::schema::ReviewType::None => "none".to_string(), - crate::templates::schema::ReviewType::Plan => "plan".to_string(), - crate::templates::schema::ReviewType::Visual => "visual".to_string(), - crate::templates::schema::ReviewType::Pr => "pr".to_string(), - }, - next_step: s.next_step.clone(), - permission_mode: match s.permission_mode { - PermissionMode::Default => "default".to_string(), - PermissionMode::Plan => "plan".to_string(), - PermissionMode::AcceptEdits => "acceptEdits".to_string(), - PermissionMode::Delegate => "delegate".to_string(), - }, - } - } -} - -/// Request to create a step -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CreateStepRequest { - pub name: String, - #[serde(default)] - pub display_name: Option, - pub prompt: String, - #[serde(default)] - pub outputs: Vec, - #[serde(default = "default_all_tools")] - pub allowed_tools: Vec, - /// Type of review required: "none", "plan", "visual", "pr" - #[serde(default = "default_review_type")] - pub review_type: String, - #[serde(default)] - pub next_step: Option, - #[serde(default = "default_permission_mode")] - pub permission_mode: String, -} - -fn default_all_tools() -> Vec { - vec!["*".to_string()] -} - -fn default_review_type() -> String { - "none".to_string() -} - -fn default_permission_mode() -> String { - "default".to_string() -} - -impl From for StepSchema { - fn from(s: CreateStepRequest) -> Self { - Self { - name: s.name, - display_name: s.display_name, - step_type: crate::templates::schema::StepTypeTag::Task, - prompt: s.prompt, - outputs: s - .outputs - .iter() - .filter_map(|o| match o.as_str() { - "plan" => Some(StepOutput::Plan), - "code" => Some(StepOutput::Code), - "test" => Some(StepOutput::Test), - "pr" => Some(StepOutput::Pr), - "ticket" => Some(StepOutput::Ticket), - "review" => Some(StepOutput::Review), - "report" => Some(StepOutput::Report), - "documentation" => Some(StepOutput::Documentation), - _ => None, - }) - .collect(), - allowed_tools: s.allowed_tools, - review_type: match s.review_type.as_str() { - "plan" => crate::templates::schema::ReviewType::Plan, - "visual" => crate::templates::schema::ReviewType::Visual, - "pr" => crate::templates::schema::ReviewType::Pr, - _ => crate::templates::schema::ReviewType::None, - }, - visual_config: None, - on_reject: None, - next_step: s.next_step, - permissions: None, - cli_args: None, - permission_mode: match s.permission_mode.as_str() { - "plan" => PermissionMode::Plan, - "acceptEdits" => PermissionMode::AcceptEdits, - "delegate" => PermissionMode::Delegate, - _ => PermissionMode::Default, - }, - json_schema: None, - json_schema_file: None, - artifact_patterns: vec![], - agent: None, - classifier_config: None, - rag_config: None, - delegator_config: None, - mcp_config: None, - multi_model_config: None, - multi_prompt_config: None, - matrixed_config: None, - } - } -} - -/// Request to update a step -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct UpdateStepRequest { - #[serde(default)] - pub display_name: Option, - #[serde(default)] - pub prompt: Option, - #[serde(default)] - pub outputs: Option>, - #[serde(default)] - pub allowed_tools: Option>, - /// Type of review required: "none", "plan", "visual", "pr" - #[serde(default)] - pub review_type: Option, - #[serde(default)] - pub next_step: Option, - #[serde(default)] - pub permission_mode: Option, -} - -// ============================================================================= -// Collection DTOs -// ============================================================================= - -/// Response for a collection -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CollectionResponse { - pub name: String, - pub description: String, - pub types: Vec, - pub is_active: bool, -} - -impl CollectionResponse { - pub fn from_collection(c: &IssueTypeCollection, is_active: bool) -> Self { - Self { - name: c.name.clone(), - description: c.description.clone(), - types: c.types.clone(), - is_active, - } - } -} - -// ============================================================================= -// External Issue Type DTOs (from kanban providers) -// ============================================================================= - -/// Summary of an issue type from an external kanban provider (Jira, Linear) -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ExternalIssueTypeSummary { - /// Provider-specific unique identifier - pub id: String, - /// Issue type name (e.g., "Bug", "Story", "Task") - pub name: String, - /// Description of the issue type - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Icon/avatar URL from the provider - #[serde(skip_serializing_if = "Option::is_none")] - pub icon_url: Option, -} - -// ============================================================================= -// Kanban Issue Type Catalog DTOs -// ============================================================================= - -/// A synced kanban issue type from the persisted catalog. -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct KanbanIssueTypeResponse { - /// Provider-specific ID (Jira type ID, Linear label ID) - pub id: String, - /// Display name (e.g., "Bug", "Story", "Task") - pub name: String, - /// Description from the provider - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Icon/avatar URL from the provider - #[serde(skip_serializing_if = "Option::is_none")] - pub icon_url: Option, - /// Provider name ("jira", "linear", or "github") - pub provider: String, - /// Project/team key - pub project: String, - /// What this type represents in the provider ("issuetype" or "label") - pub source_kind: String, - /// ISO 8601 timestamp of last sync - pub synced_at: String, -} - -/// Response from syncing kanban issue types from a provider. -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct SyncKanbanIssueTypesResponse { - /// Number of issue types synced - pub synced: usize, - /// The synced issue types - pub types: Vec, -} - -// ============================================================================= -// Kanban Onboarding DTOs -// ============================================================================= - -/// Which kanban provider an onboarding request targets. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS, PartialEq, Eq)] -#[ts(export)] -#[serde(rename_all = "lowercase")] -pub enum KanbanProviderKind { - Jira, - Linear, - Github, -} - -/// Ephemeral Jira credentials supplied by a client during onboarding. -/// -/// These are never persisted to disk by the onboarding endpoints that take -/// this struct — the actual secret stays in the env var named in -/// `api_key_env` once set via `/api/v1/kanban/session-env`. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct JiraCredentials { - /// Jira Cloud domain (e.g., "acme.atlassian.net") - pub domain: String, - /// Atlassian account email for Basic Auth - pub email: String, - /// API token / personal access token - pub api_token: String, -} - -/// Ephemeral Linear credentials supplied by a client during onboarding. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LinearCredentials { - /// Linear API key (prefixed `lin_api_`) - pub api_key: String, -} - -/// Ephemeral GitHub Projects credentials supplied by a client during onboarding. -/// -/// The token must have `project` (or `read:project`) scope. A repo-only token -/// (the kind used for `GITHUB_TOKEN` and operator's git provider) will be -/// rejected at validation time with a friendly "lacks `project` scope" error. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct GithubCredentials { - /// GitHub PAT, fine-grained PAT, or app installation token - pub token: String, -} - -/// Request to validate kanban credentials without persisting them. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ValidateKanbanCredentialsRequest { - pub provider: KanbanProviderKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jira: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub linear: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// Jira-specific validation details (returned on success). -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct JiraValidationDetailsDto { - /// Atlassian accountId (used as `sync_user_id`) - pub account_id: String, - /// User display name - pub display_name: String, -} - -/// A Linear team exposed to onboarding clients for project selection. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LinearTeamInfoDto { - pub id: String, - pub key: String, - pub name: String, -} - -/// Linear-specific validation details (returned on success). -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LinearValidationDetailsDto { - /// Linear viewer user ID (used as `sync_user_id`) - pub user_id: String, - pub user_name: String, - pub org_name: String, - pub teams: Vec, -} - -/// A GitHub Project v2 surfaced during onboarding for project picker UIs. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct GithubProjectInfoDto { - /// `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key - pub node_id: String, - /// Project number (e.g., 42) within the owner - pub number: i32, - /// Human-readable project title - pub title: String, - /// Owner login (org or user name) - pub owner_login: String, - /// "Organization" or "User" - pub owner_kind: String, -} - -/// GitHub-specific validation details (returned on success). -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct GithubValidationDetailsDto { - /// Authenticated user's login (e.g., "octocat") - pub user_login: String, - /// Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`) - pub user_id: String, - /// All Projects v2 visible to the token (across viewer + organizations) - pub projects: Vec, - /// The env var name the validated token came from. Used by clients to - /// display "Connected via `OPERATOR_GITHUB_TOKEN`" so users can rotate the - /// right token. See Token Disambiguation in the kanban github docs. - pub resolved_env_var: String, -} - -/// Response from validating kanban credentials. -/// -/// `valid: false` is returned for auth failures — never a 4xx/5xx HTTP -/// status — so clients can display `error` inline without exception handling. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ValidateKanbanCredentialsResponse { - pub valid: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jira: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub linear: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// Request to list projects/teams from a provider using ephemeral creds. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ListKanbanProjectsRequest { - pub provider: KanbanProviderKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jira: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub linear: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// A project/team entry returned by `list_projects`. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct KanbanProjectInfo { - pub id: String, - pub key: String, - pub name: String, -} - -/// Response wrapper for list-projects (wrapped for utoipa compatibility). -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ListKanbanProjectsResponse { - pub projects: Vec, -} - -/// Body for writing a Jira project config section. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct WriteJiraConfigBody { - pub domain: String, - pub email: String, - pub api_key_env: String, - pub project_key: String, - pub sync_user_id: String, -} - -/// Body for writing a Linear project/team config section. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct WriteLinearConfigBody { - pub workspace_key: String, - pub api_key_env: String, - pub project_key: String, - pub sync_user_id: String, -} - -/// Body for writing a GitHub Projects v2 config section. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct WriteGithubConfigBody { - /// GitHub owner login (user or org), used as the workspace key - pub owner: String, - /// Env var name where the project-scoped token is set - /// (default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN` - /// — see Token Disambiguation in the kanban github docs. - pub api_key_env: String, - /// `GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`) - pub project_key: String, - /// Numeric GitHub `databaseId` of the user whose items to sync - pub sync_user_id: String, -} - -/// Request to write or upsert a kanban config section. -/// -/// This endpoint does NOT take the secret — only the env var NAME -/// (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct WriteKanbanConfigRequest { - pub provider: KanbanProviderKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jira: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub linear: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// Response after writing a kanban config section. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct WriteKanbanConfigResponse { - /// Filesystem path that was written (e.g., ".tickets/operator/config.toml") - pub written_path: String, - /// Header of the top-level section that was upserted - /// (e.g., `[kanban.jira."acme.atlassian.net"]`) - pub section_header: String, -} - -/// Jira session env body — includes the actual secret to set in env. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct JiraSessionEnv { - pub domain: String, - pub email: String, - pub api_token: String, - pub api_key_env: String, -} - -/// Linear session env body — includes the actual secret to set in env. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LinearSessionEnv { - pub api_key: String, - pub api_key_env: String, -} - -/// GitHub Projects session env body — includes the actual secret to set in env. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct GithubSessionEnv { - pub token: String, - pub api_key_env: String, -} - -/// Request to set kanban-related env vars on the server for the current -/// session so subsequent `from_config` calls find the API key. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct SetKanbanSessionEnvRequest { - pub provider: KanbanProviderKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jira: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub linear: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// Response from setting session env vars. -/// -/// `shell_export_block` uses `` placeholders, NOT the actual -/// secret — it is meant for the user to copy into their shell profile. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct SetKanbanSessionEnvResponse { - /// Names (not values) of env vars that were set in the server process. - pub env_vars_set: Vec, - /// Multi-line `export FOO=""` block for the user to copy - /// into `~/.zshrc` / `~/.bashrc`. - pub shell_export_block: String, -} - -// ============================================================================= -// Health/Status DTOs -// ============================================================================= - -/// Health check response -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct HealthResponse { - pub status: String, - pub version: String, -} - -/// Status response with registry info -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct StatusResponse { - pub status: String, - pub version: String, - pub issuetype_count: usize, - pub collection_count: usize, - pub active_collection: String, -} - -// ============================================================================= -// Kanban Board DTOs -// ============================================================================= - -/// A ticket card for the kanban board -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct KanbanTicketCard { - /// Ticket ID (e.g., "FEAT-7598") - pub id: String, - /// Ticket summary/title - pub summary: String, - /// Ticket type: FEAT, FIX, INV, SPIKE - pub ticket_type: String, - /// Project name - pub project: String, - /// Current status: queued, running, awaiting, completed - pub status: String, - /// Current step name - pub step: String, - /// Human-readable step name - #[serde(skip_serializing_if = "Option::is_none")] - pub step_display_name: Option, - /// Priority: P0-critical, P1-high, P2-medium, P3-low - pub priority: String, - /// Timestamp for sorting (YYYYMMDD-HHMM format) - pub timestamp: String, -} - -/// Kanban board response with tickets grouped by column -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct KanbanBoardResponse { - /// Tickets in queue (not yet started) - pub queue: Vec, - /// Tickets currently being worked on - pub running: Vec, - /// Tickets awaiting review or input - pub awaiting: Vec, - /// Completed tickets - pub done: Vec, - /// Total ticket count across all columns - pub total_count: usize, - /// ISO 8601 timestamp of last data refresh - pub last_updated: String, -} - -// ============================================================================= -// Queue Status DTOs -// ============================================================================= - -/// Ticket counts by type for queue status -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct QueueByType { - pub inv: usize, - pub fix: usize, - pub feat: usize, - pub spike: usize, -} - -/// Queue status response with ticket counts -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct QueueStatusResponse { - /// Tickets waiting in queue - pub queued: usize, - /// Tickets currently being worked on - pub in_progress: usize, - /// Tickets awaiting review or input - pub awaiting: usize, - /// Completed tickets (today) - pub completed: usize, - /// Breakdown by ticket type - pub by_type: QueueByType, -} - -// ============================================================================= -// Active Agents DTOs -// ============================================================================= - -/// A single active agent -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ActiveAgentResponse { - /// Agent ID (e.g., "op-gamesvc-001") - pub id: String, - /// Associated ticket ID (e.g., "FEAT-042") - pub ticket_id: String, - /// Ticket type: FEAT, FIX, INV, SPIKE - pub ticket_type: String, - /// Project being worked on - pub project: String, - /// Agent status: running, `awaiting_input`, completing - pub status: String, - /// Execution mode: autonomous, paired - pub mode: String, - /// When the agent started (ISO 8601) - pub started_at: String, - /// Current workflow step - #[serde(skip_serializing_if = "Option::is_none")] - pub current_step: Option, - /// Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" - #[serde(skip_serializing_if = "Option::is_none")] - pub session_wrapper: Option, - /// Session window reference ID (e.g. cmux window, tmux session) - #[serde(skip_serializing_if = "Option::is_none")] - pub session_window_ref: Option, - /// Session context reference (e.g. cmux workspace, zellij session) - #[serde(skip_serializing_if = "Option::is_none")] - pub session_context_ref: Option, - /// Session pane reference (e.g. cmux surface, zellij pane) - #[serde(skip_serializing_if = "Option::is_none")] - pub session_pane_ref: Option, -} - -/// Response for active agents list -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ActiveAgentsResponse { - /// List of active agents - pub agents: Vec, - /// Total count of active agents - pub count: usize, -} - -// ============================================================================= -// Ticket Launch DTOs -// ============================================================================= - -/// Request to launch a ticket -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LaunchTicketRequest { - /// Named delegator to use (takes precedence over provider/model) - #[serde(default)] - pub delegator: Option, - /// LLM provider to use (e.g., "claude") — legacy fallback when no delegator - #[serde(default)] - pub provider: Option, - /// Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator - #[serde(default)] - pub model: Option, - /// Run in YOLO mode (auto-accept all prompts) - #[serde(default)] - pub yolo_mode: bool, - /// Session wrapper type: "vscode", "tmux", "cmux", "terminal" - #[serde(default)] - pub wrapper: Option, - /// Feedback for relaunch (what went wrong on previous attempt) - #[serde(default)] - pub retry_reason: Option, - /// Existing session ID to resume (for continuing from where it left off) - #[serde(default)] - pub resume_session_id: Option, -} - -/// Response from launching a ticket -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LaunchTicketResponse { - /// Agent ID assigned to this launch - pub agent_id: String, - /// Ticket ID that was launched - pub ticket_id: String, - /// Working directory (worktree if created, else project path) - pub working_directory: String, - /// Command to execute in terminal - pub command: String, - /// Terminal name to use (same value as `tmux_session_name`) - pub terminal_name: String, - /// Tmux session name for attaching (same value as `terminal_name`, kept for backward compat) - pub tmux_session_name: String, - /// Which session wrapper was used: "tmux", "vscode", or "cmux" - #[serde(skip_serializing_if = "Option::is_none")] - pub session_wrapper: Option, - /// Session window reference ID (e.g. cmux window, tmux session) - #[serde(skip_serializing_if = "Option::is_none")] - pub session_window_ref: Option, - /// Session context reference (e.g. cmux workspace, zellij session) - #[serde(skip_serializing_if = "Option::is_none")] - pub session_context_ref: Option, - /// Session UUID for the LLM tool - pub session_id: String, - /// Whether a worktree was created - pub worktree_created: bool, - /// Branch name (if worktree was created) - #[serde(skip_serializing_if = "Option::is_none")] - pub branch: Option, -} - -// ============================================================================= -// OperatorOutput DTOs (structured agent output) -// ============================================================================= - -/// Standardized agent output for progress tracking and step transitions. -/// -/// Agents output a status block in their response which is parsed into this structure. -/// Used for progress tracking, loop detection, and intelligent step transitions. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS, Default)] -#[ts(export)] -pub struct OperatorOutput { - /// Current work status: `in_progress`, complete, blocked, failed - pub status: String, - /// Agent signals done with step (true) or more work remains (false) - pub exit_signal: bool, - /// Agent's confidence in completion (0-100%) - #[serde(skip_serializing_if = "Option::is_none")] - pub confidence: Option, - /// Number of files changed this iteration - #[serde(skip_serializing_if = "Option::is_none")] - pub files_modified: Option, - /// Test suite status: passing, failing, skipped, `not_run` - #[serde(skip_serializing_if = "Option::is_none")] - pub tests_status: Option, - /// Number of errors encountered - #[serde(skip_serializing_if = "Option::is_none")] - pub error_count: Option, - /// Number of sub-tasks completed this iteration - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks_completed: Option, - /// Estimated remaining sub-tasks - #[serde(skip_serializing_if = "Option::is_none")] - pub tasks_remaining: Option, - /// Brief description of work done (max 500 chars) - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, - /// Suggested next action (max 200 chars) - #[serde(skip_serializing_if = "Option::is_none")] - pub recommendation: Option, - /// Issues preventing progress (signals intervention needed) - #[serde(skip_serializing_if = "Option::is_none")] - pub blockers: Option>, -} - -// ============================================================================= -// Step Completion DTOs (for opr8r wrapper) -// ============================================================================= - -/// Request to report step completion (from opr8r wrapper) -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct StepCompleteRequest { - /// Exit code from the LLM command - pub exit_code: i32, - /// Whether output validation passed (if schema was specified) - #[serde(default = "default_true")] - pub output_valid: bool, - /// List of validation errors (if `output_valid` is false) - #[serde(skip_serializing_if = "Option::is_none")] - pub output_schema_errors: Option>, - /// Session ID from the LLM session - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - /// Duration of the step in seconds - pub duration_secs: u64, - /// Sample of the output (first N chars for debugging) - #[serde(skip_serializing_if = "Option::is_none")] - pub output_sample: Option, - /// Structured output from agent (parsed `OPERATOR_STATUS` block) - #[serde(skip_serializing_if = "Option::is_none")] - pub output: Option, -} - -/// Response from step completion endpoint -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct StepCompleteResponse { - /// Status of the step: "completed", "`awaiting_review`", "failed", "iterate" - pub status: String, - /// Information about the next step (if any) - #[serde(skip_serializing_if = "Option::is_none")] - pub next_step: Option, - /// Whether to automatically proceed to the next step - pub auto_proceed: bool, - /// Command to execute for the next step (opr8r wrapped) - #[serde(skip_serializing_if = "Option::is_none")] - pub next_command: Option, - - // Analysis results from OperatorOutput processing - /// Whether `OperatorOutput` was successfully parsed from agent output - #[serde(default)] - pub output_valid: bool, - /// Agent has more work (`exit_signal=false`) - indicates iteration needed - #[serde(default)] - pub should_iterate: bool, - /// How many times this step has run (for circuit breaker) - #[serde(default)] - pub iteration_count: u32, - /// Circuit breaker state: closed (normal), `half_open` (monitoring), open (halted) - #[serde(default = "default_circuit_closed")] - pub circuit_state: String, - - // Context piped from agent output for next step - /// Summary from previous step's `OperatorOutput` - #[serde(skip_serializing_if = "Option::is_none")] - pub previous_summary: Option, - /// Recommendation from previous step's `OperatorOutput` - #[serde(skip_serializing_if = "Option::is_none")] - pub previous_recommendation: Option, - /// Cumulative files modified across iterations - #[serde(default)] - pub cumulative_files_modified: u32, - /// Cumulative errors across iterations - #[serde(default)] - pub cumulative_errors: u32, -} - -fn default_circuit_closed() -> String { - "closed".to_string() -} - -/// Information about the next step in the workflow -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct NextStepInfo { - /// Step name - pub name: String, - /// Display name for the step - pub display_name: String, - /// Review type: "none", "plan", "visual", "pr" - pub review_type: String, - /// Prompt template for the step - #[serde(skip_serializing_if = "Option::is_none")] - pub prompt: Option, -} - -// ============================================================================= -// Queue Control DTOs -// ============================================================================= - -/// Response for queue pause/resume operations -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct QueueControlResponse { - /// Whether the queue is currently paused - pub paused: bool, - /// Human-readable message about the operation - pub message: String, -} - -/// Response for kanban sync operations -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct KanbanSyncResponse { - /// Ticket IDs that were created - pub created: Vec, - /// Ticket IDs that were skipped (already exist) - pub skipped: Vec, - /// Error messages for failed syncs - pub errors: Vec, - /// Total count of issues processed - pub total_processed: usize, -} - -// ============================================================================= -// Agent Review DTOs -// ============================================================================= - -/// Response for agent review operations (approve/reject) -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ReviewResponse { - /// Agent ID that was reviewed - pub agent_id: String, - /// Review status: "approved" or "rejected" - pub status: String, - /// Human-readable message about the operation - pub message: String, -} - -/// Request to reject an agent's review -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct RejectReviewRequest { - /// Reason for rejection (feedback for the agent) - pub reason: String, -} - -// ============================================================================= -// Project DTOs -// ============================================================================= - -/// Summary of a project with analysis data -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ProjectSummary { - /// Project directory name - pub project_name: String, - /// Absolute path to project root - pub project_path: String, - /// Whether the project directory exists on disk - pub exists: bool, - /// Whether catalog-info.yaml exists - pub has_catalog_info: bool, - /// Whether project-context.json exists - pub has_project_context: bool, - /// Primary Kind from `kind_assessment` (e.g., "microservice") - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - /// Kind confidence score 0.0-1.0 - #[serde(skip_serializing_if = "Option::is_none")] - pub kind_confidence: Option, - /// Taxonomy tier (e.g., "engines") - #[serde(skip_serializing_if = "Option::is_none")] - pub kind_tier: Option, - /// Language display names - pub languages: Vec, - /// Framework display names - pub frameworks: Vec, - /// Database display names - pub databases: Vec, - /// Has Dockerfile or docker-compose - #[serde(skip_serializing_if = "Option::is_none")] - pub has_docker: Option, - /// Has test frameworks detected - #[serde(skip_serializing_if = "Option::is_none")] - pub has_tests: Option, - /// Detected port numbers - pub ports: Vec, - /// Number of environment variables - pub env_var_count: usize, - /// Number of entry points - pub entry_point_count: usize, - /// Available command names (start, dev, test, etc.) - pub commands: Vec, -} - -/// Response from creating an ASSESS ticket -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct AssessTicketResponse { - /// Ticket ID (e.g., "ASSESS-1234") - pub ticket_id: String, - /// Path to the created ticket file - pub ticket_path: String, - /// Project name that was assessed - pub project_name: String, -} - -// ============================================================================= -// Skills DTOs -// ============================================================================= - -/// A single discovered skill file -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct SkillEntry { - /// Tool this skill belongs to (e.g., "claude", "codex") - pub tool_name: String, - /// Filename of the skill (e.g., "commit.md") - pub filename: String, - /// Full path to the skill file - pub file_path: String, - /// Scope: "global" or "project" - pub scope: String, -} - -/// Response for skills listing -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct SkillsResponse { - /// List of discovered skills - pub skills: Vec, - /// Total count - pub total: usize, -} - -// ============================================================================= -// Delegator DTOs -// ============================================================================= - -/// Response for a single delegator -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct DelegatorResponse { - /// Unique name - pub name: String, - /// LLM tool name (e.g., "claude") - pub llm_tool: String, - /// Model alias (e.g., "opus") - pub model: String, - /// Optional display name - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, - /// Arbitrary model properties - pub model_properties: std::collections::HashMap, - /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. - #[serde(skip_serializing_if = "Option::is_none")] - pub model_server: Option, - /// Optional launch configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub launch_config: Option, -} - -/// Request to create a new delegator -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CreateDelegatorRequest { - /// Unique name for the delegator - pub name: String, - /// LLM tool name (must match a detected tool) - pub llm_tool: String, - /// Model alias - pub model: String, - /// Optional display name - #[serde(default)] - pub display_name: Option, - /// Arbitrary model properties - #[serde(default)] - pub model_properties: std::collections::HashMap, - /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. - #[serde(default)] - pub model_server: Option, - /// Optional launch configuration - #[serde(default)] - pub launch_config: Option, -} - -/// Launch configuration DTO for delegators -/// -/// Optional fields use tri-state semantics: `None` = inherit global config, -/// `Some(true/false)` = explicit override per-delegator. -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct DelegatorLaunchConfigDto { - /// Run in YOLO mode - #[serde(default)] - pub yolo: bool, - /// Permission mode override - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission_mode: Option, - /// Additional CLI flags - #[serde(default)] - pub flags: Vec, - /// Override global `git.use_worktrees` (None = use global setting) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub use_worktrees: Option, - /// Whether to create a git branch for the ticket (None = default behavior) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub create_branch: Option, - /// Run in docker container (None = use global `launch.docker.enabled`) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub docker: Option, - /// Prompt text to prepend before the generated step prompt - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_prefix: Option, - /// Prompt text to append after the generated step prompt - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt_suffix: Option, -} - -/// Response listing all delegators -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct DelegatorsResponse { - /// List of delegators - pub delegators: Vec, - /// Total count - pub total: usize, -} - -/// Request to create a delegator from a detected LLM tool -/// -/// Pre-populates delegator fields from the detected tool, requiring minimal input. -/// If `name` is omitted, auto-generates as `"{tool_name}-{model}"`. -/// If `model` is omitted, uses the tool's first model alias. -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CreateDelegatorFromToolRequest { - /// Name of the detected tool (e.g., "claude", "codex", "gemini") - pub tool_name: String, - /// Model alias to use (e.g., "opus"). If omitted, uses the tool's first model alias. - #[serde(default)] - pub model: Option, - /// Custom delegator name. If omitted, auto-generates as `"{tool_name}-{model}"`. - #[serde(default)] - pub name: Option, - /// Optional display name for UI - #[serde(default)] - pub display_name: Option, - /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. - #[serde(default)] - pub model_server: Option, - /// Optional launch configuration - #[serde(default)] - pub launch_config: Option, -} - -// ============================================================================= -// Model Server DTOs -// ============================================================================= - -/// Response for a single model server -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ModelServerResponse { - /// Unique name (e.g., "ollama-local") - pub name: String, - /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" - pub kind: String, - /// Base URL of the inference endpoint (e.g., `http://localhost:11434`) - #[serde(skip_serializing_if = "Option::is_none")] - pub base_url: Option, - /// Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) - #[serde(skip_serializing_if = "Option::is_none")] - pub api_key_env: Option, - /// Additional environment variables set when spawning agents that use this server - pub extra_env: std::collections::HashMap, - /// Optional display name for UI - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option, - /// Whether this is a user-declared server (true) or an implicit builtin (false) - pub user_declared: bool, -} - -/// Response listing all model servers (declared + implicit builtins) -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct ModelServersResponse { - /// List of model servers - pub servers: Vec, - /// Total count - pub total: usize, -} - -/// Request to create a new model server -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct CreateModelServerRequest { - /// Unique name for this model server - pub name: String, - /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" - pub kind: String, - /// Base URL of the inference endpoint - #[serde(default)] - pub base_url: Option, - /// Name of an env var providing the API key - #[serde(default)] - pub api_key_env: Option, - /// Additional environment variables - #[serde(default)] - pub extra_env: std::collections::HashMap, - /// Optional display name for UI - #[serde(default)] - pub display_name: Option, -} - -// ============================================================================= -// LLM Tools DTOs -// ============================================================================= - -/// Response listing detected LLM tools -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct LlmToolsResponse { - /// Detected CLI tools with model aliases and capabilities - pub tools: Vec, - /// Total count - pub total: usize, -} - -/// Request to set the global default LLM tool and model -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct SetDefaultLlmRequest { - /// Tool name (must match a detected tool, e.g., "claude") - pub tool: String, - /// Model alias (e.g., "opus", "sonnet") - pub model: String, -} - -/// Response with the current default LLM tool and model -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] -#[ts(export)] -pub struct DefaultLlmResponse { - /// Default tool name (empty string if not set) - pub tool: String, - /// Default model alias (empty string if not set) - pub model: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_issue_type_request_into() { - let req = CreateIssueTypeRequest { - key: "test".to_string(), - name: "Test".to_string(), - description: "A test type".to_string(), - mode: "autonomous".to_string(), - glyph: "T".to_string(), - color: None, - project_required: true, - fields: vec![], - steps: vec![CreateStepRequest { - name: "execute".to_string(), - display_name: None, - prompt: "Do the thing".to_string(), - outputs: vec![], - allowed_tools: vec!["*".to_string()], - review_type: "none".to_string(), - next_step: None, - permission_mode: "default".to_string(), - }], - }; - - let it = req.into_issue_type(); - assert_eq!(it.key, "TEST"); // Uppercased - assert_eq!(it.name, "Test"); - assert!(matches!(it.mode, ExecutionMode::Autonomous)); - assert!(matches!(it.source, IssueTypeSource::User)); - assert_eq!(it.steps.len(), 1); - } - - #[test] - fn test_issue_type_response_from() { - let it = IssueType::new_imported( - "TEST".to_string(), - "Test".to_string(), - "A test".to_string(), - "jira".to_string(), - "PROJ".to_string(), - None, - ); - - let resp = IssueTypeResponse::from(&it); - assert_eq!(resp.key, "TEST"); - assert_eq!(resp.mode, "autonomous"); - assert_eq!(resp.source, "jira/PROJ"); - } - - #[test] - fn test_operator_output_default() { - let output = OperatorOutput::default(); - assert_eq!(output.status, ""); - assert!(!output.exit_signal); - assert!(output.confidence.is_none()); - assert!(output.summary.is_none()); - } - - #[test] - fn test_operator_output_serialization() { - let output = OperatorOutput { - status: "complete".to_string(), - exit_signal: true, - confidence: Some(95), - files_modified: Some(3), - tests_status: Some("passing".to_string()), - error_count: Some(0), - tasks_completed: Some(5), - tasks_remaining: Some(0), - summary: Some("Implemented feature".to_string()), - recommendation: Some("Ready for review".to_string()), - blockers: None, - }; - - let json = serde_json::to_string(&output).unwrap(); - assert!(json.contains("\"status\":\"complete\"")); - assert!(json.contains("\"exit_signal\":true")); - assert!(json.contains("\"confidence\":95")); - assert!(!json.contains("blockers")); // None fields are skipped - } - - #[test] - fn test_operator_output_deserialization() { - let json = r#"{ - "status": "in_progress", - "exit_signal": false, - "confidence": 60, - "files_modified": 2, - "tests_status": "failing", - "summary": "Working on tests" - }"#; - - let output: OperatorOutput = serde_json::from_str(json).unwrap(); - assert_eq!(output.status, "in_progress"); - assert!(!output.exit_signal); - assert_eq!(output.confidence, Some(60)); - assert_eq!(output.tests_status, Some("failing".to_string())); - } - - #[test] - fn test_step_complete_request_with_operator_output() { - let output = OperatorOutput { - status: "complete".to_string(), - exit_signal: true, - confidence: Some(90), - ..Default::default() - }; - - let request = StepCompleteRequest { - exit_code: 0, - output_valid: true, - output_schema_errors: None, - session_id: Some("session-123".to_string()), - duration_secs: 300, - output_sample: None, - output: Some(output), - }; - - let json = serde_json::to_string(&request).unwrap(); - assert!(json.contains("\"exit_code\":0")); - assert!(json.contains("\"output\":{")); - assert!(json.contains("\"status\":\"complete\"")); - } - - #[test] - fn test_launch_response_cmux_fields_present_when_set() { - let resp = LaunchTicketResponse { - agent_id: "a1".to_string(), - ticket_id: "FEAT-001".to_string(), - working_directory: "/tmp".to_string(), - command: "claude".to_string(), - terminal_name: "op-FEAT-001".to_string(), - tmux_session_name: "op-FEAT-001".to_string(), - session_wrapper: Some("cmux".to_string()), - session_window_ref: Some("win-1".to_string()), - session_context_ref: Some("ws-1".to_string()), - session_id: "uuid-1".to_string(), - worktree_created: false, - branch: None, - }; - - let json = serde_json::to_string(&resp).unwrap(); - assert!(json.contains("\"session_wrapper\":\"cmux\"")); - assert!(json.contains("\"session_window_ref\":\"win-1\"")); - assert!(json.contains("\"session_context_ref\":\"ws-1\"")); - } - - #[test] - fn test_launch_response_cmux_fields_absent_when_none() { - let resp = LaunchTicketResponse { - agent_id: "a1".to_string(), - ticket_id: "FEAT-001".to_string(), - working_directory: "/tmp".to_string(), - command: "claude".to_string(), - terminal_name: "op-FEAT-001".to_string(), - tmux_session_name: "op-FEAT-001".to_string(), - session_wrapper: None, - session_window_ref: None, - session_context_ref: None, - session_id: "uuid-1".to_string(), - worktree_created: false, - branch: None, - }; - - let json = serde_json::to_string(&resp).unwrap(); - assert!(!json.contains("session_wrapper")); - assert!(!json.contains("session_window_ref")); - assert!(!json.contains("session_context_ref")); - } - - #[test] - fn test_step_complete_response_with_analysis_fields() { - let json = r#"{ - "status": "completed", - "auto_proceed": true, - "output_valid": true, - "should_iterate": false, - "iteration_count": 1, - "circuit_state": "closed", - "previous_summary": "Built feature", - "cumulative_files_modified": 5, - "cumulative_errors": 0 - }"#; - - let response: StepCompleteResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.status, "completed"); - assert!(response.output_valid); - assert!(!response.should_iterate); - assert_eq!(response.iteration_count, 1); - assert_eq!(response.circuit_state, "closed"); - assert_eq!(response.previous_summary, Some("Built feature".to_string())); - } -} diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs new file mode 100644 index 0000000..a88a6ed --- /dev/null +++ b/src/rest/dto/agents.rs @@ -0,0 +1,410 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +// ============================================================================= +// Health/Status DTOs +// ============================================================================= + +/// Health check response +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct HealthResponse { + pub status: String, + pub version: String, +} + +/// Status response with registry info +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StatusResponse { + pub status: String, + pub version: String, + pub issuetype_count: usize, + pub collection_count: usize, + pub active_collection: String, +} + +// ============================================================================= +// Kanban Board DTOs +// ============================================================================= + +/// A ticket card for the kanban board +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanTicketCard { + /// Ticket ID (e.g., "FEAT-7598") + pub id: String, + /// Ticket summary/title + pub summary: String, + /// Ticket type: FEAT, FIX, INV, SPIKE + pub ticket_type: String, + /// Project name + pub project: String, + /// Current status: queued, running, awaiting, completed + pub status: String, + /// Current step name + pub step: String, + /// Human-readable step name + #[serde(skip_serializing_if = "Option::is_none")] + pub step_display_name: Option, + /// Priority: P0-critical, P1-high, P2-medium, P3-low + pub priority: String, + /// Timestamp for sorting (YYYYMMDD-HHMM format) + pub timestamp: String, +} + +/// Kanban board response with tickets grouped by column +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanBoardResponse { + /// Tickets in queue (not yet started) + pub queue: Vec, + /// Tickets currently being worked on + pub running: Vec, + /// Tickets awaiting review or input + pub awaiting: Vec, + /// Completed tickets + pub done: Vec, + /// Total ticket count across all columns + pub total_count: usize, + /// ISO 8601 timestamp of last data refresh + pub last_updated: String, +} + +// ============================================================================= +// Queue Status DTOs +// ============================================================================= + +/// Ticket counts by type for queue status +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct QueueByType { + pub inv: usize, + pub fix: usize, + pub feat: usize, + pub spike: usize, +} + +/// Queue status response with ticket counts +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct QueueStatusResponse { + /// Tickets waiting in queue + pub queued: usize, + /// Tickets currently being worked on + pub in_progress: usize, + /// Tickets awaiting review or input + pub awaiting: usize, + /// Completed tickets (today) + pub completed: usize, + /// Breakdown by ticket type + pub by_type: QueueByType, +} + +// ============================================================================= +// Active Agents DTOs +// ============================================================================= + +/// A single active agent +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ActiveAgentResponse { + /// Agent ID (e.g., "op-gamesvc-001") + pub id: String, + /// Associated ticket ID (e.g., "FEAT-042") + pub ticket_id: String, + /// Ticket type: FEAT, FIX, INV, SPIKE + pub ticket_type: String, + /// Project being worked on + pub project: String, + /// Agent status: running, `awaiting_input`, completing + pub status: String, + /// Execution mode: autonomous, paired + pub mode: String, + /// When the agent started (ISO 8601) + pub started_at: String, + /// Current workflow step + #[serde(skip_serializing_if = "Option::is_none")] + pub current_step: Option, + /// Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrapper: Option, + /// Session window reference ID (e.g. cmux window, tmux session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_window_ref: Option, + /// Session context reference (e.g. cmux workspace, zellij session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_context_ref: Option, + /// Session pane reference (e.g. cmux surface, zellij pane) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_pane_ref: Option, +} + +/// Response for active agents list +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ActiveAgentsResponse { + /// List of active agents + pub agents: Vec, + /// Total count of active agents + pub count: usize, +} + +// ============================================================================= +// Ticket Launch DTOs +// ============================================================================= + +/// Request to launch a ticket +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LaunchTicketRequest { + /// Named delegator to use (takes precedence over provider/model) + #[serde(default)] + pub delegator: Option, + /// LLM provider to use (e.g., "claude") — legacy fallback when no delegator + #[serde(default)] + pub provider: Option, + /// Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator + #[serde(default)] + pub model: Option, + /// Run in YOLO mode (auto-accept all prompts) + #[serde(default)] + pub yolo_mode: bool, + /// Session wrapper type: "vscode", "tmux", "cmux", "terminal" + #[serde(default)] + pub wrapper: Option, + /// Feedback for relaunch (what went wrong on previous attempt) + #[serde(default)] + pub retry_reason: Option, + /// Existing session ID to resume (for continuing from where it left off) + #[serde(default)] + pub resume_session_id: Option, +} + +/// Response from launching a ticket +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LaunchTicketResponse { + /// Agent ID assigned to this launch + pub agent_id: String, + /// Ticket ID that was launched + pub ticket_id: String, + /// Working directory (worktree if created, else project path) + pub working_directory: String, + /// Command to execute in terminal + pub command: String, + /// Terminal name to use (same value as `tmux_session_name`) + pub terminal_name: String, + /// Tmux session name for attaching (same value as `terminal_name`, kept for backward compat) + pub tmux_session_name: String, + /// Which session wrapper was used: "tmux", "vscode", or "cmux" + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrapper: Option, + /// Session window reference ID (e.g. cmux window, tmux session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_window_ref: Option, + /// Session context reference (e.g. cmux workspace, zellij session) + #[serde(skip_serializing_if = "Option::is_none")] + pub session_context_ref: Option, + /// Session UUID for the LLM tool + pub session_id: String, + /// Whether a worktree was created + pub worktree_created: bool, + /// Branch name (if worktree was created) + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, +} + +// ============================================================================= +// OperatorOutput DTOs (structured agent output) +// ============================================================================= + +/// Standardized agent output for progress tracking and step transitions. +/// +/// Agents output a status block in their response which is parsed into this structure. +/// Used for progress tracking, loop detection, and intelligent step transitions. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS, Default)] +#[ts(export)] +pub struct OperatorOutput { + /// Current work status: `in_progress`, complete, blocked, failed + pub status: String, + /// Agent signals done with step (true) or more work remains (false) + pub exit_signal: bool, + /// Agent's confidence in completion (0-100%) + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// Number of files changed this iteration + #[serde(skip_serializing_if = "Option::is_none")] + pub files_modified: Option, + /// Test suite status: passing, failing, skipped, `not_run` + #[serde(skip_serializing_if = "Option::is_none")] + pub tests_status: Option, + /// Number of errors encountered + #[serde(skip_serializing_if = "Option::is_none")] + pub error_count: Option, + /// Number of sub-tasks completed this iteration + #[serde(skip_serializing_if = "Option::is_none")] + pub tasks_completed: Option, + /// Estimated remaining sub-tasks + #[serde(skip_serializing_if = "Option::is_none")] + pub tasks_remaining: Option, + /// Brief description of work done (max 500 chars) + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Suggested next action (max 200 chars) + #[serde(skip_serializing_if = "Option::is_none")] + pub recommendation: Option, + /// Issues preventing progress (signals intervention needed) + #[serde(skip_serializing_if = "Option::is_none")] + pub blockers: Option>, +} + +// ============================================================================= +// Step Completion DTOs (for opr8r wrapper) +// ============================================================================= + +/// Request to report step completion (from opr8r wrapper) +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StepCompleteRequest { + /// Exit code from the LLM command + pub exit_code: i32, + /// Whether output validation passed (if schema was specified) + #[serde(default = "default_true")] + pub output_valid: bool, + /// List of validation errors (if `output_valid` is false) + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema_errors: Option>, + /// Session ID from the LLM session + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Duration of the step in seconds + pub duration_secs: u64, + /// Sample of the output (first N chars for debugging) + #[serde(skip_serializing_if = "Option::is_none")] + pub output_sample: Option, + /// Structured output from agent (parsed `OPERATOR_STATUS` block) + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +fn default_true() -> bool { + true +} + +/// Response from step completion endpoint +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StepCompleteResponse { + /// Status of the step: "completed", "`awaiting_review`", "failed", "iterate" + pub status: String, + /// Information about the next step (if any) + #[serde(skip_serializing_if = "Option::is_none")] + pub next_step: Option, + /// Whether to automatically proceed to the next step + pub auto_proceed: bool, + /// Command to execute for the next step (opr8r wrapped) + #[serde(skip_serializing_if = "Option::is_none")] + pub next_command: Option, + + // Analysis results from OperatorOutput processing + /// Whether `OperatorOutput` was successfully parsed from agent output + #[serde(default)] + pub output_valid: bool, + /// Agent has more work (`exit_signal=false`) - indicates iteration needed + #[serde(default)] + pub should_iterate: bool, + /// How many times this step has run (for circuit breaker) + #[serde(default)] + pub iteration_count: u32, + /// Circuit breaker state: closed (normal), `half_open` (monitoring), open (halted) + #[serde(default = "default_circuit_closed")] + pub circuit_state: String, + + // Context piped from agent output for next step + /// Summary from previous step's `OperatorOutput` + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_summary: Option, + /// Recommendation from previous step's `OperatorOutput` + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_recommendation: Option, + /// Cumulative files modified across iterations + #[serde(default)] + pub cumulative_files_modified: u32, + /// Cumulative errors across iterations + #[serde(default)] + pub cumulative_errors: u32, +} + +fn default_circuit_closed() -> String { + "closed".to_string() +} + +/// Information about the next step in the workflow +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct NextStepInfo { + /// Step name + pub name: String, + /// Display name for the step + pub display_name: String, + /// Review type: "none", "plan", "visual", "pr" + pub review_type: String, + /// Prompt template for the step + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, +} + +// ============================================================================= +// Queue Control DTOs +// ============================================================================= + +/// Response for queue pause/resume operations +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct QueueControlResponse { + /// Whether the queue is currently paused + pub paused: bool, + /// Human-readable message about the operation + pub message: String, +} + +/// Response for kanban sync operations +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanSyncResponse { + /// Ticket IDs that were created + pub created: Vec, + /// Ticket IDs that were skipped (already exist) + pub skipped: Vec, + /// Error messages for failed syncs + pub errors: Vec, + /// Total count of issues processed + pub total_processed: usize, +} + +// ============================================================================= +// Agent Review DTOs +// ============================================================================= + +/// Response for agent review operations (approve/reject) +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ReviewResponse { + /// Agent ID that was reviewed + pub agent_id: String, + /// Review status: "approved" or "rejected" + pub status: String, + /// Human-readable message about the operation + pub message: String, +} + +/// Request to reject an agent's review +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct RejectReviewRequest { + /// Reason for rejection (feedback for the agent) + pub reason: String, +} diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs new file mode 100644 index 0000000..3b2bf98 --- /dev/null +++ b/src/rest/dto/configuration.rs @@ -0,0 +1,310 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +// ============================================================================= +// Project DTOs +// ============================================================================= + +/// Summary of a project with analysis data +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ProjectSummary { + /// Project directory name + pub project_name: String, + /// Absolute path to project root + pub project_path: String, + /// Whether the project directory exists on disk + pub exists: bool, + /// Whether catalog-info.yaml exists + pub has_catalog_info: bool, + /// Whether project-context.json exists + pub has_project_context: bool, + /// Primary Kind from `kind_assessment` (e.g., "microservice") + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Kind confidence score 0.0-1.0 + #[serde(skip_serializing_if = "Option::is_none")] + pub kind_confidence: Option, + /// Taxonomy tier (e.g., "engines") + #[serde(skip_serializing_if = "Option::is_none")] + pub kind_tier: Option, + /// Language display names + pub languages: Vec, + /// Framework display names + pub frameworks: Vec, + /// Database display names + pub databases: Vec, + /// Has Dockerfile or docker-compose + #[serde(skip_serializing_if = "Option::is_none")] + pub has_docker: Option, + /// Has test frameworks detected + #[serde(skip_serializing_if = "Option::is_none")] + pub has_tests: Option, + /// Detected port numbers + pub ports: Vec, + /// Number of environment variables + pub env_var_count: usize, + /// Number of entry points + pub entry_point_count: usize, + /// Available command names (start, dev, test, etc.) + pub commands: Vec, +} + +/// Response from creating an ASSESS ticket +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AssessTicketResponse { + /// Ticket ID (e.g., "ASSESS-1234") + pub ticket_id: String, + /// Path to the created ticket file + pub ticket_path: String, + /// Project name that was assessed + pub project_name: String, +} + +// ============================================================================= +// Skills DTOs +// ============================================================================= + +/// A single discovered skill file +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SkillEntry { + /// Tool this skill belongs to (e.g., "claude", "codex") + pub tool_name: String, + /// Filename of the skill (e.g., "commit.md") + pub filename: String, + /// Full path to the skill file + pub file_path: String, + /// Scope: "global" or "project" + pub scope: String, +} + +/// Response for skills listing +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SkillsResponse { + /// List of discovered skills + pub skills: Vec, + /// Total count + pub total: usize, +} + +// ============================================================================= +// Delegator DTOs +// ============================================================================= + +/// Response for a single delegator +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorResponse { + /// Unique name + pub name: String, + /// LLM tool name (e.g., "claude") + pub llm_tool: String, + /// Model alias (e.g., "opus") + pub model: String, + /// Optional display name + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Arbitrary model properties + pub model_properties: std::collections::HashMap, + /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_server: Option, + /// Optional launch configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_config: Option, +} + +/// Request to create a new delegator +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateDelegatorRequest { + /// Unique name for the delegator + pub name: String, + /// LLM tool name (must match a detected tool) + pub llm_tool: String, + /// Model alias + pub model: String, + /// Optional display name + #[serde(default)] + pub display_name: Option, + /// Arbitrary model properties + #[serde(default)] + pub model_properties: std::collections::HashMap, + /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + #[serde(default)] + pub model_server: Option, + /// Optional launch configuration + #[serde(default)] + pub launch_config: Option, +} + +/// Launch configuration DTO for delegators +/// +/// Optional fields use tri-state semantics: `None` = inherit global config, +/// `Some(true/false)` = explicit override per-delegator. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorLaunchConfigDto { + /// Run in YOLO mode + #[serde(default)] + pub yolo: bool, + /// Permission mode override + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + /// Additional CLI flags + #[serde(default)] + pub flags: Vec, + /// Override global `git.use_worktrees` (None = use global setting) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_worktrees: Option, + /// Whether to create a git branch for the ticket (None = default behavior) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub create_branch: Option, + /// Run in docker container (None = use global `launch.docker.enabled`) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docker: Option, + /// Prompt text to prepend before the generated step prompt + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_prefix: Option, + /// Prompt text to append after the generated step prompt + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_suffix: Option, + /// 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, +} + +/// Response listing all delegators +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DelegatorsResponse { + /// List of delegators + pub delegators: Vec, + /// Total count + pub total: usize, +} + +/// Request to create a delegator from a detected LLM tool +/// +/// Pre-populates delegator fields from the detected tool, requiring minimal input. +/// If `name` is omitted, auto-generates as `"{tool_name}-{model}"`. +/// If `model` is omitted, uses the tool's first model alias. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateDelegatorFromToolRequest { + /// Name of the detected tool (e.g., "claude", "codex", "gemini") + pub tool_name: String, + /// Model alias to use (e.g., "opus"). If omitted, uses the tool's first model alias. + #[serde(default)] + pub model: Option, + /// Custom delegator name. If omitted, auto-generates as `"{tool_name}-{model}"`. + #[serde(default)] + pub name: Option, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, + /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. + #[serde(default)] + pub model_server: Option, + /// Optional launch configuration + #[serde(default)] + pub launch_config: Option, +} + +// ============================================================================= +// Model Server DTOs +// ============================================================================= + +/// Response for a single model server +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServerResponse { + /// Unique name (e.g., "ollama-local") + pub name: String, + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint (e.g., `http://localhost:11434`) + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + /// Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_env: Option, + /// Additional environment variables set when spawning agents that use this server + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Whether this is a user-declared server (true) or an implicit builtin (false) + pub user_declared: bool, +} + +/// Response listing all model servers (declared + implicit builtins) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServersResponse { + /// List of model servers + pub servers: Vec, + /// Total count + pub total: usize, +} + +/// Request to create a new model server +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateModelServerRequest { + /// Unique name for this model server + pub name: String, + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint + #[serde(default)] + pub base_url: Option, + /// Name of an env var providing the API key + #[serde(default)] + pub api_key_env: Option, + /// Additional environment variables + #[serde(default)] + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, +} + +// ============================================================================= +// LLM Tools DTOs +// ============================================================================= + +/// Response listing detected LLM tools +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LlmToolsResponse { + /// Detected CLI tools with model aliases and capabilities + pub tools: Vec, + /// Total count + pub total: usize, +} + +/// Request to set the global default LLM tool and model +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetDefaultLlmRequest { + /// Tool name (must match a detected tool, e.g., "claude") + pub tool: String, + /// Model alias (e.g., "opus", "sonnet") + pub model: String, +} + +/// Response with the current default LLM tool and model +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct DefaultLlmResponse { + /// Default tool name (empty string if not set) + pub tool: String, + /// Default model alias (empty string if not set) + pub model: String, +} diff --git a/src/rest/dto/issue_types.rs b/src/rest/dto/issue_types.rs new file mode 100644 index 0000000..07a7d69 --- /dev/null +++ b/src/rest/dto/issue_types.rs @@ -0,0 +1,458 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::issuetypes::schema::IssueTypeSource; +use crate::issuetypes::{IssueType, IssueTypeCollection}; +use crate::templates::schema::{ + ExecutionMode, FieldSchema, FieldType, PermissionMode, StepOutput, StepSchema, +}; + +// ============================================================================= +// Issue Type DTOs +// ============================================================================= + +/// Response for a single issue type +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct IssueTypeResponse { + pub key: String, + pub name: String, + pub description: String, + pub mode: String, + pub glyph: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + pub project_required: bool, + pub source: String, + pub fields: Vec, + pub steps: Vec, +} + +impl From<&IssueType> for IssueTypeResponse { + fn from(it: &IssueType) -> Self { + Self { + key: it.key.clone(), + name: it.name.clone(), + description: it.description.clone(), + mode: match it.mode { + ExecutionMode::Autonomous => "autonomous".to_string(), + ExecutionMode::Paired => "paired".to_string(), + }, + glyph: it.glyph.clone(), + color: it.color.clone(), + project_required: it.project_required, + source: it.source_display(), + fields: it.fields.iter().map(FieldResponse::from).collect(), + steps: it.steps.iter().map(StepResponse::from).collect(), + } + } +} + +/// Summary response for listing issue types +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct IssueTypeSummary { + pub key: String, + pub name: String, + pub description: String, + pub mode: String, + pub glyph: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub color: Option, + pub source: String, + pub step_count: usize, +} + +impl From<&IssueType> for IssueTypeSummary { + fn from(it: &IssueType) -> Self { + Self { + key: it.key.clone(), + name: it.name.clone(), + description: it.description.clone(), + mode: match it.mode { + ExecutionMode::Autonomous => "autonomous".to_string(), + ExecutionMode::Paired => "paired".to_string(), + }, + glyph: it.glyph.clone(), + color: it.color.clone(), + source: it.source_display(), + step_count: it.steps.len(), + } + } +} + +/// Request to create a new issue type +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateIssueTypeRequest { + pub key: String, + pub name: String, + pub description: String, + #[serde(default = "default_mode")] + pub mode: String, + pub glyph: String, + #[serde(default)] + pub color: Option, + #[serde(default = "default_true")] + pub project_required: bool, + #[serde(default)] + pub fields: Vec, + pub steps: Vec, +} + +fn default_mode() -> String { + "autonomous".to_string() +} + +fn default_true() -> bool { + true +} + +impl CreateIssueTypeRequest { + /// Convert request to `IssueType` + pub fn into_issue_type(self) -> IssueType { + IssueType { + key: self.key.to_uppercase(), + name: self.name, + description: self.description, + mode: if self.mode == "paired" { + ExecutionMode::Paired + } else { + ExecutionMode::Autonomous + }, + glyph: self.glyph, + color: self.color, + project_required: self.project_required, + fields: self + .fields + .into_iter() + .map(std::convert::Into::into) + .collect(), + steps: self + .steps + .into_iter() + .map(std::convert::Into::into) + .collect(), + agent_prompt: None, + agent: None, + source: IssueTypeSource::User, + external_id: None, + } + } +} + +/// Request to update an issue type +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateIssueTypeRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub glyph: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub project_required: Option, + #[serde(default)] + pub fields: Option>, + #[serde(default)] + pub steps: Option>, +} + +// ============================================================================= +// Field DTOs +// ============================================================================= + +/// Response for a field +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct FieldResponse { + pub name: String, + pub description: String, + pub field_type: String, + pub required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + pub user_editable: bool, +} + +impl From<&FieldSchema> for FieldResponse { + fn from(f: &FieldSchema) -> Self { + Self { + name: f.name.clone(), + description: f.description.clone(), + field_type: match f.field_type { + FieldType::String => "string".to_string(), + FieldType::Enum => "enum".to_string(), + FieldType::Bool => "bool".to_string(), + FieldType::Date => "date".to_string(), + FieldType::Text => "text".to_string(), + FieldType::Integer => "integer".to_string(), + }, + required: f.required, + default: f.default.clone(), + options: f.options.clone(), + placeholder: f.placeholder.clone(), + max_length: f.max_length, + user_editable: f.user_editable, + } + } +} + +/// Request to create a field +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateFieldRequest { + pub name: String, + pub description: String, + #[serde(default = "default_string_type")] + pub field_type: String, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub options: Vec, + #[serde(default)] + pub placeholder: Option, + #[serde(default)] + pub max_length: Option, + #[serde(default = "default_true")] + pub user_editable: bool, +} + +fn default_string_type() -> String { + "string".to_string() +} + +impl From for FieldSchema { + fn from(f: CreateFieldRequest) -> Self { + Self { + name: f.name, + description: f.description, + field_type: match f.field_type.as_str() { + "enum" => FieldType::Enum, + "bool" => FieldType::Bool, + "date" => FieldType::Date, + "text" => FieldType::Text, + _ => FieldType::String, + }, + required: f.required, + default: f.default, + auto: None, + options: f.options, + placeholder: f.placeholder, + max_length: f.max_length, + display_order: None, + user_editable: f.user_editable, + } + } +} + +// ============================================================================= +// Step DTOs +// ============================================================================= + +/// Response for a step +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StepResponse { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub prompt: String, + pub outputs: Vec, + pub allowed_tools: Vec, + /// Type of review required: "none", "plan", "visual", "pr" + pub review_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_step: Option, + pub permission_mode: String, +} + +impl From<&StepSchema> for StepResponse { + fn from(s: &StepSchema) -> Self { + Self { + name: s.name.clone(), + display_name: s.display_name.clone(), + prompt: s.prompt.clone(), + outputs: s + .outputs + .iter() + .map(|o| match o { + StepOutput::Plan => "plan".to_string(), + StepOutput::Code => "code".to_string(), + StepOutput::Test => "test".to_string(), + StepOutput::Pr => "pr".to_string(), + StepOutput::Ticket => "ticket".to_string(), + StepOutput::Review => "review".to_string(), + StepOutput::Report => "report".to_string(), + StepOutput::Documentation => "documentation".to_string(), + }) + .collect(), + allowed_tools: s.allowed_tools.clone(), + review_type: match s.review_type { + crate::templates::schema::ReviewType::None => "none".to_string(), + crate::templates::schema::ReviewType::Plan => "plan".to_string(), + crate::templates::schema::ReviewType::Visual => "visual".to_string(), + crate::templates::schema::ReviewType::Pr => "pr".to_string(), + }, + next_step: s.next_step.clone(), + permission_mode: match s.permission_mode { + PermissionMode::Default => "default".to_string(), + PermissionMode::Plan => "plan".to_string(), + PermissionMode::AcceptEdits => "acceptEdits".to_string(), + PermissionMode::Delegate => "delegate".to_string(), + }, + } + } +} + +/// Request to create a step +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateStepRequest { + pub name: String, + #[serde(default)] + pub display_name: Option, + pub prompt: String, + #[serde(default)] + pub outputs: Vec, + #[serde(default = "default_all_tools")] + pub allowed_tools: Vec, + /// Type of review required: "none", "plan", "visual", "pr" + #[serde(default = "default_review_type")] + pub review_type: String, + #[serde(default)] + pub next_step: Option, + #[serde(default = "default_permission_mode")] + pub permission_mode: String, +} + +fn default_all_tools() -> Vec { + vec!["*".to_string()] +} + +fn default_review_type() -> String { + "none".to_string() +} + +fn default_permission_mode() -> String { + "default".to_string() +} + +impl From for StepSchema { + fn from(s: CreateStepRequest) -> Self { + Self { + name: s.name, + display_name: s.display_name, + step_type: crate::templates::schema::StepTypeTag::Task, + prompt: s.prompt, + outputs: s + .outputs + .iter() + .filter_map(|o| match o.as_str() { + "plan" => Some(StepOutput::Plan), + "code" => Some(StepOutput::Code), + "test" => Some(StepOutput::Test), + "pr" => Some(StepOutput::Pr), + "ticket" => Some(StepOutput::Ticket), + "review" => Some(StepOutput::Review), + "report" => Some(StepOutput::Report), + "documentation" => Some(StepOutput::Documentation), + _ => None, + }) + .collect(), + allowed_tools: s.allowed_tools, + review_type: match s.review_type.as_str() { + "plan" => crate::templates::schema::ReviewType::Plan, + "visual" => crate::templates::schema::ReviewType::Visual, + "pr" => crate::templates::schema::ReviewType::Pr, + _ => crate::templates::schema::ReviewType::None, + }, + visual_config: None, + on_reject: None, + next_step: s.next_step, + permissions: None, + cli_args: None, + permission_mode: match s.permission_mode.as_str() { + "plan" => PermissionMode::Plan, + "acceptEdits" => PermissionMode::AcceptEdits, + "delegate" => PermissionMode::Delegate, + _ => PermissionMode::Default, + }, + json_schema: None, + json_schema_file: None, + artifact_patterns: vec![], + agent: None, + classifier_config: None, + rag_config: None, + delegator_config: None, + mcp_config: None, + multi_model_config: None, + multi_prompt_config: None, + matrixed_config: None, + } + } +} + +/// Request to update a step +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateStepRequest { + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub outputs: Option>, + #[serde(default)] + pub allowed_tools: Option>, + /// Type of review required: "none", "plan", "visual", "pr" + #[serde(default)] + pub review_type: Option, + #[serde(default)] + pub next_step: Option, + #[serde(default)] + pub permission_mode: Option, +} + +// ============================================================================= +// Collection DTOs +// ============================================================================= + +/// Response for a collection +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CollectionResponse { + pub name: String, + pub description: String, + pub types: Vec, + pub is_active: bool, +} + +impl CollectionResponse { + pub fn from_collection(c: &IssueTypeCollection, is_active: bool) -> Self { + Self { + name: c.name.clone(), + description: c.description.clone(), + types: c.types.clone(), + is_active, + } + } +} diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs new file mode 100644 index 0000000..25b3882 --- /dev/null +++ b/src/rest/dto/kanban.rs @@ -0,0 +1,352 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +// ============================================================================= +// External Issue Type DTOs (from kanban providers) +// ============================================================================= + +/// Summary of an issue type from an external kanban provider (Jira, Linear) +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ExternalIssueTypeSummary { + /// Provider-specific unique identifier + pub id: String, + /// Issue type name (e.g., "Bug", "Story", "Task") + pub name: String, + /// Description of the issue type + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icon/avatar URL from the provider + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, +} + +// ============================================================================= +// Kanban Issue Type Catalog DTOs +// ============================================================================= + +/// A synced kanban issue type from the persisted catalog. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanIssueTypeResponse { + /// Provider-specific ID (Jira type ID, Linear label ID) + pub id: String, + /// Display name (e.g., "Bug", "Story", "Task") + pub name: String, + /// Description from the provider + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Icon/avatar URL from the provider + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, + /// Provider name ("jira", "linear", or "github") + pub provider: String, + /// Project/team key + pub project: String, + /// What this type represents in the provider ("issuetype" or "label") + pub source_kind: String, + /// ISO 8601 timestamp of last sync + pub synced_at: String, +} + +/// Response from syncing kanban issue types from a provider. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SyncKanbanIssueTypesResponse { + /// Number of issue types synced + pub synced: usize, + /// The synced issue types + pub types: Vec, +} + +// ============================================================================= +// Kanban Onboarding DTOs +// ============================================================================= + +/// Which kanban provider an onboarding request targets. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS, PartialEq, Eq)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum KanbanProviderKind { + Jira, + Linear, + Github, +} + +/// Ephemeral Jira credentials supplied by a client during onboarding. +/// +/// These are never persisted to disk by the onboarding endpoints that take +/// this struct — the actual secret stays in the env var named in +/// `api_key_env` once set via `/api/v1/kanban/session-env`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct JiraCredentials { + /// Jira Cloud domain (e.g., "acme.atlassian.net") + pub domain: String, + /// Atlassian account email for Basic Auth + pub email: String, + /// API token / personal access token + pub api_token: String, +} + +/// Ephemeral Linear credentials supplied by a client during onboarding. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearCredentials { + /// Linear API key (prefixed `lin_api_`) + pub api_key: String, +} + +/// Ephemeral GitHub Projects credentials supplied by a client during onboarding. +/// +/// The token must have `project` (or `read:project`) scope. A repo-only token +/// (the kind used for `GITHUB_TOKEN` and operator's git provider) will be +/// rejected at validation time with a friendly "lacks `project` scope" error. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubCredentials { + /// GitHub PAT, fine-grained PAT, or app installation token + pub token: String, +} + +/// Request to validate kanban credentials without persisting them. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateKanbanCredentialsRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Jira-specific validation details (returned on success). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct JiraValidationDetailsDto { + /// Atlassian accountId (used as `sync_user_id`) + pub account_id: String, + /// User display name + pub display_name: String, +} + +/// A Linear team exposed to onboarding clients for project selection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearTeamInfoDto { + pub id: String, + pub key: String, + pub name: String, +} + +/// Linear-specific validation details (returned on success). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearValidationDetailsDto { + /// Linear viewer user ID (used as `sync_user_id`) + pub user_id: String, + pub user_name: String, + pub org_name: String, + pub teams: Vec, +} + +/// A GitHub Project v2 surfaced during onboarding for project picker UIs. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubProjectInfoDto { + /// `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key + pub node_id: String, + /// Project number (e.g., 42) within the owner + pub number: i32, + /// Human-readable project title + pub title: String, + /// Owner login (org or user name) + pub owner_login: String, + /// "Organization" or "User" + pub owner_kind: String, +} + +/// GitHub-specific validation details (returned on success). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubValidationDetailsDto { + /// Authenticated user's login (e.g., "octocat") + pub user_login: String, + /// Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`) + pub user_id: String, + /// All Projects v2 visible to the token (across viewer + organizations) + pub projects: Vec, + /// The env var name the validated token came from. Used by clients to + /// display "Connected via `OPERATOR_GITHUB_TOKEN`" so users can rotate the + /// right token. See Token Disambiguation in the kanban github docs. + pub resolved_env_var: String, +} + +/// Response from validating kanban credentials. +/// +/// `valid: false` is returned for auth failures — never a 4xx/5xx HTTP +/// status — so clients can display `error` inline without exception handling. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateKanbanCredentialsResponse { + pub valid: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Request to list projects/teams from a provider using ephemeral creds. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ListKanbanProjectsRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// A project/team entry returned by `list_projects`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanProjectInfo { + pub id: String, + pub key: String, + pub name: String, +} + +/// Response wrapper for list-projects (wrapped for utoipa compatibility). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ListKanbanProjectsResponse { + pub projects: Vec, +} + +/// Body for writing a Jira project config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteJiraConfigBody { + pub domain: String, + pub email: String, + pub api_key_env: String, + pub project_key: String, + pub sync_user_id: String, +} + +/// Body for writing a Linear project/team config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteLinearConfigBody { + pub workspace_key: String, + pub api_key_env: String, + pub project_key: String, + pub sync_user_id: String, +} + +/// Body for writing a GitHub Projects v2 config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteGithubConfigBody { + /// GitHub owner login (user or org), used as the workspace key + pub owner: String, + /// Env var name where the project-scoped token is set + /// (default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN` + /// — see Token Disambiguation in the kanban github docs. + pub api_key_env: String, + /// `GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`) + pub project_key: String, + /// Numeric GitHub `databaseId` of the user whose items to sync + pub sync_user_id: String, +} + +/// Request to write or upsert a kanban config section. +/// +/// This endpoint does NOT take the secret — only the env var NAME +/// (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteKanbanConfigRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Response after writing a kanban config section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteKanbanConfigResponse { + /// Filesystem path that was written (e.g., ".tickets/operator/config.toml") + pub written_path: String, + /// Header of the top-level section that was upserted + /// (e.g., `[kanban.jira."acme.atlassian.net"]`) + pub section_header: String, +} + +/// Jira session env body — includes the actual secret to set in env. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct JiraSessionEnv { + pub domain: String, + pub email: String, + pub api_token: String, + pub api_key_env: String, +} + +/// Linear session env body — includes the actual secret to set in env. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LinearSessionEnv { + pub api_key: String, + pub api_key_env: String, +} + +/// GitHub Projects session env body — includes the actual secret to set in env. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GithubSessionEnv { + pub token: String, + pub api_key_env: String, +} + +/// Request to set kanban-related env vars on the server for the current +/// session so subsequent `from_config` calls find the API key. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetKanbanSessionEnvRequest { + pub provider: KanbanProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jira: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linear: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github: Option, +} + +/// Response from setting session env vars. +/// +/// `shell_export_block` uses `` placeholders, NOT the actual +/// secret — it is meant for the user to copy into their shell profile. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetKanbanSessionEnvResponse { + /// Names (not values) of env vars that were set in the server process. + pub env_vars_set: Vec, + /// Multi-line `export FOO=""` block for the user to copy + /// into `~/.zshrc` / `~/.bashrc`. + pub shell_export_block: String, +} diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs new file mode 100644 index 0000000..8e4b6a3 --- /dev/null +++ b/src/rest/dto/mod.rs @@ -0,0 +1,220 @@ +//! Data Transfer Objects for the REST API. +//! +//! Organized by domain: +//! - `issue_types`: `IssueType`, `Field`, `Step`, `Collection` DTOs +//! - `kanban`: Kanban onboarding, board, and sync DTOs +//! - `agents`: Agent lifecycle, launch, step execution, and review DTOs +//! - `configuration`: `Delegator`, model server, LLM tool, and project DTOs + +pub mod agents; +pub mod configuration; +pub mod issue_types; +pub mod kanban; + +pub use agents::*; +pub use configuration::*; +pub use issue_types::*; +pub use kanban::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_issue_type_request_into() { + let req = CreateIssueTypeRequest { + key: "test".to_string(), + name: "Test".to_string(), + description: "A test type".to_string(), + mode: "autonomous".to_string(), + glyph: "T".to_string(), + color: None, + project_required: true, + fields: vec![], + steps: vec![CreateStepRequest { + name: "execute".to_string(), + display_name: None, + prompt: "Do the thing".to_string(), + outputs: vec![], + allowed_tools: vec!["*".to_string()], + review_type: "none".to_string(), + next_step: None, + permission_mode: "default".to_string(), + }], + }; + + let it = req.into_issue_type(); + assert_eq!(it.key, "TEST"); // Uppercased + assert_eq!(it.name, "Test"); + assert!(matches!( + it.mode, + crate::templates::schema::ExecutionMode::Autonomous + )); + assert!(matches!( + it.source, + crate::issuetypes::schema::IssueTypeSource::User + )); + assert_eq!(it.steps.len(), 1); + } + + #[test] + fn test_issue_type_response_from() { + let it = crate::issuetypes::IssueType::new_imported( + "TEST".to_string(), + "Test".to_string(), + "A test".to_string(), + "jira".to_string(), + "PROJ".to_string(), + None, + ); + + let resp = IssueTypeResponse::from(&it); + assert_eq!(resp.key, "TEST"); + assert_eq!(resp.mode, "autonomous"); + assert_eq!(resp.source, "jira/PROJ"); + } + + #[test] + fn test_operator_output_default() { + let output = OperatorOutput::default(); + assert_eq!(output.status, ""); + assert!(!output.exit_signal); + assert!(output.confidence.is_none()); + assert!(output.summary.is_none()); + } + + #[test] + fn test_operator_output_serialization() { + let output = OperatorOutput { + status: "complete".to_string(), + exit_signal: true, + confidence: Some(95), + files_modified: Some(3), + tests_status: Some("passing".to_string()), + error_count: Some(0), + tasks_completed: Some(5), + tasks_remaining: Some(0), + summary: Some("Implemented feature".to_string()), + recommendation: Some("Ready for review".to_string()), + blockers: None, + }; + + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains("\"status\":\"complete\"")); + assert!(json.contains("\"exit_signal\":true")); + assert!(json.contains("\"confidence\":95")); + assert!(!json.contains("blockers")); // None fields are skipped + } + + #[test] + fn test_operator_output_deserialization() { + let json = r#"{ + "status": "in_progress", + "exit_signal": false, + "confidence": 60, + "files_modified": 2, + "tests_status": "failing", + "summary": "Working on tests" + }"#; + + let output: OperatorOutput = serde_json::from_str(json).unwrap(); + assert_eq!(output.status, "in_progress"); + assert!(!output.exit_signal); + assert_eq!(output.confidence, Some(60)); + assert_eq!(output.tests_status, Some("failing".to_string())); + } + + #[test] + fn test_step_complete_request_with_operator_output() { + let output = OperatorOutput { + status: "complete".to_string(), + exit_signal: true, + confidence: Some(90), + ..Default::default() + }; + + let request = StepCompleteRequest { + exit_code: 0, + output_valid: true, + output_schema_errors: None, + session_id: Some("session-123".to_string()), + duration_secs: 300, + output_sample: None, + output: Some(output), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("\"exit_code\":0")); + assert!(json.contains("\"output\":{")); + assert!(json.contains("\"status\":\"complete\"")); + } + + #[test] + fn test_launch_response_cmux_fields_present_when_set() { + let resp = LaunchTicketResponse { + agent_id: "a1".to_string(), + ticket_id: "FEAT-001".to_string(), + working_directory: "/tmp".to_string(), + command: "claude".to_string(), + terminal_name: "op-FEAT-001".to_string(), + tmux_session_name: "op-FEAT-001".to_string(), + session_wrapper: Some("cmux".to_string()), + session_window_ref: Some("win-1".to_string()), + session_context_ref: Some("ws-1".to_string()), + session_id: "uuid-1".to_string(), + worktree_created: false, + branch: None, + }; + + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("\"session_wrapper\":\"cmux\"")); + assert!(json.contains("\"session_window_ref\":\"win-1\"")); + assert!(json.contains("\"session_context_ref\":\"ws-1\"")); + } + + #[test] + fn test_launch_response_cmux_fields_absent_when_none() { + let resp = LaunchTicketResponse { + agent_id: "a1".to_string(), + ticket_id: "FEAT-001".to_string(), + working_directory: "/tmp".to_string(), + command: "claude".to_string(), + terminal_name: "op-FEAT-001".to_string(), + tmux_session_name: "op-FEAT-001".to_string(), + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, + session_id: "uuid-1".to_string(), + worktree_created: false, + branch: None, + }; + + let json = serde_json::to_string(&resp).unwrap(); + assert!(!json.contains("session_wrapper")); + assert!(!json.contains("session_window_ref")); + assert!(!json.contains("session_context_ref")); + } + + #[test] + fn test_step_complete_response_with_analysis_fields() { + let json = r#"{ + "status": "completed", + "auto_proceed": true, + "output_valid": true, + "should_iterate": false, + "iteration_count": 1, + "circuit_state": "closed", + "previous_summary": "Built feature", + "cumulative_files_modified": 5, + "cumulative_errors": 0 + }"#; + + let response: StepCompleteResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.status, "completed"); + assert!(response.output_valid); + assert!(!response.should_iterate); + assert_eq!(response.iteration_count, 1); + assert_eq!(response.circuit_state, "closed"); + assert_eq!(response.previous_summary, Some("Built feature".to_string())); + } +} diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index cfe17d7..4a900c5 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -153,6 +153,7 @@ fn dto_to_launch_config(lc: DelegatorLaunchConfigDto) -> DelegatorLaunchConfig { docker: lc.docker, prompt_prefix: lc.prompt_prefix, prompt_suffix: lc.prompt_suffix, + operator_relay: lc.operator_relay, } } @@ -167,6 +168,7 @@ fn launch_config_to_dto(lc: &DelegatorLaunchConfig) -> DelegatorLaunchConfigDto docker: lc.docker, prompt_prefix: lc.prompt_prefix.clone(), prompt_suffix: lc.prompt_suffix.clone(), + operator_relay: lc.operator_relay, } } @@ -386,6 +388,7 @@ mod tests { docker: Some(false), prompt_prefix: Some("Always follow TDD.".to_string()), prompt_suffix: Some("Run tests before finishing.".to_string()), + operator_relay: None, }), }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); @@ -422,4 +425,23 @@ mod tests { let result = create_from_tool(State(state), Json(req)).await; assert!(result.is_err()); } + + #[test] + fn test_dto_round_trips_operator_relay() { + let config = DelegatorLaunchConfig { + yolo: false, + permission_mode: None, + flags: vec![], + use_worktrees: None, + create_branch: None, + docker: None, + prompt_prefix: None, + prompt_suffix: None, + operator_relay: Some(true), + }; + let dto = launch_config_to_dto(&config); + assert_eq!(dto.operator_relay, Some(true)); + let round_tripped = dto_to_launch_config(dto); + assert_eq!(round_tripped.operator_relay, Some(true)); + } } diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 5c413dd..ada940f 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -3,6 +3,8 @@ //! Provides the launch endpoint for starting agents via external clients //! like the VS Code extension. +use std::sync::Arc; + use axum::{ extract::{Path, State}, Json, @@ -310,6 +312,20 @@ pub async fn complete_step( "completed".to_string() }; + // Fire-and-forget: push step-completed activity log to upstream kanban provider. + if status == "completed" { + if let Some(ref ks) = state.kanban_sync { + let ks = Arc::clone(ks); + let ticket_clone = ticket.clone(); + let step = step_name.clone(); + let summary = request.output.as_ref().and_then(|o| o.summary.clone()); + tokio::spawn(async move { + ks.on_step_completed(&ticket_clone, &step, "unknown", summary.as_deref()) + .await; + }); + } + } + // Find next step info let next_step_info = current_step.next_step.as_ref().and_then(|next_name| { issue_type.get_step(next_name).map(|step| NextStepInfo { @@ -492,6 +508,7 @@ mod tests { docker: Some(true), prompt_prefix: Some("PREFIX".to_string()), prompt_suffix: Some("SUFFIX".to_string()), + operator_relay: None, }), }); let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); @@ -705,6 +722,7 @@ mod tests { docker: Some(false), prompt_prefix: Some("BEGIN".to_string()), prompt_suffix: Some("END".to_string()), + operator_relay: None, }), }]); let ctx = AgentContext { @@ -753,6 +771,7 @@ mod tests { step: "review".to_string(), content: "# test".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: Some(worktree.to_string_lossy().to_string()), branch: None, diff --git a/src/rest/state.rs b/src/rest/state.rs index f4a1143..f297e86 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; +use crate::api::kanban_sync::KanbanBidirectionalSync; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; use crate::startup::templates::{ensure_schemas, init_default_templates}; @@ -22,6 +23,9 @@ pub struct ApiState { pub tickets_path: PathBuf, /// Active MCP SSE sessions (`session_id` -> message sender) pub mcp_sessions: Arc>>>, + /// Bidirectional kanban sync service (present only when at least one project has + /// `bidirectional: true` in its sync config). + pub kanban_sync: Option>, } impl ApiState { @@ -77,11 +81,22 @@ impl ApiState { } } + let config_arc = Arc::new(config); + let kanban_sync = { + let ks = KanbanBidirectionalSync::new(Arc::clone(&config_arc)); + if ks.has_any_bidirectional() { + Some(Arc::new(ks)) + } else { + None + } + }; + Self { registry: Arc::new(RwLock::new(registry)), - config: Arc::new(config), + config: config_arc, tickets_path, mcp_sessions: Arc::new(Mutex::new(HashMap::new())), + kanban_sync, } } @@ -126,4 +141,56 @@ mod tests { PathBuf::from("/tmp/tickets/operator/issuetypes") ); } + + #[test] + fn test_kanban_sync_none_when_no_bidirectional_projects() { + // Default config has no kanban projects configured, so kanban_sync is None. + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + assert!( + state.kanban_sync.is_none(), + "kanban_sync should be None when no bidirectional projects are configured" + ); + } + + #[test] + fn test_kanban_sync_some_when_bidirectional_project_configured() { + use crate::config::{JiraConfig, KanbanConfig, ProjectSyncConfig}; + use std::collections::HashMap; + + let mut project_sync = ProjectSyncConfig { + sync_user_id: String::new(), + sync_statuses: Vec::new(), + collection_name: None, + type_mappings: HashMap::new(), + bidirectional: true, + }; + let _ = &mut project_sync; // suppress unused_mut if needed + + let mut projects = HashMap::new(); + projects.insert("MY-PROJECT".to_string(), project_sync); + + let jira_config = JiraConfig { + enabled: true, + api_key_env: "OPERATOR_JIRA_API_KEY".to_string(), + email: "test@example.com".to_string(), + projects, + }; + + let mut jira_map = HashMap::new(); + jira_map.insert("test.atlassian.net".to_string(), jira_config); + + let mut config = Config::default(); + config.kanban = KanbanConfig { + jira: jira_map, + linear: HashMap::new(), + github: HashMap::new(), + }; + + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + assert!( + state.kanban_sync.is_some(), + "kanban_sync should be Some when at least one project has bidirectional: true" + ); + } } diff --git a/src/steps/manager.rs b/src/steps/manager.rs index 7050bb3..579c5ea 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -404,6 +404,7 @@ mod tests { step: step.to_string(), content: "Test content".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/steps/session.rs b/src/steps/session.rs index d0a9820..348450d 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -271,6 +271,7 @@ mod tests { step: "plan".to_string(), content: "Test content".to_string(), sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/ui/create_dialog.rs b/src/ui/create_dialog.rs index 8966fd8..2df2262 100644 --- a/src/ui/create_dialog.rs +++ b/src/ui/create_dialog.rs @@ -882,7 +882,6 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { } /// Render a form with its fields (free function to avoid borrow issues) -#[allow(dead_code)] fn render_form(frame: &mut Frame, area: Rect, form: &mut TicketForm) { let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index d926880..6b8c9f4 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -648,6 +648,7 @@ mod tests { step: String::new(), content: "Test content".to_string(), sessions: HashMap::new(), + step_delegators: HashMap::new(), llm_task: crate::queue::LlmTask::default(), worktree_path: None, branch: None, diff --git a/src/ui/kanban_view.rs b/src/ui/kanban_view.rs index 9a1faf6..66064a8 100644 --- a/src/ui/kanban_view.rs +++ b/src/ui/kanban_view.rs @@ -117,12 +117,6 @@ impl KanbanView { self.status_message = Some(message.to_string()); } - /// Clear status message (used when sync completes) - #[allow(dead_code)] - pub fn clear_status(&mut self) { - self.status_message = None; - } - fn selected_index(&self) -> usize { self.list_state.selected().unwrap_or(0) } diff --git a/tests/relay_integration.rs b/tests/relay_integration.rs index ebacbbc..4120852 100644 --- a/tests/relay_integration.rs +++ b/tests/relay_integration.rs @@ -6,14 +6,14 @@ //! Exercises ask/reply, broadcast, rename, timeout, and peer-gone flows //! using only in-process async code (no external services needed). //! -//! **Layer 2** — `relay-channel` binary driven via JSON-RPC stdio. +//! **Layer 2** — `opr8r relay` binary driven via JSON-RPC stdio. //! Verifies the MCP protocol surface: initialize, tools/list, relay_peers. //! Binary tests skip gracefully if the binary hasn't been built yet. //! //! ## Running //! //! ```bash -//! # Build opr8r first (provides relay-channel subcommand for Layer 2) +//! # Build opr8r first (provides relay subcommand for Layer 2) //! cargo build --manifest-path opr8r/Cargo.toml //! //! # Run all relay integration tests @@ -343,40 +343,40 @@ async fn test_peer_gone_on_disconnect() { ctx.hub.shutdown().await; } -// ── Layer 2: relay-channel binary via JSON-RPC stdio ───────────────────────── +// ── Layer 2: opr8r relay binary via JSON-RPC stdio ─────────────────────────── -/// Returns `(binary_path, extra_args)` for invoking the relay-channel MCP server. +/// Returns `(binary_path, extra_args)` for invoking the relay MCP server. /// /// Preference order: -/// 1. `opr8r/target/debug/opr8r relay-channel` (primary distribution vehicle) -/// 2. `opr8r/target/release/opr8r relay-channel` -/// 3. `target/debug/relay-channel` (legacy standalone, kept for transition) -/// 4. `target/release/relay-channel` -fn relay_channel_command() -> (PathBuf, Vec) { +/// 1. `opr8r/target/debug/opr8r relay` (primary distribution vehicle) +/// 2. `opr8r/target/release/opr8r relay` +/// 3. `target/debug/operator-relay` (legacy standalone, kept for transition) +/// 4. `target/release/operator-relay` +fn operator_relay_command() -> (PathBuf, Vec) { let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let opr8r_debug = manifest.join("opr8r/target/debug/opr8r"); if opr8r_debug.exists() { - return (opr8r_debug, vec!["relay-channel".to_string()]); + return (opr8r_debug, vec!["relay".to_string()]); } let opr8r_release = manifest.join("opr8r/target/release/opr8r"); if opr8r_release.exists() { - return (opr8r_release, vec!["relay-channel".to_string()]); + return (opr8r_release, vec!["relay".to_string()]); } - let debug = manifest.join("target/debug/relay-channel"); + let debug = manifest.join("target/debug/operator-relay"); if debug.exists() { return (debug, vec![]); } - (manifest.join("target/release/relay-channel"), vec![]) + (manifest.join("target/release/operator-relay"), vec![]) } fn binary_available() -> bool { - let (binary, _) = relay_channel_command(); + let (binary, _) = operator_relay_command(); binary.exists() } -/// Create a tokio Command pre-configured to run the relay-channel MCP server. -fn make_relay_channel_cmd() -> tokio::process::Command { - let (binary, args) = relay_channel_command(); +/// Create a tokio Command pre-configured to run the relay MCP server. +fn make_operator_relay_cmd() -> tokio::process::Command { + let (binary, args) = operator_relay_command(); let mut cmd = tokio::process::Command::new(binary); cmd.args(args); cmd @@ -401,7 +401,7 @@ async fn rpc_recv( line.clear(); let n = reader.read_line(&mut line).await.expect("read_line failed"); if n == 0 { - panic!("relay-channel process closed stdout waiting for id={id}"); + panic!("relay process closed stdout waiting for id={id}"); } if let Ok(val) = serde_json::from_str::(line.trim()) { if val.get("id").and_then(|v| v.as_u64()) == Some(id) { @@ -419,21 +419,21 @@ async fn test_binary_initialize() { skip_if_not_configured!(); if !binary_available() { eprintln!( - "Skipping: relay-channel binary not found (run `cargo build --bin relay-channel`)" + "Skipping: operator-relay binary not found (run `cargo build --manifest-path opr8r/Cargo.toml`)" ); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "test-agent-init") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -456,7 +456,7 @@ async fn test_binary_initialize() { assert_eq!( resp["result"]["serverInfo"]["name"].as_str(), - Some("relay-channel"), + Some("relay"), "serverInfo.name mismatch: {resp}" ); assert!( @@ -484,20 +484,20 @@ async fn test_binary_initialize() { async fn test_binary_tools_list() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "test-agent-tools") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -553,20 +553,20 @@ async fn test_binary_tools_list() { async fn test_binary_relay_peers_empty() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "solo-agent") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -612,7 +612,7 @@ async fn test_binary_relay_peers_empty() { async fn test_binary_relay_peers_with_peer() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } @@ -621,14 +621,14 @@ async fn test_binary_relay_peers_with_peer() { // Register alice via ChannelSession so the binary will see her let (alice, _) = ctx.connect_as("alice").await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "observer") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -675,20 +675,20 @@ async fn test_binary_relay_peers_with_peer() { async fn test_binary_relay_ask_returns_immediately() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "asker") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -741,20 +741,20 @@ async fn test_binary_relay_ask_returns_immediately() { async fn test_binary_relay_rename_structured() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "old-name") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -799,21 +799,21 @@ async fn test_binary_relay_rename_structured() { async fn test_binary_incoming_ask_notification_shape() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; // Spawn a binary that will receive the ask - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "receiver") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let stdout_raw = child.stdout.take().unwrap(); @@ -904,20 +904,20 @@ async fn test_binary_incoming_ask_notification_shape() { async fn test_binary_incoming_reply_notification_content_is_raw_text() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut asker_child = make_relay_channel_cmd() + let mut asker_child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "asker2") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut asker_stdin = asker_child.stdin.take().unwrap(); let mut asker_stdout = BufReader::new(asker_child.stdout.take().unwrap()); @@ -997,20 +997,20 @@ async fn test_binary_incoming_reply_notification_content_is_raw_text() { async fn test_binary_ask_error_notification_uses_lowercase_code() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "err-tester") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let stdout_raw = child.stdout.take().unwrap(); @@ -1089,20 +1089,20 @@ async fn test_binary_ask_error_notification_uses_lowercase_code() { async fn test_binary_relay_ask_schema_has_thread_id() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "schema-tester") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); @@ -1145,20 +1145,20 @@ async fn test_binary_relay_ask_schema_has_thread_id() { async fn test_binary_relay_ask_thread_id_propagated_to_notification() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut receiver_child = make_relay_channel_cmd() + let mut receiver_child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "thread-receiver") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel (receiver)"); + .expect("failed to spawn relay (receiver)"); let mut receiver_stdin = receiver_child.stdin.take().unwrap(); let receiver_stdout_raw = receiver_child.stdout.take().unwrap(); @@ -1226,20 +1226,20 @@ async fn test_binary_relay_ask_thread_id_propagated_to_notification() { async fn test_binary_relay_broadcast_schema_has_exclude_self() { skip_if_not_configured!(); if !binary_available() { - eprintln!("Skipping: relay-channel binary not found"); + eprintln!("Skipping: operator-relay binary not found"); return; } let ctx = RelayTestContext::new().await; - let mut child = make_relay_channel_cmd() + let mut child = make_operator_relay_cmd() .env("RELAY_HUB_SOCKET", ctx.socket_path.to_str().unwrap()) .env("RELAY_AGENT_NAME", "broadcast-schema-tester") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() - .expect("failed to spawn relay-channel"); + .expect("failed to spawn relay"); let mut stdin = child.stdin.take().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); From 6ef7cc699b5a0c075383933f9e498b95538fb44c Mon Sep 17 00:00:00 2001 From: untra Date: Thu, 7 May 2026 16:17:37 -0600 Subject: [PATCH 5/6] upgrade rust toolchain to 1.88.0 Co-Authored-By: Claude Opus 4.5 --- .github/workflows/build.yaml | 12 +- .github/workflows/docs.yml | 2 +- .../workflows/integration-tests-matrix.yml | 14 +- .github/workflows/integration-tests.yml | 2 +- .../2026-04-18-polymorphic-steps-handoff.md | 397 ------------------ opr8r/src/cli.rs | 5 +- 6 files changed, 16 insertions(+), 416 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9cd7031..12e237f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@v4 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 with: components: rustfmt, clippy @@ -76,7 +76,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 with: components: llvm-tools-preview @@ -137,7 +137,7 @@ jobs: - uses: actions/checkout@v4 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux @@ -173,7 +173,7 @@ jobs: - uses: actions/checkout@v4 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -221,7 +221,7 @@ jobs: - uses: actions/checkout@v4 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 with: targets: ${{ matrix.target }} @@ -362,7 +362,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo registry uses: actions/cache@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 45eea5f..4429413 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,7 +30,7 @@ jobs: # Install Rust toolchain for docs generation - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 # Cache cargo for faster builds - name: Cache cargo diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index 98f3496..c6113e1 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -74,7 +74,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -119,7 +119,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -175,7 +175,7 @@ jobs: run: brew install tmux - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -234,7 +234,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -303,7 +303,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -356,7 +356,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 @@ -408,7 +408,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index f63a2f6..ffc0b30 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -58,7 +58,7 @@ jobs: git remote set-head origin --auto || true - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.85.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Cache cargo uses: actions/cache@v4 diff --git a/docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md b/docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md deleted file mode 100644 index 6971abb..0000000 --- a/docs/superpowers/specs/2026-04-18-polymorphic-steps-handoff.md +++ /dev/null @@ -1,397 +0,0 @@ -# Polymorphic Step Types — Remaining Work Handoff - -## What's Done (Phases 1-3 + Phase 4 partial) - -All schema types, validation, step output artifacts, single-agent executors, and multi-agent aggregation functions are implemented and tested. 1,666 tests pass, `cargo fmt && cargo clippy -- -D warnings` clean. - -### Completed artifacts: - -| File | What was added | -|------|---------------| -| `src/templates/schema.rs` | `StepTypeTag` enum (8 variants), `ClassifierConfig`, `ClassifierOutputType`, `RagConfig`, `RagSource`, `DelegatorStepConfig`, `McpStepConfig`, `McpToolRef`, `MultiModelConfig`, `VotingStrategy`, `VotingMode`, `MultiPromptConfig`, `SelectionStrategy`, `MatrixedConfig`, `MatrixedOutputFormat`. `StepSchema` updated with `step_type` field + 7 optional `*_config` fields. `validate_type_config()` on StepSchema. | -| `src/templates/step_type.rs` | `classifier_json_schema()`, `prompt_augmentation()`, `effective_allowed_tools()`, `effective_agent()`, `aggregate_multi_model()`, `aggregate_multi_prompt()`, `aggregate_matrixed()`, `apply_votes()`, `apply_selection()`, `apply_aggregation()`, `select_winner_by_strategy()`. 24 unit tests. | -| `src/steps/manager.rs` | `load_step_outputs()` reads `{worktree}/.tickets/steps/{name}.output.json` into Handlebars context as `{{ steps.{name}.* }}`. `render_prompt()` injects step outputs. 5 tests. | -| `src/steps/session.rs` | `generate_prompt()` uses `effective_allowed_tools()` and `prompt_augmentation()`. | -| `src/agents/launcher/step_config.rs` | `get_step_config()` derives `json_schema` from classifier config, injects MCP server permissions, merges delegator permissions. | -| `src/agents/agent_switcher.rs` | `needs_switch()` uses `effective_agent()`. | -| `src/agents/launcher/mod.rs` | `collect_tools_for_ticket()` uses `effective_agent()`. | -| `src/queue/ticket.rs` | `advance_step()` uses `effective_agent()`. | -| `src/state.rs` | `MultiAgentGroup`, `MultiAgentPhase`, `multi_agent_groups` field on `State`, helper methods: `create_multi_agent_group()`, `get_group_for_agent()`, `get_group_for_ticket()`, `record_agent_output()`, `update_group_phase()`, `complete_group()`, `cleanup_finished_groups()`. | -| `src/docs_gen/issuetype_json_schema.rs` | Generator now outputs to `src/schemas/issuetype_schema.json` (overwrites stale hand-written file). | -| `src/schemas/issuetype_schema.json` | Auto-generated, includes all new step types. | - ---- - -## Remaining Work - -### Phase 4d: Fan-out launch in `src/agents/launcher/mod.rs` - -**Where to insert**: In `launch_with_options()` (line 227), after getting the effective step (around line 247), detect if the current step is a multi-agent type and branch: - -```rust -// After getting the step schema for the ticket: -let step = ticket.current_step_schema(); -if let Some(ref step) = step { - match step.step_type { - StepTypeTag::MultiModel => return self.launch_multi_model(ticket, step, options).await, - StepTypeTag::MultiPrompt => return self.launch_multi_prompt(ticket, step, options).await, - StepTypeTag::Matrixed => return self.launch_matrixed(ticket, step, options).await, - _ => {} // fall through to existing single-agent launch - } -} -``` - -**New methods to add on `Launcher`**: - -1. `launch_multi_model()`: - - Read `step.multi_model_config.delegators` (e.g., `["claude-opus", "gemini-pro"]`) - - For each delegator name, resolve to a `Delegator` from `config.delegators` - - Call `launch_with_options()` (or the inner launch_in_tmux/cmux) N times, each with: - - `session_name = format!("op-{}-{}", ticket.id, delegator_name)` - - The delegator's tool+model - - Same prompt (from the step) - - Same worktree - - Register each sub-agent via `state.add_agent_with_options()` - - Create a `MultiAgentGroup` via `state.create_multi_agent_group(ticket_id, step_name, "multi_model", agent_ids)` - - Return the group_id (or first agent_id) - -2. `launch_multi_prompt()`: - - Read `step.multi_prompt_config.prompt_variations` - - Same delegator for all (from `multi_prompt_config.agent` or default) - - N launches, each with a different prompt from `prompt_variations[i]` - - Session names: `format!("op-{}-v{}", ticket.id, i)` - - Create group with keys `"0"`, `"1"`, ... for `individual_outputs` - -3. `launch_matrixed()`: - - NxM launches: delegators × prompt_variations - - Session names: `format!("op-{}-{}-v{}", ticket.id, delegator_name, prompt_idx)` - - Create group with keys `"{delegator}:{prompt_idx}"` - -**Important**: Each sub-agent writes its output to `{worktree}/.tickets/steps/{step_name}/{agent_id_or_key}.json`. The sub-agent's prompt should include an instruction like: "Write your final output to `.tickets/steps/{step_name}/{key}.json`." - -**Slot accounting**: Check `state.running_agents().len() + N <= config.effective_max_agents()` before launching. If not enough slots, either fail with an error or launch partial (queuing is complex — fail-fast is simpler for v1). - -### Phase 4e: Group-aware sync in `src/agents/sync.rs` - -**Where to insert**: In `sync_all()` (line 99), at the `SyncAction::StepCompleted` handler (line 249): - -```rust -SyncAction::StepCompleted => { - // Check if this agent belongs to a multi-agent group - if let Some(group) = state.get_group_for_agent(&agent_id) { - let group_id = group.group_id.clone(); - let step_type = group.step_type.clone(); - let step_name = group.step_name.clone(); - - // Read this agent's output from worktree - let output = read_agent_step_output(ticket, &step_name, &agent_id); - - // Record it; returns true if all agents in group are done - let all_done = state.record_agent_output(&agent_id, output)?; - - if all_done { - let group = state.get_group_for_ticket(&ticket.id).unwrap().clone(); - - // Get step schema for config access - let step_schema = ticket.current_step_schema(); - - let aggregated = match step_type.as_str() { - "multi_model" => { - let config = step_schema.and_then(|s| s.multi_model_config.clone()); - if let Some(cfg) = config { - let mut result = step_type::aggregate_multi_model( - &group.individual_outputs, &cfg - ); - // TODO: Phase 2 voting round if cfg.share_answers - // For v1, use the pre-vote winner selection - result - } else { serde_json::json!(null) } - } - "multi_prompt" => { /* similar with aggregate_multi_prompt */ } - "matrixed" => { /* similar with aggregate_matrixed */ } - _ => serde_json::json!(null), - }; - - // Write aggregated output artifact - write_step_output_artifact(ticket, &step_name, &aggregated)?; - - // Mark group complete - state.complete_group(&group_id, aggregated)?; - - // Advance the ticket's step (single advance for the whole group) - ticket.advance_step()?; - - // Clean up sub-agent records - for aid in &group.agent_ids { - state.remove_agent(aid)?; - } - } - // else: not all done yet, wait for remaining sub-agents - } else { - // Existing single-agent completion logic (unchanged) - match ticket.advance_step() { ... } - } -} -``` - -**Helper functions to add**: - -```rust -fn read_agent_step_output(ticket: &Ticket, step_name: &str, key: &str) -> serde_json::Value { - let worktree = ticket.worktree_path.as_deref().unwrap_or("."); - let path = format!("{worktree}/.tickets/steps/{step_name}/{key}.json"); - std::fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(serde_json::Value::Null) -} - -fn write_step_output_artifact( - ticket: &Ticket, step_name: &str, output: &serde_json::Value -) -> Result<()> { - let worktree = ticket.worktree_path.as_deref().unwrap_or("."); - let dir = format!("{worktree}/.tickets/steps"); - std::fs::create_dir_all(&dir)?; - let path = format!("{dir}/{step_name}.output.json"); - std::fs::write(&path, serde_json::to_string_pretty(output)?)?; - Ok(()) -} -``` - -### Phase 4f: REST API for grouped completion in `src/rest/routes/launch.rs` - -**Where to insert**: In `complete_step()` (line 173), after determining the status: - -```rust -// After existing status determination (line 204-225): - -// Check if this agent is part of a multi-agent group -let api_state = state.lock().await; -if let Some(group) = api_state.get_group_for_agent(&request.session_id.unwrap_or_default()) { - // This is a sub-agent completion — collect but don't advance - let output = request.output.as_ref() - .and_then(|o| o.summary.as_ref()) - .map(|s| serde_json::json!(s)) - .unwrap_or(serde_json::Value::Null); - - let all_done = api_state.record_agent_output(&agent_id, output)?; - - if all_done { - // Return status indicating the group is ready for aggregation - // The sync loop will handle the actual aggregation - return Ok(Json(StepCompleteResponse { - status: "group_complete".to_string(), - auto_proceed: false, // sync loop handles advancement - ..default_response - })); - } else { - return Ok(Json(StepCompleteResponse { - status: "group_partial".to_string(), - auto_proceed: false, - ..default_response - })); - } -} -// else: fall through to existing single-agent logic -``` - -Add `"group_complete"` and `"group_partial"` as recognized status values. - -### Phase 2 Voting/Selection Rounds - -The most complex part. When `all_done` is true in the sync loop and the step type is `multi_model` with `share_answers: true`: - -1. **Single Judge mode** (`voting_mode == SingleJudge`): - - Launch ONE new agent (first delegator) with a voting prompt that includes all collected responses - - Use `classifier_json_schema` to generate a schema for `{ "vote": , "reasoning": "" }` - - When this agent completes, call `apply_votes()` on the aggregated output - - Then write the artifact and advance - -2. **Multi Voter mode** (`voting_mode == MultiVoter`): - - Transition group phase to `Voting` - - Launch N new agents (one per original delegator) with voting prompts - - Each votes via structured output - - When all N voting agents complete, tally and `apply_votes()` - -For v1, implement `SingleJudge` only. `MultiVoter` can be a follow-up. - -For `multi_prompt` with `selection_strategy`: - - Launch one agent with selection_prompt + all variation outputs - - Agent returns `{ "selected_index": N }` - - Call `apply_selection()` on the aggregated output - ---- - -## Phase 5: Collection Examples & Remaining Items - -### 5a. Add `if/then` conditionals to JSON schema - -In `src/docs_gen/issuetype_json_schema.rs`, in `generate()` (after line 48 where schema metadata is added), add post-processing: - -```rust -// Add if/then conditionals for step type validation -if let Some(defs) = schema_value.get("$defs").or(schema_value.get("definitions")) { - if defs.get("StepSchema").is_some() { - let step_types = [ - ("classifier", "classifier_config"), - ("rag", "rag_config"), - ("delegator", "delegator_config"), - ("mcp", "mcp_config"), - ("multi_model", "multi_model_config"), - ("multi_prompt", "multi_prompt_config"), - ("matrixed", "matrixed_config"), - ]; - // Find StepSchema in $defs and add allOf with if/then blocks - // Each: if { properties: { type: { const: X } } } then { required: [X_config] } - } -} -``` - -This is optional — the Rust-side `validate_type_config()` already enforces this at load time. - -### 5b. Re-enable JSON_SCHEMA_ENABLED for classifier steps - -In `src/agents/launcher/llm_command.rs`: -- Line 13: Change `const JSON_SCHEMA_ENABLED: bool = false;` to `true` -- OR: Make it conditional — only enable for classifier steps by checking `step_config.json_schema.is_some()` regardless of the constant - -The safer approach: remove the constant entirely and always pass `--json-schema` when `step_config.json_schema` is `Some`. The original issue was command-line length, but we write schemas to files (not inline), so the path length should be fine. - -### 5c. Regenerate documentation - -```bash -cargo run -- docs --only issuetype-json-schema # Regenerates src/schemas/issuetype_schema.json -cargo run -- docs --only issuetype # Regenerates docs/schemas/issuetype.md -``` - -### 5d. Create example collection with new step types - -Create `src/collections/advanced/` with example issuetypes demonstrating each new step type: - -1. **`REVIEW.json`** — multi-model consensus review: - ```json - { - "key": "REVIEW", - "name": "Multi-Model Review", - "steps": [ - { - "name": "review", - "type": "multi_model", - "prompt": "Review this PR for issues", - "outputs": ["review"], - "multi_model_config": { - "delegators": ["claude-opus", "gemini-pro"], - "voting_strategy": "majority", - "share_answers": true - }, - "next_step": "apply" - }, - { - "name": "apply", - "type": "task", - "prompt": "Apply the winning review: {{ steps.review.winner_response }}", - "outputs": ["code"], - "allowed_tools": ["Read", "Write", "Edit"] - } - ] - } - ``` - -2. **`ASSESS.json`** — classifier + RAG pipeline: - ```json - { - "steps": [ - { "type": "rag", "name": "gather", ... }, - { "type": "classifier", "name": "classify", "classifier_config": { "output_type": "enum", "options": [...] } }, - { "type": "task", "name": "act", "prompt": "Severity is {{ steps.classify.value }}" } - ] - } - ``` - -### 5e. Add `advanced` collection preset - -In `src/config.rs`, add to `CollectionPreset`: -```rust -pub enum CollectionPreset { - Simple, - DevKanban, - DevopsKanban, - Advanced, // NEW - Custom, -} -``` - -With `issue_types()` returning the advanced collection types. - ---- - -## Testing Checklist - -### Unit tests (already passing — 1,666 total): -- [x] `StepTypeTag` serde round-trip for all 8 variants -- [x] All config structs deserialize from JSON -- [x] `validate_type_config()` catches missing configs, invalid options, minimum counts -- [x] `classifier_json_schema()` generates correct schema for all 5 output types -- [x] `prompt_augmentation()` produces correct text for each step type -- [x] `effective_agent()` resolves from type-specific configs with fallback -- [x] `effective_allowed_tools()` resolves from type-specific configs with fallback -- [x] `load_step_outputs()` reads artifacts into Handlebars context -- [x] `render_prompt()` interpolates `{{ steps.X.value }}` correctly -- [x] `aggregate_multi_model()` collects responses and selects winner -- [x] `apply_votes()` updates winner based on vote tallies -- [x] `aggregate_multi_prompt()` collects variations -- [x] `apply_selection()` updates selected response -- [x] `aggregate_matrixed()` builds NxM matrix -- [x] `apply_aggregation()` sets aggregated result -- [x] `MultiAgentGroup` state persistence (serde round-trip via `#[serde(default)]`) -- [x] All existing collection JSONs parse without errors (backward compat) - -### Tests to write for Phase 4d-4f: -- [ ] `launch_multi_model()` creates N agents + 1 group in state -- [ ] `launch_multi_prompt()` creates N agents with different prompts -- [ ] Slot limit check: launching exceeding `max_parallel` fails gracefully -- [ ] Sync: single sub-agent completion → group not done yet -- [ ] Sync: all sub-agents complete → triggers aggregation + advance_step -- [ ] Sync: aggregated output written to `.tickets/steps/{name}.output.json` -- [ ] REST: `complete_step` returns `group_partial` for incomplete groups -- [ ] REST: `complete_step` returns `group_complete` when last agent finishes -- [ ] Voting round (single_judge): launches judge agent, processes vote output -- [ ] State cleanup: `cleanup_finished_groups()` removes completed groups - -### CI validation: -```bash -cargo fmt # Must be clean -cargo clippy -- -D warnings # Must be clean -cargo test # All tests pass -cargo run -- docs # Regenerates all docs without error -``` - -### Manual E2E test: -1. Configure 2+ delegators in operator config -2. Create a ticket with a `multi_model` step referencing those delegators -3. Launch the ticket — verify N tmux sessions spawn -4. Let all agents complete — verify aggregated output artifact appears -5. Verify the next step sees `{{ steps.{name}.winner_response }}` - ---- - -## Key Files Reference - -| File | Purpose | -|------|---------| -| `src/templates/schema.rs` | All type definitions (StepTypeTag, configs, enums) | -| `src/templates/step_type.rs` | Prompt augmentation, effective_agent/tools, aggregation functions | -| `src/steps/manager.rs` | Step output artifact loading into Handlebars context | -| `src/steps/session.rs` | Prompt generation with type augmentation | -| `src/agents/launcher/step_config.rs` | Step config extraction (classifier JSON schema, MCP injection) | -| `src/agents/launcher/mod.rs` | **MODIFY**: Add fan-out launch methods | -| `src/agents/sync.rs` | **MODIFY**: Add group-aware completion detection | -| `src/rest/routes/launch.rs` | **MODIFY**: Add grouped completion handling | -| `src/state.rs` | MultiAgentGroup tracking + helper methods | -| `src/schemas/issuetype_schema.json` | Auto-generated JSON schema (run `cargo run -- docs --only issuetype-json-schema`) | -| `src/docs_gen/issuetype_json_schema.rs` | Schema generator (outputs to `src/schemas/`) | -| `src/agents/launcher/llm_command.rs:13` | `JSON_SCHEMA_ENABLED` constant — set to `true` | - -## Branch - -All work is on branch `issuetype-onboarding-kanban-both`. No commits have been made for this work yet — all changes are unstaged. diff --git a/opr8r/src/cli.rs b/opr8r/src/cli.rs index 8514c22..35c7f78 100644 --- a/opr8r/src/cli.rs +++ b/opr8r/src/cli.rs @@ -97,10 +97,7 @@ mod tests { #[test] fn test_relay_subcommand_parses() { let result = Args::try_parse_from(["opr8r", "relay"]); - assert!( - result.is_ok(), - "relay subcommand should parse successfully" - ); + assert!(result.is_ok(), "relay subcommand should parse successfully"); let args = result.unwrap(); assert!(matches!(args.subcommand, Some(Cmd::Relay))); } From f0d57cec538f15094ac0b655d1f05923debd7511 Mon Sep 17 00:00:00 2001 From: untra Date: Thu, 7 May 2026 16:38:26 -0600 Subject: [PATCH 6/6] platform support page, fix suggested deficiencies clippy, linting etc Co-Authored-By: Claude Opus 4.5 --- .github/workflows/backstage.yaml | 3 + .github/workflows/build.yaml | 1 + .github/workflows/docs.yml | 3 + .../workflows/integration-tests-matrix.yml | 3 + .github/workflows/integration-tests.yml | 3 + .github/workflows/opr8r.yaml | 3 + .github/workflows/sign-windows.yaml | 3 + .github/workflows/vscode-extension.yaml | 3 + .github/workflows/zed-extension.yaml | 3 + crates/relay/src/lib.rs | 3 + docs/_data/navigation.yml | 2 + docs/getting-started/platform-support.md | 82 +++++++++++++++++++ docs/getting-started/prerequisites.md | 2 + opr8r/Cargo.lock | 8 +- opr8r/src/main.rs | 9 ++ opr8r/src/operator_relay.rs | 11 +-- src/api/kanban_sync.rs | 2 +- src/app/mod.rs | 6 ++ src/app/tests.rs | 8 +- src/relay/mod.rs | 1 + src/rest/state.rs | 12 +-- tests/relay_integration.rs | 24 ++---- vscode-extension/src/status-provider.ts | 2 +- .../test/suite/status-provider.test.ts | 6 +- vscode-extension/webview-ui/App.tsx | 2 +- vscode-extension/webview-ui/types/defaults.ts | 2 + 26 files changed, 163 insertions(+), 44 deletions(-) create mode 100644 docs/getting-started/platform-support.md diff --git a/.github/workflows/backstage.yaml b/.github/workflows/backstage.yaml index 58d6409..11c2d5c 100644 --- a/.github/workflows/backstage.yaml +++ b/.github/workflows/backstage.yaml @@ -11,6 +11,9 @@ concurrency: group: backstage-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: app-ci: runs-on: ubuntu-latest diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 12e237f..4b2013c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -26,6 +26,7 @@ on: env: CARGO_TERM_COLOR: always + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' permissions: contents: write diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4429413..282567c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,6 +21,9 @@ concurrency: group: "pages" cancel-in-progress: false +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index c6113e1..1e8de8d 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -49,6 +49,9 @@ on: type: boolean default: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: # ============================================================================ # BUILD ARTIFACTS FIRST diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ffc0b30..46cb518 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -41,6 +41,9 @@ on: type: boolean default: false +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: integration-tests: runs-on: ubuntu-latest diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index 682677c..65bd712 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -17,6 +17,9 @@ concurrency: group: opr8r-${{ github.ref }} cancel-in-progress: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: lint-test: name: Lint & Test diff --git a/.github/workflows/sign-windows.yaml b/.github/workflows/sign-windows.yaml index d5c0dcd..ff14881 100644 --- a/.github/workflows/sign-windows.yaml +++ b/.github/workflows/sign-windows.yaml @@ -19,6 +19,9 @@ on: AZ_PIPELINE_DEFINITION_ID: required: true +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: sign: runs-on: ubuntu-latest diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 2200805..4acb356 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -23,6 +23,9 @@ defaults: run: working-directory: vscode-extension +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: lint-test: runs-on: ubuntu-latest diff --git a/.github/workflows/zed-extension.yaml b/.github/workflows/zed-extension.yaml index f815b14..16fdc5a 100644 --- a/.github/workflows/zed-extension.yaml +++ b/.github/workflows/zed-extension.yaml @@ -11,6 +11,9 @@ on: - 'zed-extension/**' - '.github/workflows/zed-extension.yaml' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + jobs: lint-build: name: Lint and Build diff --git a/crates/relay/src/lib.rs b/crates/relay/src/lib.rs index 2829ee6..0162fd5 100644 --- a/crates/relay/src/lib.rs +++ b/crates/relay/src/lib.rs @@ -4,8 +4,11 @@ //! so that relay tooling can be distributed via the signed `opr8r` binary without //! pulling in the full TUI/REST dependency stack. +#[cfg(unix)] pub mod channel_session; +#[cfg(unix)] pub mod client; +#[cfg(unix)] pub mod hub; pub mod protocol; pub mod session_name; diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 3c16342..44e422d 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -5,6 +5,8 @@ docs: url: /getting-started/ - title: Prerequisites url: /getting-started/prerequisites/ + - title: Platform Support + url: /getting-started/platform-support/ - title: Installation url: /getting-started/installation/ - title: Supported Session Management diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md new file mode 100644 index 0000000..0132224 --- /dev/null +++ b/docs/getting-started/platform-support.md @@ -0,0 +1,82 @@ +--- +title: "Platform Support & Limitations" +description: "What works on each operating system and which features are unavailable on specific platforms." +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 + +| Feature | macOS | Linux | Windows | +|---------|-------|-------|---------| +| Session manager: VS Code Extension | ✅ | ✅ | ✅ Required | +| Session manager: tmux | ✅ | ✅ | ❌ | +| Session manager: cmux | ✅ | ❌ | ❌ | +| Session manager: Zellij | ✅ | ✅ | ❌ | +| Relay hub (multi-agent) | ✅ | ✅ | ❌ | +| `opr8r relay` subcommand | ✅ | ✅ | ❌ | +| Backstage Server | ✅ | ✅ | ❌ | +| Native OS notifications | ✅ | ✅ | ⚠️ | +| Kanban: Jira Cloud | ✅ | ✅ | ✅ | +| Kanban: Linear | ✅ | ✅ | ✅ | +| Kanban: GitHub Issues | ⚠️ | ⚠️ | ⚠️ | +| Git: GitHub (`gh`) | ✅ | ✅ | ✅ | +| Git: GitLab (`glab`) | ⚠️ | ⚠️ | ⚠️ | +| Agent: Claude Code | ✅ | ✅ | ✅ | +| Agent: Codex | ✅ | ✅ | ✅ | +| Agent: Gemini CLI | ⚠️ | ⚠️ | ⚠️ | + +**Legend:** ✅ Fully supported  ·  ❌ Not available  ·  ⚠️ Partial / planned + +--- + +## Windows + +Windows is a supported download target. The step-wrapper (`opr8r`), REST API, kanban sync, and VS Code extension all work. The following features are currently unavailable on Windows. + +| Feature | Status | Reason | Workaround | +|---------|--------|--------|------------| +| Relay hub / `opr8r relay` | ❌ Blocked | Requires Unix domain sockets (`tokio::net::unix`), which are not available on Windows | None yet. Planned: named-pipe or TCP-loopback transport in a future release | +| Backstage Server | ❌ Blocked | Not yet ported to Windows | None. No timeline committed | +| tmux session manager | ❌ N/A | tmux does not run on Windows | Use the VS Code Extension | +| cmux session manager | ❌ N/A | cmux is macOS-specific | Use the VS Code Extension | +| Zellij session manager | ❌ N/A | Zellij does not run on Windows | Use the VS Code Extension | +| Native OS notifications | ⚠️ Planned | Platform notification crates (`mac-notification-sys`, `notify-rust`) are Unix-only; a Windows crate has not been integrated | Notifications fall back to log output only | + +--- + +## Linux + +Linux is a first-class platform. One known gap: + +| Feature | Status | Reason | Workaround | +|---------|--------|--------|------------| +| cmux session manager | ❌ N/A | cmux is a macOS-specific terminal multiplexer | Use tmux or the VS Code Extension | + +--- + +## macOS + +macOS is the primary development platform. No known feature gaps. + +--- + +## Integration-Level Gaps (all platforms) + +These gaps apply on every operating system because the integration itself is not fully implemented. + +| Feature | Status | Notes | +|---------|--------|-------| +| Kanban: GitHub Issues | ⚠️ Detection only | GitHub Issues is detected as a provider but full two-way sync (create, update, close) is not implemented. Only Jira Cloud and Linear have full sync. | +| Git: GitLab (`glab`) | ⚠️ Detection only | GitLab is detected via the `glab` CLI for branch and PR metadata, but PR creation and status webhooks are not implemented. | +| Git: Bitbucket, Azure DevOps | ⚠️ Detection only | Detected via their respective CLIs; no PR workflow integration. | +| Agent: Gemini CLI | ⚠️ Experimental | Session detection and artifact parsing are less battle-tested than Claude Code. Some multi-step issue-type flows may behave unexpectedly. | + +--- + +## Reporting a Gap + +If you hit a limitation not listed here, please [open an issue on GitHub](https://github.com/untra/operator/issues){:target="_blank"}. diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index 4478148..6e6adcf 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -83,6 +83,8 @@ Windows support has some limitations: - **Backstage Server**: Not supported on Windows - **Notifications**: Native notifications are planned; currently logs only +See [Platform Support & Limitations](/getting-started/platform-support/) for a complete list of OS-specific gaps, their reasons, and planned resolutions. + ## Optional Dependencies ### Coding Agent diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index 9548bf2..dd99204 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -101,9 +101,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" @@ -1094,9 +1094,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/opr8r/src/main.rs b/opr8r/src/main.rs index 8f0bd7e..b9aa8de 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -1,5 +1,6 @@ mod api; mod cli; +#[cfg(unix)] mod operator_relay; mod output_parser; mod runner; @@ -56,7 +57,15 @@ async fn main() -> ExitCode { // Dispatch relay subcommand before any step-wrapper logic if args.subcommand == Some(Cmd::Relay) { + #[cfg(unix)] return operator_relay::run().await; + #[cfg(not(unix))] + { + eprintln!( + "[opr8r relay] relay is not supported on this platform (requires Unix sockets)" + ); + return ExitCode::from(1); + } } // Step-wrapper mode: validate required fields diff --git a/opr8r/src/operator_relay.rs b/opr8r/src/operator_relay.rs index cc0f727..4f18f58 100644 --- a/opr8r/src/operator_relay.rs +++ b/opr8r/src/operator_relay.rs @@ -112,14 +112,9 @@ pub async fn run() -> ExitCode { let (stdin_tx, mut stdin_rx) = mpsc::channel::(32); std::thread::spawn(move || { let stdin = std::io::stdin(); - for line in stdin.lock().lines() { - match line { - Ok(l) if !l.is_empty() => { - if stdin_tx.blocking_send(l).is_err() { - break; - } - } - _ => {} + for l in stdin.lock().lines().map_while(Result::ok) { + if !l.is_empty() && stdin_tx.blocking_send(l).is_err() { + break; } } }); diff --git a/src/api/kanban_sync.rs b/src/api/kanban_sync.rs index 9ab2a96..ae0e9e3 100644 --- a/src/api/kanban_sync.rs +++ b/src/api/kanban_sync.rs @@ -271,7 +271,7 @@ mod tests { fn make_sync_cfg(statuses: Vec<&str>) -> ProjectSyncConfig { ProjectSyncConfig { - sync_statuses: statuses.into_iter().map(|s| s.to_string()).collect(), + sync_statuses: statuses.into_iter().map(ToString::to_string).collect(), bidirectional: true, ..Default::default() } diff --git a/src/app/mod.rs b/src/app/mod.rs index 5eab75f..90c99c4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,7 +12,9 @@ use crate::backstage::BackstageServer; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; use crate::notifications::NotificationService; +#[cfg(unix)] use crate::relay::hub::RelayHub; +#[cfg(unix)] use crate::relay::socket_path::hub_socket_path; use crate::rest::RestApiServer; use crate::services::{KanbanSyncService, PrMonitorService, PrStatusEvent, TrackedPr}; @@ -111,6 +113,7 @@ pub struct App { /// True if REST API port was in use at startup (another instance may be running) pub(crate) api_port_conflict: bool, /// Relay hub handle (None if hub failed to start or another instance is running) + #[cfg(unix)] pub(crate) relay_hub: Option, } @@ -272,6 +275,7 @@ impl App { let help_dialog = HelpDialog::new(config.sessions.wrapper); // Start the relay hub embedded in this process + #[cfg(unix)] let relay_hub = match RelayHub::start(hub_socket_path()).await { Ok(hub) => { // Export socket path so child processes (agents) can find the hub @@ -321,6 +325,7 @@ impl App { update_notification_shown_at: None, version_rx, api_port_conflict: false, + #[cfg(unix)] relay_hub, tmux_client, }) @@ -480,6 +485,7 @@ impl App { // Terminal cleanup is handled by _terminal_guard drop // Shut down relay hub before exit + #[cfg(unix)] if let Some(hub) = self.relay_hub.take() { hub.shutdown().await; } diff --git a/src/app/tests.rs b/src/app/tests.rs index 99c7704..006d458 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -814,7 +814,7 @@ mod agent_switches { .iter() .filter_map(|agent| { let rs = agent.review_state.as_ref()?; - rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + rs.strip_prefix("switching_agent:").map(str::to_string) }) .collect(); @@ -845,7 +845,7 @@ mod agent_switches { .iter() .filter_map(|agent| { let rs = agent.review_state.as_ref()?; - rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + rs.strip_prefix("switching_agent:").map(str::to_string) }) .collect(); @@ -875,7 +875,7 @@ mod agent_switches { .iter() .filter_map(|agent| { let rs = agent.review_state.as_ref()?; - rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + rs.strip_prefix("switching_agent:").map(str::to_string) }) .collect(); @@ -928,7 +928,7 @@ mod agent_switches { .iter() .filter_map(|agent| { let rs = agent.review_state.as_ref()?; - rs.strip_prefix("switching_agent:").map(|s| s.to_string()) + rs.strip_prefix("switching_agent:").map(str::to_string) }) .collect(); diff --git a/src/relay/mod.rs b/src/relay/mod.rs index 388fc01..03b6d5b 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -6,5 +6,6 @@ //! See `docs/relay/` for architecture documentation. // Re-export the shared relay crate so existing `crate::relay::*` paths continue to work. +#[cfg(unix)] pub use operator_relay::hub; pub use operator_relay::socket_path; diff --git a/src/rest/state.rs b/src/rest/state.rs index f297e86..ed7ba21 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -180,11 +180,13 @@ mod tests { let mut jira_map = HashMap::new(); jira_map.insert("test.atlassian.net".to_string(), jira_config); - let mut config = Config::default(); - config.kanban = KanbanConfig { - jira: jira_map, - linear: HashMap::new(), - github: HashMap::new(), + let config = Config { + kanban: KanbanConfig { + jira: jira_map, + linear: HashMap::new(), + github: HashMap::new(), + }, + ..Default::default() }; let state = ApiState::new(config, PathBuf::from("/tmp/test")); diff --git a/tests/relay_integration.rs b/tests/relay_integration.rs index 4120852..7c97c4d 100644 --- a/tests/relay_integration.rs +++ b/tests/relay_integration.rs @@ -7,7 +7,7 @@ //! using only in-process async code (no external services needed). //! //! **Layer 2** — `opr8r relay` binary driven via JSON-RPC stdio. -//! Verifies the MCP protocol surface: initialize, tools/list, relay_peers. +//! Verifies the MCP protocol surface: initialize, tools/list, `relay_peers`. //! Binary tests skip gracefully if the binary hasn't been built yet. //! //! ## Running @@ -400,11 +400,9 @@ async fn rpc_recv( loop { line.clear(); let n = reader.read_line(&mut line).await.expect("read_line failed"); - if n == 0 { - panic!("relay process closed stdout waiting for id={id}"); - } + assert!(n != 0, "relay process closed stdout waiting for id={id}"); if let Ok(val) = serde_json::from_str::(line.trim()) { - if val.get("id").and_then(|v| v.as_u64()) == Some(id) { + if val.get("id").and_then(serde_json::Value::as_u64) == Some(id) { return val; } } @@ -849,9 +847,7 @@ async fn test_binary_incoming_ask_notification_shape() { loop { line.clear(); let n = reader.read_line(&mut line).await.expect("read_line failed"); - if n == 0 { - panic!("stdout closed"); - } + assert!(n != 0, "stdout closed"); if let Ok(val) = serde_json::from_str::(line.trim()) { if val.get("method").and_then(|m| m.as_str()) == Some("notifications/claude/channel") @@ -963,9 +959,7 @@ async fn test_binary_incoming_reply_notification_content_is_raw_text() { .read_line(&mut line) .await .expect("read_line failed"); - if n == 0 { - panic!("stdout closed"); - } + assert!(n != 0, "stdout closed"); if let Ok(val) = serde_json::from_str::(line.trim()) { if val.get("method").and_then(|m| m.as_str()) == Some("notifications/claude/channel") @@ -1044,9 +1038,7 @@ async fn test_binary_ask_error_notification_uses_lowercase_code() { loop { line.clear(); let n = reader.read_line(&mut line).await.expect("read_line failed"); - if n == 0 { - panic!("stdout closed"); - } + assert!(n != 0, "stdout closed"); if let Ok(val) = serde_json::from_str::(line.trim()) { if val.get("method").and_then(|m| m.as_str()) == Some("notifications/claude/channel") @@ -1194,9 +1186,7 @@ async fn test_binary_relay_ask_thread_id_propagated_to_notification() { loop { line.clear(); let n = reader.read_line(&mut line).await.expect("read_line failed"); - if n == 0 { - panic!("stdout closed"); - } + assert!(n != 0, "stdout closed"); if let Ok(val) = serde_json::from_str::(line.trim()) { if val.get("method").and_then(|m| m.as_str()) == Some("notifications/claude/channel") diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index 5ce072b..5e47d02 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -7,7 +7,7 @@ * Sections use progressive disclosure — they only appear when prerequisites are met: * Tier 0: Configuration (always visible) * Tier 1: Connections (requires configReady) - * Tier 2: Kanban, LLM Tools, Git (requires connectionsReady) + * Tier 2: Kanban, LLM Tools, Model Servers, Git (requires connectionsReady / llmReady) * Tier 3: Issue Types/issuetypes (kanbanConfigured), Delegators/delegators (llmConfigured), Managed Projects/projects (gitConfigured) */ diff --git a/vscode-extension/test/suite/status-provider.test.ts b/vscode-extension/test/suite/status-provider.test.ts index acd964b..fda8008 100644 --- a/vscode-extension/test/suite/status-provider.test.ts +++ b/vscode-extension/test/suite/status-provider.test.ts @@ -311,7 +311,7 @@ suite('Status Provider Test Suite', () => { assert.deepStrictEqual(labels, ['Configuration', 'Connections']); }); - test('tier 2: adds Kanban, LLM Tools, Git when connections ready', async () => { + test('tier 2: adds Kanban, LLM Tools, Model Servers, Git when connections ready', async () => { const mockContext = createMockContext(sandbox, '/fake/working-dir'); sandbox.stub(configPaths, 'configFileExists').resolves(true); sandbox.stub(configPaths, 'getResolvedConfigPath').returns(''); @@ -338,7 +338,7 @@ suite('Status Provider Test Suite', () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git', 'Delegators'] + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Model Servers', 'Git', 'Delegators'] ); }); @@ -442,7 +442,7 @@ suite('Status Provider Test Suite', () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Git', 'Issue Types', 'Delegators', 'Managed Projects'] + ['Configuration', 'Connections', 'Kanban', 'LLM Tools', 'Model Servers', 'Git', 'Issue Types', 'Delegators', 'Managed Projects'] ); }); diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index 1a235f5..e8a1117 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -381,7 +381,7 @@ function deepMerge>(target: T, source: T): T { const DEFAULT_JIRA: JiraConfig = { enabled: false, api_key_env: 'OPERATOR_JIRA_API_KEY', email: '', projects: {} }; const DEFAULT_LINEAR: LinearConfig = { enabled: false, api_key_env: 'OPERATOR_LINEAR_API_KEY', projects: {} }; -const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: null, type_mappings: {} }; +const DEFAULT_PROJECT_SYNC: ProjectSyncConfig = { sync_user_id: '', sync_statuses: [], collection_name: null, type_mappings: {}, bidirectional: false }; /** Apply an update to the config object by section/key path */ function applyUpdate( diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index e13c937..badae8c 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -143,6 +143,8 @@ const DEFAULT_CONFIG: Config = { timeout_secs: BigInt(10), }, delegators: [], + model_servers: [], + relay: { auto_inject_mcp: false }, }; export const DEFAULT_WEBVIEW_CONFIG: WebviewConfig = {