diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2cb819aa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Checkout with LF everywhere; Windows runners default to core.autocrlf=true, +# which makes oxfmt/cargo fmt see every file as unformatted. +* text=auto eol=lf + +# Batch files need CRLF for goto/label parsing. +*.bat text eol=crlf +*.cmd text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.ttf binary +*.woff binary +*.woff2 binary +*.vsix binary diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c47a522c..0e6eeb7e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -65,6 +65,28 @@ jobs: with: bun-version: 1.3.14 + # oxfmt/oxlint are installed once at the repo root and cover every hand-written JS/TS subproject. + # The type-aware lints resolve each subproject's own node_modules and + # copy-types output, so those have to exist before oxlint runs + - name: Install frontend dependencies + run: | + bun install --frozen-lockfile + (cd webcomponents && bun install --frozen-lockfile && bun run copy-types) + (cd ui && bun install --frozen-lockfile) + (cd vscode-extension && npm ci && npm run copy-types) + + - name: Format and lint frontend + run: | + bun run fmt:check + bun run lint:ui + bun run lint:webcomponents + bun run lint:vscode + bun run lint:agnt + bun run lint:coder-module + + - name: Lint shell scripts + run: shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/* + - name: Build shared web components run: | cd webcomponents @@ -87,7 +109,7 @@ jobs: fi - name: Check formatting - run: cargo fmt -- --check + run: cargo fmt --all -- --check - name: Clippy run: cargo clippy --locked --all-targets --all-features -- -D warnings @@ -102,6 +124,22 @@ jobs: arguments: --all-features command: check + # crates/relay has its own Cargo.lock and is not a workspace member, so + # the root fmt/clippy/deny above never reach it. + - name: crates/relay + run: | + cd crates/relay + cargo fmt -- --check + cargo clippy --locked --all-targets --all-features -- -D warnings + cargo test --locked --all-features + + - name: cargo-deny (crates/relay) + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: crates/relay/Cargo.toml + arguments: --all-features + command: check + - name: Set up Helm uses: azure/setup-helm@v5.0.1 @@ -549,6 +587,12 @@ jobs: run: | sed -i "s/const VERSION = '[^']*'/const VERSION = '${{ needs.version.outputs.version }}'/" vscode-extension/src/webhook-server.ts + # Pinned by tests/version_parity.rs; the range keeps the sed inside the + # install_version block so sibling variable defaults are untouched. + - name: Update coder-module install_version default + run: | + sed -i '/variable "install_version"/,/^}/ s/^\( default *= *\)"[^"]*"/\1"${{ needs.version.outputs.version }}"/' coder-module/main.tf + # openapi.json's version is code-derived (env!("CARGO_PKG_VERSION")); the # already-built linux binary embeds the new version, so regenerate the # committed spec from it instead of recompiling. @@ -570,6 +614,7 @@ jobs: opr8r/Cargo.toml opr8r/Cargo.lock \ zed-extension/Cargo.toml zed-extension/extension.toml zed-extension/Cargo.lock \ agnt-plugin/package.json agnt-plugin/manifest.json \ + coder-module/main.tf \ docs/schemas/openapi.json git commit -m "chore: bump version to v${{ needs.version.outputs.version }} [skip ci]" git push diff --git a/.github/workflows/coder-module.yaml b/.github/workflows/coder-module.yaml index b886af2d..2c9bc9c7 100644 --- a/.github/workflows/coder-module.yaml +++ b/.github/workflows/coder-module.yaml @@ -48,5 +48,14 @@ jobs: - name: Shell-check the rendered startup script run: ../scripts/ci/check-coder-module.sh + # The .ts harness is linted/formatted from the repo root, where oxlint + # and oxfmt (and their configs) live. + - name: Lint and format TypeScript harness + working-directory: . + run: | + bun install --frozen-lockfile + bun run lint:coder-module + bun run fmt:check + - name: Test run: bun test diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index d66c6b6d..3d9061bb 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -53,6 +53,9 @@ jobs: - name: Lint run: npm run lint + - name: Check formatting + run: npm run fmt:check + - name: Compile run: npm run compile diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..a2bd6c53 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,8 @@ +{ + "ignorePatterns": [ + "**/generated/**", + "bindings/**", + "shared/types.ts", + "vscode-extension/shared/**" + ] +} diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 0c6e2b63..c681251a 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -41,10 +41,8 @@ "target/**", "docs/_site/**", "docs/assets/js/**", - "agnt-plugin/**", - "coder-module/**", "bindings/**", - "shared/**", + "shared/types.ts", "scripts/**", "zed-extension/**", "opr8r/**" @@ -223,6 +221,22 @@ "typescript/no-misused-promises": "off" } }, + { + "files": ["agnt-plugin/**/*.js"], + "env": { "node": true, "es2022": true } + }, + { + "files": ["coder-module/*.ts"], + "env": { "node": true, "es2022": true }, + "globals": { "Bun": "readonly" }, + "rules": { + // Terraform state attributes are arbitrary JSON; the helper mirrors the + // upstream Coder signature and tests index into it (app.healthcheck[0]). + "typescript/no-explicit-any": "off", + // Per-case async closures passed to expect().toThrow() read better local. + "unicorn/consistent-function-scoping": "off" + } + }, { "files": [ "webcomponents/scripts/**", diff --git a/CLAUDE.md b/CLAUDE.md index 8ea5bb66..8c9372d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ - **Config**: config crate (TOML); **File Watching**: notify crate ## Code Style -Aim for functional software development with a focus on stateless, single responsibility focus. +Aim for functional software development with a focus on stateless, single responsibility testable functions. Minimize use of comments entirely; they should be terse and used judiciously, ideally one line tops. Data types come from rust; typescript and docs binds are generated from low-level rust types annotated with comments that embed as descriptions into configuration and reference files. Favor falsey defaults ; lets aim not to enforce `default=true` or some other javascript-truthy default value. @@ -45,8 +45,21 @@ make check cargo fmt --all -- --check # Format check cargo clippy --locked --all-targets --all-features -- -D warnings # Lint (warnings are errors) cargo test --locked # Run all tests +make relay # crates/relay (not a workspace member) +make fmt-ts # oxfmt --check, every JS/TS subproject +make lint-ts # oxlint, every JS/TS subproject +make lint-shell # shellcheck -S warning ``` +Formatting and linting are enforced for every subproject, not just the main +crate. Rust uses `cargo fmt`/`clippy` (root, `crates/relay`, `opr8r`, +`zed-extension`), JS/TS uses root-installed `oxfmt` + `oxlint` (`ui`, +`webcomponents`, `vscode-extension`, `agnt-plugin`, the `coder-module` test +harness), Terraform uses `terraform fmt`, charts use `helm lint`, and shell +scripts use `shellcheck`. `bun run fmt` rewrites; `bun run fmt:check` reports. +Generated output (`bindings/`, `shared/types.ts`, each `*/generated/`) is +excluded by `.oxfmtrc.json` / `.oxlintrc.jsonc` and must never be reformatted. + > The `--locked --all-targets --all-features` flags matter: plain > `cargo clippy` misses test-target and feature-gated lints (e.g. a dependency > deprecation that only surfaces under `--all-targets`), which is how a clippy @@ -78,7 +91,7 @@ cargo run **vscode-extension** (TypeScript/npm): ```bash -cd vscode-extension && npm run lint && npm run compile +cd vscode-extension && npm run lint && npm run fmt:check && npm run compile ``` ### Test-Driven Development (TDD) @@ -114,7 +127,9 @@ make check ## Quick Reference ```bash -make check # Full CI-parity gate (fmt + clippy + test) +make check # Full CI-parity gate (Rust + relay + JS/TS + shell) +bun run fmt # Format every JS/TS subproject in place +bun run lint # oxlint across every JS/TS subproject make install-hooks # Install the lint-only pre-push hook (once per clone) cargo fmt # Format code cargo clippy --locked --all-targets --all-features -- -D warnings # Lint (CI parity) diff --git a/Dockerfile.local b/Dockerfile.local index 540c902a..88d907e3 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,3 @@ -# --- Stage 1: embedded web UI --------------------------------------------- # Cargo.toml sets default = ["embed-ui"], and build.rs silently substitutes a # placeholder index.html when ui/dist is missing, which would produce an image # with no dashboard. Building the SPA here is required, not optional. @@ -30,10 +29,8 @@ COPY docs/assets/css/ ./docs/assets/css/ COPY ui/ ./ui/ RUN cd ui && bun install --frozen-lockfile && bun run build -# --- Stage 2: Rust binaries ----------------------------------------------- -# The full image rather than -slim: rusqlite is features = ["bundled"], so a C -# toolchain is required. -FROM rust:1.95 AS build +# rusqlite is features = ["bundled"], so a C toolchain is required. +FROM rust:1.98 AS build WORKDIR /src COPY . . @@ -45,7 +42,6 @@ RUN cargo build --release --locked --bin operator # member, so it needs its own invocation. RUN cd opr8r && cargo build --release --locked -# --- Stage 3: runtime ------------------------------------------------------ # Mirrors Dockerfile; only the operator/opr8r binary source differs. # glibc 2.41 >= the ubuntu-24.04 build runners' 2.39, so the GNU binary runs. FROM debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 diff --git a/Makefile b/Makefile index 4dab95e9..dfaa0870 100644 --- a/Makefile +++ b/Makefile @@ -4,11 +4,12 @@ # a clean CI run. `make install-hooks` wires the committed pre-push hook, which # runs the fast lint gate (fmt + clippy, no tests) before every push. -.PHONY: check fmt clippy test build run install-hooks bindings webcomponents ui docs +.PHONY: check fmt clippy test build run install-hooks bindings webcomponents ui docs \ + fmt-ts lint-ts lint-shell relay # Full CI-parity gate. Keep these commands byte-identical to # .github/workflows/build.yaml so local and CI never disagree. -check: fmt clippy test +check: fmt clippy test relay fmt-ts lint-ts lint-shell fmt: cargo fmt --all -- --check @@ -19,6 +20,29 @@ clippy: test: cargo test --locked +# crates/relay has its own Cargo.lock and is not a workspace member, so the +# targets above never reach it. +relay: + cd crates/relay && cargo fmt -- --check + cd crates/relay && cargo clippy --locked --all-targets --all-features -- -D warnings + cd crates/relay && cargo test --locked --all-features + +# oxfmt/oxlint are installed once at the repo root and cover every hand-written +# JS/TS subproject. `bun run fmt` (no :check) rewrites instead of reporting. +fmt-ts: + bun install --frozen-lockfile + bun run fmt:check + +lint-ts: + bun run lint:ui + bun run lint:webcomponents + bun run lint:vscode + bun run lint:agnt + bun run lint:coder-module + +lint-shell: + shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/* + # Optimized release binary at target/release/operator. build: cargo build --release diff --git a/agnt-plugin/alert.js b/agnt-plugin/alert.js index e20da9eb..3e75faea 100644 --- a/agnt-plugin/alert.js +++ b/agnt-plugin/alert.js @@ -6,7 +6,7 @@ class AlertTool { this.name = "operator-alert"; } async execute(params, _inputData, _workflowEngine) { - if (!params || !params.message) { + if (!params?.message) { return { success: false, result: null, error: "missing required param: message" }; } return callOperator({ diff --git a/agnt-plugin/create-ticket.js b/agnt-plugin/create-ticket.js index 4c577cfb..a4a1ba47 100644 --- a/agnt-plugin/create-ticket.js +++ b/agnt-plugin/create-ticket.js @@ -6,7 +6,7 @@ class CreateTicketTool { this.name = "operator-create-ticket"; } async execute(params, _inputData, _workflowEngine) { - if (!params || !params.template) { + if (!params?.template) { return { success: false, result: null, error: "missing required param: template" }; } return callOperator({ diff --git a/agnt-plugin/export.js b/agnt-plugin/export.js index 71ace0a1..f3730807 100644 --- a/agnt-plugin/export.js +++ b/agnt-plugin/export.js @@ -6,7 +6,7 @@ class ExportWorkflowTool { this.name = "operator-export-workflow"; } async execute(params, _inputData, _workflowEngine) { - if (!params || !params.id) { + if (!params?.id) { return { success: false, result: null, error: "missing required param: id" }; } const format = params.format || "agnt"; diff --git a/agnt-plugin/launch.js b/agnt-plugin/launch.js index c9b1d361..45b5366d 100644 --- a/agnt-plugin/launch.js +++ b/agnt-plugin/launch.js @@ -6,7 +6,7 @@ class LaunchAgentTool { this.name = "operator-launch-agent"; } async execute(params, _inputData, _workflowEngine) { - if (!params || !params.id) { + if (!params?.id) { return { success: false, result: null, error: "missing required param: id" }; } return callOperator({ diff --git a/agnt-plugin/lib/operator-client.js b/agnt-plugin/lib/operator-client.js index 60d4509e..daa8744b 100644 --- a/agnt-plugin/lib/operator-client.js +++ b/agnt-plugin/lib/operator-client.js @@ -9,11 +9,8 @@ const DEFAULT_BASE_URL = "http://localhost:7008"; * Resolve the Operator REST base URL from params, env, or the default. */ export function resolveBaseUrl(params) { - const fromParam = params && params.operatorBaseUrl; - const fromEnv = - typeof process !== "undefined" && process.env - ? process.env.OPERATOR_BASE_URL - : undefined; + const fromParam = params?.operatorBaseUrl; + const fromEnv = typeof process === "undefined" ? undefined : process.env?.OPERATOR_BASE_URL; return (fromParam || fromEnv || DEFAULT_BASE_URL).replace(/\/+$/, ""); } @@ -45,8 +42,7 @@ export async function callOperator({ params, path, method = "GET", body }) { parsed = text; } if (!res.ok) { - const detail = - parsed && parsed.error ? parsed.error : `HTTP ${res.status}`; + const detail = parsed?.error ? parsed.error : `HTTP ${res.status}`; return { success: false, result: parsed, error: `${method} ${url} failed: ${detail}` }; } return { success: true, result: parsed, error: null }; diff --git a/agnt-plugin/run-step.js b/agnt-plugin/run-step.js index 2fabedfa..91d38b87 100644 --- a/agnt-plugin/run-step.js +++ b/agnt-plugin/run-step.js @@ -2,7 +2,7 @@ // // Each exported node represents one issuetype step and carries // { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks Operator to run it via the launch endpoint. -// Operator sequences its own steps internally, so the per-step nodes are a faithful visualization of the ticket's shape; +// Operator sequences its own steps internally, so the per-step nodes are a faithful visualization of the ticket's shape; // executing them drives the one underlying Operator ticket (the launch endpoint's relaunch path tolerates a ticket that is already in progress). import { callOperator } from "./lib/operator-client.js"; diff --git a/bindings/GitOnboardingState.ts b/bindings/GitOnboardingState.ts new file mode 100644 index 00000000..0c221428 --- /dev/null +++ b/bindings/GitOnboardingState.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GitOnboardingState = "cli-missing" | "token-required" | "authenticated"; diff --git a/bindings/GitProviderOnboardingResponse.ts b/bindings/GitProviderOnboardingResponse.ts new file mode 100644 index 00000000..064914cf --- /dev/null +++ b/bindings/GitProviderOnboardingResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GitOnboardingState } from "./GitOnboardingState"; + +export type GitProviderOnboardingResponse = { slug: string, label: string, docs_url: string, configured: boolean, command: string, token_env: string, state: GitOnboardingState, action_url: string, username?: string | null, }; diff --git a/bindings/HostedCollectionSelection.ts b/bindings/HostedCollectionSelection.ts new file mode 100644 index 00000000..06a863b8 --- /dev/null +++ b/bindings/HostedCollectionSelection.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HostedCollectionSelection = { id: string, checksum: string, }; diff --git a/bindings/LaunchConfig.ts b/bindings/LaunchConfig.ts index db3a67c2..cc2e565b 100644 --- a/bindings/LaunchConfig.ts +++ b/bindings/LaunchConfig.ts @@ -3,6 +3,10 @@ import type { DockerConfig } from "./DockerConfig"; import type { YoloConfig } from "./YoloConfig"; export type LaunchConfig = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, +/** + * Default named execution target. Per-launch and per-delegator choices take precedence. + */ +target: string | null, /** * Docker execution configuration */ diff --git a/bindings/LaunchConfiguration.ts b/bindings/LaunchConfiguration.ts index 61119331..59d68fae 100644 --- a/bindings/LaunchConfiguration.ts +++ b/bindings/LaunchConfiguration.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SessionWrapper } from "./SessionWrapper"; -export type LaunchConfiguration = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, docker_enabled: boolean, docker_image: string, yolo_enabled: boolean, session_wrapper: SessionWrapper, }; +export type LaunchConfiguration = { confirm_autonomous: boolean, confirm_paired: boolean, launch_delay_ms: bigint, target: string | null, docker_enabled: boolean, docker_image: string, yolo_enabled: boolean, session_wrapper: SessionWrapper, }; diff --git a/bindings/LaunchConfigurationPatch.ts b/bindings/LaunchConfigurationPatch.ts index a5beaf91..693efd05 100644 --- a/bindings/LaunchConfigurationPatch.ts +++ b/bindings/LaunchConfigurationPatch.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SessionWrapper } from "./SessionWrapper"; -export type LaunchConfigurationPatch = { confirm_autonomous: boolean | null, confirm_paired: boolean | null, launch_delay_ms: bigint | null, docker_enabled: boolean | null, docker_image: string | null, yolo_enabled: boolean | null, session_wrapper: SessionWrapper | null, }; +export type LaunchConfigurationPatch = { confirm_autonomous: boolean | null, confirm_paired: boolean | null, launch_delay_ms: bigint | null, target: string | null, docker_enabled: boolean | null, docker_image: string | null, yolo_enabled: boolean | null, session_wrapper: SessionWrapper | null, }; diff --git a/bindings/SetGitSessionEnvRequest.ts b/bindings/SetGitSessionEnvRequest.ts new file mode 100644 index 00000000..47c578fd --- /dev/null +++ b/bindings/SetGitSessionEnvRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetGitSessionEnvRequest = { provider: string, token: string, }; diff --git a/bindings/SetGitSessionEnvResponse.ts b/bindings/SetGitSessionEnvResponse.ts new file mode 100644 index 00000000..f15ff7c6 --- /dev/null +++ b/bindings/SetGitSessionEnvResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetGitSessionEnvResponse = { shell_export_block: string, }; diff --git a/bindings/SetupCollectionResponse.ts b/bindings/SetupCollectionResponse.ts new file mode 100644 index 00000000..f489151a --- /dev/null +++ b/bindings/SetupCollectionResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupCollectionResponse = { id: string, name: string, description: string, types: Array, default_selected: Array, origin: string, note?: string | null, checksum: string, }; diff --git a/bindings/SetupExecutionTarget.ts b/bindings/SetupExecutionTarget.ts new file mode 100644 index 00000000..24178d67 --- /dev/null +++ b/bindings/SetupExecutionTarget.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupExecutionTarget = { "kind": "local" } | { "kind": "coder", name: string, template: string, }; diff --git a/bindings/SetupInitializeRequest.ts b/bindings/SetupInitializeRequest.ts new file mode 100644 index 00000000..9d2fe364 --- /dev/null +++ b/bindings/SetupInitializeRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CollectionPreset } from "./CollectionPreset"; +import type { HostedCollectionSelection } from "./HostedCollectionSelection"; +import type { SessionWrapperType } from "./SessionWrapperType"; +import type { SetupExecutionTarget } from "./SetupExecutionTarget"; + +export type SetupInitializeRequest = { preset: CollectionPreset, task_fields: Array, wrapper: SessionWrapperType, execution_target: SetupExecutionTarget, use_worktrees: boolean, acceptance_criteria: string, model_servers: Array, hosted_collections: Array, }; diff --git a/bindings/SetupInitializeResponse.ts b/bindings/SetupInitializeResponse.ts new file mode 100644 index 00000000..83ee3933 --- /dev/null +++ b/bindings/SetupInitializeResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupInitializeResponse = { initialized: boolean, config_path: string, tickets_path: string, files_created: Array, files_skipped: Array, projects: Array, }; diff --git a/bindings/SetupStatusResponse.ts b/bindings/SetupStatusResponse.ts new file mode 100644 index 00000000..266c6a24 --- /dev/null +++ b/bindings/SetupStatusResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetupStatusResponse = { initialized: boolean, admin_configured: boolean, config_path: string, tickets_path: string, projects_by_tool: { [key in string]: Array }, default_acceptance_criteria: string, }; diff --git a/bindings/SetupStep.ts b/bindings/SetupStep.ts new file mode 100644 index 00000000..e25b413b --- /dev/null +++ b/bindings/SetupStep.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. + +/** + * A step in the setup wizard. + */ +export type SetupStep = "welcome" | "kanban-info" | "model-server" | "git-provider" | "collection-source" | "hosted-collections" | "task-field-config" | "session-wrapper-choice" | "execution-target" | "worktree-preference" | "admin-password" | "tmux-onboarding" | "vscode-setup" | "cmux-setup" | "zellij-setup" | "acceptance-criteria" | "startup-tickets" | "confirm"; diff --git a/bindings/SetupStepResponse.ts b/bindings/SetupStepResponse.ts new file mode 100644 index 00000000..2d4fb63b --- /dev/null +++ b/bindings/SetupStepResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SetupStep } from "./SetupStep"; + +export type SetupStepResponse = { slug: SetupStep, name: string, description: string, help_text: string, order: number, }; diff --git a/bindings/ValidateGitTokenRequest.ts b/bindings/ValidateGitTokenRequest.ts new file mode 100644 index 00000000..cee87cab --- /dev/null +++ b/bindings/ValidateGitTokenRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ValidateGitTokenRequest = { provider: string, token: string, }; diff --git a/bindings/ValidateGitTokenResponse.ts b/bindings/ValidateGitTokenResponse.ts new file mode 100644 index 00000000..80070803 --- /dev/null +++ b/bindings/ValidateGitTokenResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ValidateGitTokenResponse = { valid: boolean, username?: string | null, error?: string | null, }; diff --git a/bindings/WriteGitConfigRequest.ts b/bindings/WriteGitConfigRequest.ts new file mode 100644 index 00000000..88b79a54 --- /dev/null +++ b/bindings/WriteGitConfigRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WriteGitConfigRequest = { provider: string, token_env: string, }; diff --git a/bindings/WriteGitConfigResponse.ts b/bindings/WriteGitConfigResponse.ts new file mode 100644 index 00000000..8002eea0 --- /dev/null +++ b/bindings/WriteGitConfigResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WriteGitConfigResponse = { provider: string, token_env: string, shell_export_block: string, username?: string | null, }; diff --git a/bun.lock b/bun.lock index e93384e5..05275f04 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "operator-docs", "devDependencies": { + "oxfmt": "0.66.0", "oxlint": "1.81.0", "oxlint-tsgolint": "7.0.2001", "typedoc": "^0.27.0", @@ -15,6 +16,44 @@ "packages": { "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@1.27.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^1.27.2", "@shikijs/types": "^1.27.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.66.0", "", { "os": "android", "cpu": "arm" }, "sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.66.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.66.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.66.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.66.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.66.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.66.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.66.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.66.0", "", { "os": "linux", "cpu": "none" }, "sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.66.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.66.0", "", { "os": "linux", "cpu": "x64" }, "sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.66.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.66.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.66.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.66.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], @@ -93,12 +132,16 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "oxfmt": ["oxfmt@0.66.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.66.0", "@oxfmt/binding-android-arm64": "0.66.0", "@oxfmt/binding-darwin-arm64": "0.66.0", "@oxfmt/binding-darwin-x64": "0.66.0", "@oxfmt/binding-freebsd-x64": "0.66.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.66.0", "@oxfmt/binding-linux-arm-musleabihf": "0.66.0", "@oxfmt/binding-linux-arm64-gnu": "0.66.0", "@oxfmt/binding-linux-arm64-musl": "0.66.0", "@oxfmt/binding-linux-ppc64-gnu": "0.66.0", "@oxfmt/binding-linux-riscv64-gnu": "0.66.0", "@oxfmt/binding-linux-riscv64-musl": "0.66.0", "@oxfmt/binding-linux-s390x-gnu": "0.66.0", "@oxfmt/binding-linux-x64-gnu": "0.66.0", "@oxfmt/binding-linux-x64-musl": "0.66.0", "@oxfmt/binding-openharmony-arm64": "0.66.0", "@oxfmt/binding-win32-arm64-msvc": "0.66.0", "@oxfmt/binding-win32-ia32-msvc": "0.66.0", "@oxfmt/binding-win32-x64-msvc": "0.66.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w=="], + "oxlint": ["oxlint@1.81.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.81.0", "@oxlint/binding-android-arm64": "1.81.0", "@oxlint/binding-darwin-arm64": "1.81.0", "@oxlint/binding-darwin-x64": "1.81.0", "@oxlint/binding-freebsd-x64": "1.81.0", "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", "@oxlint/binding-linux-arm-musleabihf": "1.81.0", "@oxlint/binding-linux-arm64-gnu": "1.81.0", "@oxlint/binding-linux-arm64-musl": "1.81.0", "@oxlint/binding-linux-ppc64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-musl": "1.81.0", "@oxlint/binding-linux-s390x-gnu": "1.81.0", "@oxlint/binding-linux-x64-gnu": "1.81.0", "@oxlint/binding-linux-x64-musl": "1.81.0", "@oxlint/binding-openharmony-arm64": "1.81.0", "@oxlint/binding-win32-arm64-msvc": "1.81.0", "@oxlint/binding-win32-ia32-msvc": "1.81.0", "@oxlint/binding-win32-x64-msvc": "1.81.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg=="], "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "typedoc": ["typedoc@0.27.9", "", { "dependencies": { "@gerrit0/mini-shiki": "^1.24.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.6.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-/z585740YHURLl9DN2jCWe6OW7zKYm6VoQ93H0sxZ1cwHQEQrUn5BJrEnkWhfzUdyO+BLGjnKUZ9iz9hKloFDw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/coder-module/main.test.ts b/coder-module/main.test.ts index ae3594e2..a6d7215a 100644 --- a/coder-module/main.test.ts +++ b/coder-module/main.test.ts @@ -58,9 +58,7 @@ describe("operator", async () => { expect(app.slug).toBe("operator"); expect(app.display_name).toBe("Operator"); expect(app.share).toBe("owner"); - expect(app.healthcheck[0].url).toBe( - "http://localhost:7008/api/v1/health", - ); + expect(app.healthcheck[0].url).toBe("http://localhost:7008/api/v1/health"); }); it("applies with custom port", async () => { @@ -71,9 +69,7 @@ describe("operator", async () => { const app = findResourceInstance(state, "coder_app"); expect(app.url).toBe("http://localhost:9000"); - expect(app.healthcheck[0].url).toBe( - "http://localhost:9000/api/v1/health", - ); + expect(app.healthcheck[0].url).toBe("http://localhost:9000/api/v1/health"); }); it("generates config with custom values", async () => { @@ -91,7 +87,7 @@ describe("operator", async () => { }); it("uses config_toml verbatim when provided", async () => { - const customConfig = '[rest_api]\nenabled = true\nport = 8080'; + const customConfig = "[rest_api]\nenabled = true\nport = 8080"; const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", config_toml: customConfig, diff --git a/coder-module/main.tf b/coder-module/main.tf index 5e56daf7..4e0362fa 100644 --- a/coder-module/main.tf +++ b/coder-module/main.tf @@ -38,7 +38,7 @@ variable "slug" { variable "install_version" { type = string description = "The version of operator to install (must match a GitHub release tag)." - default = "0.2.8" + default = "0.2.9" } variable "install_prefix" { diff --git a/coder-module/test.ts b/coder-module/test.ts index f36c1929..70d64143 100644 --- a/coder-module/test.ts +++ b/coder-module/test.ts @@ -14,8 +14,7 @@ import { expect, it } from "bun:test"; /// OpenTofu is a drop-in for the commands used here; prefer whichever exists /// so contributors aren't forced to install a specific CLI. -const tfBin = (): string => - process.env.TF_CLI ?? (Bun.which("terraform") ? "terraform" : "tofu"); +const tfBin = (): string => process.env.TF_CLI ?? (Bun.which("terraform") ? "terraform" : "tofu"); interface ExecResult { code: number; @@ -23,11 +22,7 @@ interface ExecResult { stderr: string; } -const exec = ( - args: string[], - cwd: string, - env: NodeJS.ProcessEnv = {}, -): Promise => +const exec = (args: string[], cwd: string, env: NodeJS.ProcessEnv = {}): Promise => new Promise((resolve, reject) => { const child = spawn(tfBin(), args, { cwd, @@ -43,7 +38,9 @@ const exec = ( export const runTerraformInit = async (dir: string): Promise => { const { code, stderr } = await exec(["init", "-input=false", "-no-color"], dir); - if (code !== 0) throw new Error(`terraform init failed:\n${stderr}`); + if (code !== 0) { + throw new Error(`terraform init failed:\n${stderr}`); + } }; export interface TerraformStateResource { @@ -64,7 +61,9 @@ export const runTerraformApply = async ( vars: Record, ): Promise => { const env: NodeJS.ProcessEnv = {}; - for (const [k, v] of Object.entries(vars)) env[`TF_VAR_${k}`] = v; + for (const [k, v] of Object.entries(vars)) { + env[`TF_VAR_${k}`] = v; + } // Each apply is independent; a stale state file would mask a failed apply. const statePath = path.join(dir, "terraform.tfstate"); @@ -75,7 +74,9 @@ export const runTerraformApply = async ( dir, env, ); - if (code !== 0) throw new Error(stderr); + if (code !== 0) { + throw new Error(stderr); + } return JSON.parse(await readFile(statePath, "utf8")) as TerraformState; }; @@ -91,24 +92,17 @@ export const findResourceInstance = ( (r) => r.type === type && (name === undefined || r.name === name), ); if (!resource) { - throw new Error( - `Resource ${type}${name ? `.${name}` : ""} not found in state`, - ); + throw new Error(`Resource ${type}${name ? `.${name}` : ""} not found in state`); } if (resource.instances.length !== 1) { - throw new Error( - `Expected 1 instance of ${type}, got ${resource.instances.length}`, - ); + throw new Error(`Expected 1 instance of ${type}, got ${resource.instances.length}`); } return resource.instances[0].attributes; }; /// Registers a test per required variable asserting the apply fails when it is /// omitted, plus one asserting the module applies with all of them supplied. -export const testRequiredVariables = ( - dir: string, - vars: Record, -): void => { +export const testRequiredVariables = (dir: string, vars: Record): void => { it("applies with all required variables", async () => { await runTerraformApply(dir, vars); }); diff --git a/config/default.toml b/config/default.toml index 22ba39e7..c24bf532 100644 --- a/config/default.toml +++ b/config/default.toml @@ -89,6 +89,9 @@ confirm_paired = true # Delay between launching multiple agents (milliseconds) launch_delay_ms = 2000 +# Default execution target. Named targets are declared with [[targets]]. +# target = "local" + # Session wrapper configuration # Controls how operator creates and manages terminal sessions for agents [sessions] @@ -140,8 +143,7 @@ connect_timeout_ms = 5000 # Agent delegator configurations # 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.). +# 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" diff --git a/crates/relay/Cargo.lock b/crates/relay/Cargo.lock index 91350b93..ca4c643a 100644 --- a/crates/relay/Cargo.lock +++ b/crates/relay/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" diff --git a/docs/_config.yml b/docs/_config.yml index 962fc1f5..83b5889a 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -5,9 +5,8 @@ url: "https://operator.untra.io" baseurl: "" # Social/SEO -# 1200x630 social card (raster PNG — SVG is not rendered by social unfurlers). -# Also set in `defaults` below because jekyll-seo-tag emits og:image from -# page.image, not site.image. +# 1200x630 social card (raster PNG / SVG is not rendered by social unfurlers). +# Also set in `defaults` below because jekyll-seo-tag emits og:image from page.image, not site.image. image: /assets/img/operator_og.png twitter: card: summary_large_image diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 1af23e51..f83beac7 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -302,7 +302,7 @@ summary.nav-item-row::-webkit-details-marker { color: inherit; } -/* Workflows nav button — the collection catalog. +/* Workflows nav button . The collection catalog. * A shade apart from both the sage (--color-green-l1) used by every ordinary * nav link and the salmon Downloads button below it, so the two read as a pair * of distinct calls to action rather than one repeated. */ diff --git a/docs/assets/css/tokens.css b/docs/assets/css/tokens.css index c0f6c69f..05e3a228 100644 --- a/docs/assets/css/tokens.css +++ b/docs/assets/css/tokens.css @@ -1,32 +1,29 @@ -/* Operator! brand tokens — SINGLE SOURCE OF TRUTH for web surfaces. +/* Operator! brand tokens : SINGLE SOURCE OF TRUTH for web surfaces. * * Consumed by both the docs site (docs/assets/css/main.css) and the embedded - * React SPA (ui/src/index.css). Do not re-declare these brand vars in either - * consumer — change them here and both surfaces follow. + * React SPA (ui/src/index.css). Do not re-declare these brand vars in either consumer. * * The TUI (src/ui/*.rs, ANSI) and the VS Code webview (defers to the editor - * theme) deliberately do NOT consume this file — see operator/CLAUDE.md - * "Design & UI Consistency" and docs/design-system/. + * theme) deliberately do NOT consume this file : see operator/CLAUDE.md * * Palette: warm terracotta + cornflower + cream, over a green scale - * (sage -> teal -> deep pine -> midnight). Light default, dark via - * [data-theme="dark"]. + * (sage -> teal -> deep pine -> midnight). Light default, dark via [data-theme="dark"]. */ :root { /* Brand palette */ - --color-salmon: #e05d44; /* Terracotta — primary brand */ - --color-cornflower: #6688aa; /* Muted blue — secondary text / separators */ + --color-salmon: #e05d44; /* Terracotta : primary brand */ + --color-cornflower: #6688aa; /* Muted blue : secondary text / separators */ --color-cream: #f2eac9; /* Warm accent / highlights */ --color-coral: #e05d44; /* Link / accent (alias of salmon in light) */ --color-bg: #faf8f5; /* Page background */ --color-white: #ffffff; /* Green scale */ - --color-green-l1: #66aa99; /* Sage — nav buttons */ - --color-green-l2: #448880; /* Teal — hover / success */ - --color-green-l3: #115566; /* Deep pine — selected / primary text */ - --color-green-l4: #082226; /* Midnight — darkest */ + --color-green-l1: #66aa99; /* Sage : nav buttons */ + --color-green-l2: #448880; /* Teal : hover / success */ + --color-green-l3: #115566; /* Deep pine : selected / primary text */ + --color-green-l4: #082226; /* Midnight : darkest */ --color-teal: #115566; /* Body text (== green-l3) */ /* Shared layout */ @@ -41,7 +38,7 @@ --color-bg: #0d1011; --color-white: #1a1d1e; - /* Green scale — adjusted for dark mode */ + /* Green scale : adjusted for dark mode */ --color-green-l1: #3d8a7a; --color-green-l2: #5aa896; --color-green-l3: #88ccbb; diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 3831c541..fa1a5e57 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -91,6 +91,7 @@ Agent launch behavior and confirmations | `confirm_autonomous` * | `boolean` | true | | | `confirm_paired` * | `boolean` | true | | | `launch_delay_ms` * | `integer` | 2000 | | +| `target` | `string` \| `null` | - | Default named execution target. Per-launch and per-delegator choices take precedence. | | `docker` | → `DockerConfig` | - | Docker execution configuration | | `yolo` | → `YoloConfig` | - | YOLO (auto-accept) mode configuration | diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 2f388d55..3ec83a2a 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -105,6 +105,9 @@ llm_tool = "claude" model = "opus" [delegators.launch_config] target = "cloud" + +[launch] +target = "cloud" # default when a launch/delegator does not override it ``` **Resolution precedence** (first match wins): @@ -115,10 +118,11 @@ target = "cloud" 2. `host` name (deprecated) - the `[[hosts]]` entry of that name 3. `docker = true` (deprecated) - the synthesized docker target 4. `docker = false` - local -5. `launch.docker.enabled = true` - the synthesized docker target. +5. `launch.target` - the named global default target. +6. `launch.docker.enabled = true` - the synthesized docker target. **Behavior change:** this was previously only a TUI dialog gate; it is now a real fallback, so REST/CLI/auto launches with it set run in docker. -6. otherwise - local +7. otherwise - local Legacy inputs are synthesized rather than special-cased: `[launch.docker]` becomes a target named `docker`, and every `[[hosts]]` entry becomes an ssh diff --git a/docs/getting-started/platforms/kubernetes.md b/docs/getting-started/platforms/kubernetes.md index f5341ef9..1376ed4c 100644 --- a/docs/getting-started/platforms/kubernetes.md +++ b/docs/getting-started/platforms/kubernetes.md @@ -222,7 +222,11 @@ Coder's own ingress NetworkPolicy has to admit Operator's namespace too. kubectl -n operator exec operator-0 -- curl -sSf https://coder.example.com/api/v2/buildinfo ``` -**3. Nothing else.** The image already ships `openssh-client`, and Operator downloads the `coder` CLI from the deployment on first use, caching it on the persistent volume at `.tickets/operator/bin/coder`. No custom image, no initContainer, and no relaxing of `readOnlyRootFilesystem` - the cache and the SSH fragments both live under `/op`. +**3. Configure the target in Operator.** On a new installation, finish the getting-started wizard in the web UI, choose **Coder** as the execution target, and enter the child-workspace template. The wizard writes the project configuration to `/op/.tickets/operator/config.toml`; the chart does not own or project an Operator configuration file. + +The image already ships `openssh-client`, and Operator downloads the `coder` CLI from the deployment on first use, caching it on the persistent volume at `.tickets/operator/bin/coder`. + +No custom image, initContainer, ConfigMap, or relaxing of `readOnlyRootFilesystem` is required - the configuration, cache, and SSH fragments all live under `/op`. ## Security context diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 3cd9e402..94228608 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -528,6 +528,14 @@ "format": "uint64", "minimum": 0 }, + "target": { + "description": "Default named execution target. Per-launch and per-delegator choices\ntake precedence.", + "type": [ + "string", + "null" + ], + "default": null + }, "docker": { "description": "Docker execution configuration", "$ref": "#/$defs/DockerConfig", @@ -1118,7 +1126,7 @@ "default": true }, "host": { - "description": "Address the REST API binds to. Defaults to `127.0.0.1` (local only) so\nthe server — which reports the project directory name — is not reachable\nfrom other hosts. Set to `0.0.0.0` to expose it on all interfaces.", + "description": "Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces.", "type": "string", "default": "127.0.0.1" }, @@ -1139,7 +1147,7 @@ "default": [] }, "public_url": { - "description": "Externally reachable base URL (e.g. `https://operator.example.com`).\n\nOAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header,\nwhich a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy.", + "description": "Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host.", "type": [ "string", "null" @@ -1384,7 +1392,7 @@ "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.", + "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" @@ -1524,7 +1532,7 @@ } }, "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.", + "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": { @@ -1533,7 +1541,7 @@ "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.", + "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" }, @@ -1548,7 +1556,7 @@ } }, "OpenspecConfig": { - "description": "`OpenSpec` provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials — the provider reads local markdown under `root_path`.", + "description": "`OpenSpec` provider configuration (experimental, pull-only)\n\nThe instance name is the `HashMap` key in `KanbanConfig.openspec`. There\nare no credentials - the provider reads local markdown under `root_path`.", "type": "object", "properties": { "enabled": { @@ -1660,7 +1668,7 @@ "default": null }, "remote_agent": { - "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally — resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and — for `platform == \"agnt\"` — surfaced in the\n`--format agnt` workflow export as a native AGNT `agnt-agent` node, whose\n`agentId` is this reference's `id` (AGNT identifies agents by UUID, so the\n`id` must be the agent's UUID, not its display name). `None` = ordinary,\nlocally launchable delegator.", + "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally - resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and - for `platform == \"agnt\"` - surfaced in the\n`--format agnt` workflow export as a native AGNT `agnt-agent` node, whose\n`agentId` is this reference's `id` (AGNT identifies agents by UUID, so the\n`id` must be the agent's UUID, not its display name). `None` = ordinary,\nlocally launchable delegator.", "anyOf": [ { "$ref": "#/$defs/RemoteAgentRef" @@ -1846,7 +1854,7 @@ } }, "RemoteAgentRef": { - "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) — deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", + "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) - deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", "type": "object", "properties": { "platform": { @@ -2029,11 +2037,11 @@ ] }, "CoderConfig": { - "description": "Coder workspace target: lifecycle + alias provisioning around the shared\nSSH remote-launch path. There is no `enabled` field — presence in\n`[[targets]]` is the enablement.", + "description": "Coder workspace target: lifecycle + alias provisioning around the shared\nSSH remote-launch path. There is no `enabled` field - presence in\n`[[targets]]` is the enablement.", "type": "object", "properties": { "template": { - "description": "Coder template child workspaces are created from (an allowlist —\nnever per-ticket input)", + "description": "Coder template child workspaces are created from (an allowlist -\nnever per-ticket input)", "type": "string" }, "url_env": { diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 32d94bd7..81043f55 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -145,6 +145,7 @@ Webhook notification configuration. | `confirm_autonomous` | `boolean` | Yes | | | `confirm_paired` | `boolean` | Yes | | | `launch_delay_ms` | `integer` | Yes | | +| `target` | `string` \| `null` | No | Default named execution target. Per-launch and per-delegator choices take precedence. | | `docker` | → `DockerConfig` | No | Docker execution configuration | | `yolo` | → `YoloConfig` | No | YOLO (auto-accept) mode configuration | @@ -363,10 +364,10 @@ REST API server configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether the REST API is enabled | -| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server - which reports the project directory name - is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | +| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | | `port` | `integer` | No | Port for the REST API server | | `cors_origins` | `array` | No | CORS allowed origins. Empty means **same-origin only** | -| `public_url` | `string` \| `null` | No | Externally reachable base URL (e.g. `https://operator.example.com`). OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. | +| `public_url` | `string` \| `null` | No | Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. | ### GitConfig diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index cfab8551..88c3c678 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -2041,6 +2041,212 @@ "x-operator-scope": "read" } }, + "/api/v1/git/config": { + "put": { + "tags": [ + "Git" + ], + "operationId": "git_write_config", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteGitConfigRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteGitConfigResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/git/providers": { + "get": { + "tags": [ + "Git" + ], + "operationId": "git_providers", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GitProviderOnboardingResponse" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/git/session-env": { + "post": { + "tags": [ + "Git" + ], + "operationId": "git_set_session_env", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetGitSessionEnvRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetGitSessionEnvResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/git/validate": { + "post": { + "tags": [ + "Git" + ], + "operationId": "git_validate", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateGitTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateGitTokenResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" + } + }, "/api/v1/health": { "get": { "tags": [ @@ -4599,56 +4805,22 @@ "x-operator-scope": "read" } }, - "/api/v1/skills": { - "get": { - "tags": [ - "Skills" - ], - "summary": "List all discovered skills across LLM tools", - "operationId": "skills_list", - "responses": { - "200": { - "description": "List of discovered skill files", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SkillsResponse" - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "sessionCookie": [] - } - ], - "x-operator-scope": "read" - } - }, - "/api/v1/status": { + "/api/v1/setup/collections": { "get": { "tags": [ - "Health" + "Setup" ], - "summary": "Get service status with registry info", - "operationId": "health_status", + "operationId": "setup_collections", "responses": { "200": { - "description": "Service status with registry info", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StatusResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SetupCollectionResponse" + } } } } @@ -4671,14 +4843,12 @@ "x-operator-scope": "read" } }, - "/api/v1/tickets": { + "/api/v1/setup/initialize": { "post": { "tags": [ - "Tickets" + "Setup" ], - "summary": "Create a new ticket from a template and write it to the queue.", - "description": "Reuses the same [`TicketCreator`] the CLI (`operator create`) and MCP\n(`operator_create_ticket`) use, so a ticket created over HTTP is identical to\none created on any other surface. Powers the AGNT `operator-create-ticket`\nnode.", - "operationId": "tickets_create", + "operationId": "setup_initialize", "parameters": [ { "name": "x-operator-csrf", @@ -4694,7 +4864,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTicketRequest" + "$ref": "#/components/schemas/SetupInitializeRequest" } } }, @@ -4702,17 +4872,23 @@ }, "responses": { "200": { - "description": "Ticket created", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTicketResponse" + "$ref": "#/components/schemas/SetupInitializeResponse" } } } }, - "400": { - "description": "Unknown template type", + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "Workspace already initialized", "content": { "application/json": { "schema": { @@ -4720,12 +4896,6 @@ } } } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -4736,15 +4906,228 @@ "sessionCookie": [] } ], - "x-operator-scope": "write" + "x-operator-scope": "admin" } }, - "/api/v1/tickets/{id}": { + "/api/v1/setup/status": { "get": { "tags": [ - "Tickets" + "Setup" ], - "summary": "Get full ticket details by ID", + "operationId": "setup_status", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupStatusResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/setup/steps": { + "get": { + "tags": [ + "Setup" + ], + "operationId": "setup_steps", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SetupStepResponse" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/skills": { + "get": { + "tags": [ + "Skills" + ], + "summary": "List all discovered skills across LLM tools", + "operationId": "skills_list", + "responses": { + "200": { + "description": "List of discovered skill files", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillsResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/status": { + "get": { + "tags": [ + "Health" + ], + "summary": "Get service status with registry info", + "operationId": "health_status", + "responses": { + "200": { + "description": "Service status with registry info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/tickets": { + "post": { + "tags": [ + "Tickets" + ], + "summary": "Create a new ticket from a template and write it to the queue.", + "description": "Reuses the same [`TicketCreator`] the CLI (`operator create`) and MCP\n(`operator_create_ticket`) use, so a ticket created over HTTP is identical to\none created on any other surface. Powers the AGNT `operator-create-ticket`\nnode.", + "operationId": "tickets_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTicketResponse" + } + } + } + }, + "400": { + "description": "Unknown template type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/tickets/{id}": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "Get full ticket details by ID", "description": "Returns complete ticket data including content, metadata, step history,\nand session information. Searches queue, in-progress, and completed directories.", "operationId": "tickets_get_one", "parameters": [ @@ -5831,6 +6214,16 @@ } } }, + "CollectionPreset": { + "type": "string", + "description": "Predefined issue type collections", + "enum": [ + "simple", + "dev_kanban", + "devops_kanban", + "custom" + ] + }, "CollectionResponse": { "type": "object", "description": "Response for a collection", @@ -7268,6 +7661,59 @@ } } }, + "GitOnboardingState": { + "type": "string", + "enum": [ + "cli-missing", + "token-required", + "authenticated" + ] + }, + "GitProviderOnboardingResponse": { + "type": "object", + "required": [ + "slug", + "label", + "docs_url", + "configured", + "command", + "token_env", + "state", + "action_url" + ], + "properties": { + "action_url": { + "type": "string" + }, + "command": { + "type": "string" + }, + "configured": { + "type": "boolean" + }, + "docs_url": { + "type": "string" + }, + "label": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/GitOnboardingState" + }, + "token_env": { + "type": "string" + }, + "username": { + "type": [ + "string", + "null" + ] + } + } + }, "GithubCredentials": { "type": "object", "description": "Ephemeral GitHub Projects credentials supplied by a client during onboarding.\n\nThe token must have `project` (or `read:project`) scope. A repo-only token\n(the kind used for `GITHUB_TOKEN` and operator's git provider) will be\nrejected at validation time with a friendly \"lacks `project` scope\" error.", @@ -7392,12 +7838,27 @@ } } }, - "IntegrationCatalogEntryDto": { + "HostedCollectionSelection": { "type": "object", - "description": "One advertised integration: its vertical, identity, docs link, and support\nstatus.", "required": [ - "vertical", - "vertical_label", + "id", + "checksum" + ], + "properties": { + "checksum": { + "type": "string" + }, + "id": { + "type": "string" + } + } + }, + "IntegrationCatalogEntryDto": { + "type": "object", + "description": "One advertised integration: its vertical, identity, docs link, and support\nstatus.", + "required": [ + "vertical", + "vertical_label", "slug", "label", "readme_badge", @@ -7946,6 +8407,12 @@ "session_wrapper": { "$ref": "#/components/schemas/SessionWrapper" }, + "target": { + "type": [ + "string", + "null" + ] + }, "yolo_enabled": { "type": "boolean" } @@ -8003,6 +8470,13 @@ ], "default": null }, + "target": { + "type": [ + "string", + "null" + ], + "default": null + }, "yolo_enabled": { "type": [ "boolean", @@ -9555,6 +10029,16 @@ "zellij" ] }, + "SessionWrapperType": { + "type": "string", + "description": "Session wrapper type for terminal session management", + "enum": [ + "tmux", + "vscode", + "cmux", + "zellij" + ] + }, "SetDefaultLlmRequest": { "type": "object", "description": "Request to set the global default LLM tool and model", @@ -9573,6 +10057,34 @@ } } }, + "SetGitSessionEnvRequest": { + "type": "object", + "required": [ + "provider", + "token" + ], + "properties": { + "provider": { + "type": "string" + }, + "token": { + "type": "string", + "format": "password", + "writeOnly": true + } + } + }, + "SetGitSessionEnvResponse": { + "type": "object", + "required": [ + "shell_export_block" + ], + "properties": { + "shell_export_block": { + "type": "string" + } + } + }, "SetKanbanSessionEnvRequest": { "type": "object", "description": "Request to set kanban-related env vars on the server for the current\nsession so subsequent `from_config` calls find the API key.", @@ -9636,6 +10148,269 @@ } } }, + "SetupCollectionResponse": { + "type": "object", + "required": [ + "id", + "name", + "description", + "types", + "default_selected", + "origin", + "checksum" + ], + "properties": { + "checksum": { + "type": "string" + }, + "default_selected": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "origin": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "SetupExecutionTarget": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "local" + ] + } + } + }, + { + "type": "object", + "required": [ + "name", + "template", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "coder" + ] + }, + "name": { + "type": "string" + }, + "template": { + "type": "string" + } + } + } + ] + }, + "SetupInitializeRequest": { + "type": "object", + "required": [ + "preset", + "wrapper", + "execution_target", + "acceptance_criteria" + ], + "properties": { + "acceptance_criteria": { + "type": "string" + }, + "execution_target": { + "$ref": "#/components/schemas/SetupExecutionTarget" + }, + "hosted_collections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HostedCollectionSelection" + } + }, + "model_servers": { + "type": "array", + "items": { + "type": "string" + } + }, + "preset": { + "$ref": "#/components/schemas/CollectionPreset" + }, + "task_fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "use_worktrees": { + "type": "boolean" + }, + "wrapper": { + "$ref": "#/components/schemas/SessionWrapperType" + } + } + }, + "SetupInitializeResponse": { + "type": "object", + "required": [ + "initialized", + "config_path", + "tickets_path", + "files_created", + "files_skipped", + "projects" + ], + "properties": { + "config_path": { + "type": "string" + }, + "files_created": { + "type": "array", + "items": { + "type": "string" + } + }, + "files_skipped": { + "type": "array", + "items": { + "type": "string" + } + }, + "initialized": { + "type": "boolean" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + } + }, + "tickets_path": { + "type": "string" + } + } + }, + "SetupStatusResponse": { + "type": "object", + "required": [ + "initialized", + "admin_configured", + "config_path", + "tickets_path", + "projects_by_tool", + "default_acceptance_criteria" + ], + "properties": { + "admin_configured": { + "type": "boolean" + }, + "config_path": { + "type": "string" + }, + "default_acceptance_criteria": { + "type": "string" + }, + "initialized": { + "type": "boolean" + }, + "projects_by_tool": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "propertyNames": { + "type": "string" + } + }, + "tickets_path": { + "type": "string" + } + } + }, + "SetupStep": { + "type": "string", + "description": "A step in the setup wizard.", + "enum": [ + "welcome", + "kanban-info", + "model-server", + "git-provider", + "collection-source", + "hosted-collections", + "task-field-config", + "session-wrapper-choice", + "execution-target", + "worktree-preference", + "admin-password", + "tmux-onboarding", + "vscode-setup", + "cmux-setup", + "zellij-setup", + "acceptance-criteria", + "startup-tickets", + "confirm" + ] + }, + "SetupStepResponse": { + "type": "object", + "required": [ + "slug", + "name", + "description", + "help_text", + "order" + ], + "properties": { + "description": { + "type": "string" + }, + "help_text": { + "type": "string" + }, + "name": { + "type": "string" + }, + "order": { + "type": "integer", + "minimum": 0 + }, + "slug": { + "$ref": "#/components/schemas/SetupStep" + } + } + }, "SkillEntry": { "type": "object", "description": "A single discovered skill file", @@ -10572,6 +11347,46 @@ } } }, + "ValidateGitTokenRequest": { + "type": "object", + "required": [ + "provider", + "token" + ], + "properties": { + "provider": { + "type": "string" + }, + "token": { + "type": "string", + "format": "password", + "writeOnly": true + } + } + }, + "ValidateGitTokenResponse": { + "type": "object", + "required": [ + "valid" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "username": { + "type": [ + "string", + "null" + ] + }, + "valid": { + "type": "boolean" + } + } + }, "ValidateKanbanCredentialsRequest": { "type": "object", "description": "Request to validate kanban credentials without persisting them.", @@ -10808,6 +11623,46 @@ } } }, + "WriteGitConfigRequest": { + "type": "object", + "required": [ + "provider", + "token_env" + ], + "properties": { + "provider": { + "type": "string" + }, + "token_env": { + "type": "string" + } + } + }, + "WriteGitConfigResponse": { + "type": "object", + "required": [ + "provider", + "token_env", + "shell_export_block" + ], + "properties": { + "provider": { + "type": "string" + }, + "shell_export_block": { + "type": "string" + }, + "token_env": { + "type": "string" + }, + "username": { + "type": [ + "string", + "null" + ] + } + } + }, "WriteGithubConfigBody": { "type": "object", "description": "Body for writing a GitHub Projects v2 config section.", @@ -11186,6 +12041,14 @@ { "name": "Auth", "description": "Bootstrap, sessions, OAuth device flow, and access keys" + }, + { + "name": "Setup", + "description": "First-run workspace initialization" + }, + { + "name": "Git", + "description": "Git provider onboarding" } ] } \ No newline at end of file diff --git a/docs/schemas/state.md b/docs/schemas/state.md index 9bcb2390..3215eebd 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -38,6 +38,7 @@ This file tracks the current state of agents, completed tickets, and system stat | Property | Type | Required | Description | | --- | --- | --- | --- | +| `git_context` | object | No | Non-secret Git configuration captured at launch. | | `id` | `string` | Yes | | | `ticket_id` | `string` | Yes | | | `ticket_type` | `string` | Yes | | @@ -71,6 +72,42 @@ This file tracks the current state of agents, completed tickets, and system stat | `step_launch_context` | object | No | Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. | | `target_name` | `string` \| `null` | No | Name of the resolved execution target this agent launched on | +### GitExecutionConfig + +Git settings owned by a named delegator. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `identity` | object | No | | +| `credentials` | object | No | | +| `settings` | `array` | No | | + +### GitIdentityConfig + +Commit identity template for delegated work. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | | +| `email` | `string` | Yes | | + +### GitCredentialConfig + +Supplied HTTPS credential, bound to a repository; contains no secret value. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `repository_url` | `string` | Yes | | +| `username` | `string` | Yes | | +| `token_env` | `string` | Yes | | + +### GitConfigEntry + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `key` | `string` | Yes | | +| `value` | `string` | Yes | | + ### StepLaunchContext Launch context fixed at launch time, persisted with the agent record, and diff --git a/docs/startup/index.md b/docs/startup/index.md index 170855aa..6e27bb3b 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -3,7 +3,7 @@ title: "Setup Wizard" layout: doc --- - + When Operator starts and no `.tickets/` directory exists, the setup wizard guides you through first-time initialization. This reference documents each step of the wizard. @@ -13,21 +13,23 @@ 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 | 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 | Web UI Password | Optionally set the admin password for the web dashboard | -| 5 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | -| 6 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | -| 7 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | -| 8 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | -| 9 | Kanban Info | Kanban integration overview and provider credential detection | -| 10 | Kanban Provider Setup | Per-provider credential validation and project selection | -| 11 | Collection Source | Choose which issue type collection to use | -| 12 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | -| 13 | Task Field Config | Configure optional fields for TASK issue type | -| 14 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | -| 15 | Startup Tickets | Optionally create tickets to bootstrap your projects | -| 16 | Confirm | Review settings and confirm initialization | +| 2 | Kanban Info | Connect a kanban provider, or skip and connect one later | +| 3 | Model Server | Declare which model providers this workspace uses | +| 4 | Git Provider | Connect a git provider so agents can branch, push and open PRs | +| 5 | Collection Source | Choose which issue type collection to use | +| 6 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | +| 7 | Task Field Config | Configure optional fields for TASK issue type | +| 8 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | +| 9 | Execution Target | Choose whether agents run locally or in Coder workspaces | +| 10 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | +| 11 | Web UI Password | Optionally set the admin password for the web dashboard | +| 12 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | +| 13 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | +| 14 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | +| 15 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | +| 16 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | +| 17 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 18 | Confirm | Review settings and confirm initialization | ## Step Details @@ -45,7 +47,89 @@ This gives you an overview of your development environment before proceeding. **Navigation**: Enter to continue, Esc to cancel -### 2. Session Wrapper Choice +### 2. Kanban Info + +*Connect a kanban provider, or skip and connect one later* + +Operator can sync with external kanban providers to pull in issues as tickets. +Supported providers: Jira, Linear, GitHub Projects. + +Credentials already exported (e.g. OPERATOR_JIRA_API_KEY) are listed as detected providers. + +**Connect a kanban provider** opens the same onboarding dialog the dashboard uses: pick a provider, enter its credentials, validate them against the live API, and choose a project. The provider section is written to config.toml and the token is exported into this session, with a shell snippet to make it permanent. + +**Skip for now** moves on; press `K` from the dashboard at any time. + +**Navigation**: ↑/↓ to select, Enter to confirm, Esc to go back + +### 3. Model Server + +*Declare which model providers this workspace uses* + +Model providers are where inference happens - distinct from the agent CLI that calls them. + +Each provider is probed live: a row reads `N models` when Operator can reach it, `key missing` when its API key env var is unset, or `unreachable` with the reason. + +Space declares a provider, writing a `[[model_servers]]` entry. Operator stores only the *name* of the environment variable holding the key, never the key itself - export it in your shell to make it permanent. + +Providers needing a custom base URL (OpenAI-compatible, LM Studio) are listed but not selectable here; add them to config.toml directly. + +This step is optional - Operator ships working defaults for the first-party vendors. + +**Navigation**: ↑/↓ or j/k to navigate, Space to declare, Enter to continue, Esc to go back + +### 4. Git Provider + +*Connect a git provider so agents can branch, push and open PRs* + +Operator branches per ticket and opens pull requests on your behalf, which needs a provider and a token. + +Each row reports what was found: the provider CLI (`gh`, `glab`, `tea`) not installed, an existing CLI login Operator can adopt with no typing, or a prompt for a personal access token. + +Only the *name* of the environment variable holding the token is written to config.toml. The token itself is exported into this session, and the step prints the shell line to make that permanent - without it the token is gone when Operator exits. + +This step is optional; Operator works against a local repository with no provider connected. + +**Navigation**: ↑/↓ or j/k to navigate, Enter to connect, Esc to go back + +### 5. 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 +- **Custom Selection**: Choose individual issue types + +**Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back + +### 6. Hosted Collections + +*Browse and select hosted collections (only shown if Browse chosen)* + +Pick one or more curated collections published at operator.untra.io. + +The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. + +Selections are additive - choose as many as apply. + +**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back + +### 7. 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) +- **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 + +### 8. Session Wrapper Choice *Select which session wrapper to use for launching coding agents* @@ -59,7 +143,19 @@ Your choice determines which setup steps follow. **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 3. Worktree Preference +### 9. Execution Target + +*Choose whether agents run locally or in Coder workspaces* + +Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH. + +Coder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment. + +Coder targets disable git worktrees and relay injection, and cannot be combined with Zellij. + +**Navigation**: ↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back + +### 10. Worktree Preference *Choose whether to use git worktrees for ticket isolation* @@ -71,7 +167,7 @@ Worktrees allow multiple agents to work on different tickets simultaneously with **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 4. Web UI Password +### 11. Web UI Password *Optionally set the admin password for the web dashboard* @@ -85,7 +181,7 @@ The password must be at least 12 characters. This step is hidden whe **Navigation**: Tab to switch fields, Enter to continue (blank to skip), Esc to go back -### 5. Tmux Onboarding +### 12. Tmux Onboarding *Help and documentation about tmux session management (shown if tmux selected)* @@ -99,7 +195,7 @@ Operator session names start with 'op-' for easy identification. **Navigation**: Enter to continue, Esc to go back -### 6. VS Code Setup +### 13. VS Code Setup *VS Code extension setup and verification (shown if VS Code selected)* @@ -110,7 +206,7 @@ Install the extension from the VS Code marketplace if prompted. **Navigation**: Enter to continue, Esc to go back -### 7. Cmux Setup +### 14. Cmux Setup *cmux session wrapper setup (shown if cmux selected)* @@ -120,7 +216,7 @@ This step verifies the cmux app's CLI binary exists at the configured binary_pat **Navigation**: Enter to continue, Esc to go back -### 8. Zellij Setup +### 15. Zellij Setup *Zellij session wrapper setup (shown if Zellij selected)* @@ -130,68 +226,7 @@ This step verifies Zellij is installed and configures the layout Operator will u **Navigation**: Enter to continue, Esc to go back -### 9. 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 - -### 10. 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 - -### 11. 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 -- **Custom Selection**: Choose individual issue types - -**Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back - -### 12. Hosted Collections - -*Browse and select hosted collections (only shown if Browse chosen)* - -Pick one or more curated collections published at operator.untra.io. - -The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. - -Selections are additive - choose as many as apply. - -**Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back - -### 13. 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) -- **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 - -### 14. Acceptance Criteria +### 16. Acceptance Criteria *Review and configure acceptance criteria for ticket completion* @@ -202,7 +237,7 @@ The default criteria cover formatting, tests, and lint checks. You can customize **Navigation**: Enter to continue, Esc to go back -### 15. Startup Tickets +### 17. Startup Tickets *Optionally create tickets to bootstrap your projects* @@ -215,7 +250,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 -### 16. Confirm +### 18. Confirm *Review settings and confirm initialization* diff --git a/opr8r/config/operator-relay.json b/opr8r/config/operator-relay.json index f37fbf41..9a81be28 100644 --- a/opr8r/config/operator-relay.json +++ b/opr8r/config/operator-relay.json @@ -25,7 +25,7 @@ }, { "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.", + "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": { diff --git a/package.json b/package.json index e129483d..5f7a84d7 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,17 @@ "lint": "oxlint --type-aware", "lint:ui": "oxlint --type-aware ui/src", "lint:webcomponents": "oxlint --type-aware webcomponents/src", - "lint:vscode": "oxlint --type-aware vscode-extension/src vscode-extension/test vscode-extension/webview-ui" + "lint:vscode": "oxlint --type-aware vscode-extension/src vscode-extension/test vscode-extension/webview-ui", + "fmt": "oxfmt --write \"ui/src/**/*.{ts,tsx}\" \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/scripts/**/*.mjs\" \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\" \"agnt-plugin/**/*.js\" \"coder-module/*.ts\"", + "fmt:check": "oxfmt --check \"ui/src/**/*.{ts,tsx}\" \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/scripts/**/*.mjs\" \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\" \"agnt-plugin/**/*.js\" \"coder-module/*.ts\"", + "fmt:ui": "oxfmt --write \"ui/src/**/*.{ts,tsx}\"", + "fmt:webcomponents": "oxfmt --write \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/scripts/**/*.mjs\"", + "fmt:vscode": "oxfmt --write \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\"", + "lint:agnt": "oxlint agnt-plugin", + "lint:coder-module": "oxlint --type-aware coder-module" }, "devDependencies": { + "oxfmt": "0.66.0", "oxlint": "1.81.0", "oxlint-tsgolint": "7.0.2001", "typedoc": "^0.27.0", diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh index 100ff755..456d04ec 100755 --- a/scripts/cicdprep.sh +++ b/scripts/cicdprep.sh @@ -68,7 +68,7 @@ fail() { } skip() { - echo -e " ${YELLOW}⊘ $1 (skipped — no changes)${RESET}" + echo -e " ${YELLOW}⊘ $1 (skipped - no changes)${RESET}" SKIPPED+=("$1") } @@ -95,7 +95,7 @@ run_step() { # Verify a bun project's lockfile is in sync with its package.json, exactly the # way CI does (`bun install --frozen-lockfile`). This is the check that catches -# the "lockfile had changes, but lockfile is frozen" CI failure locally — it +# the "lockfile had changes, but lockfile is frozen" CI failure locally - it # happens when package.json is edited but bun.lock isn't regenerated/committed. check_bun_lockfile() { local dir="$1" @@ -107,7 +107,7 @@ check_bun_lockfile() { if (cd "$dir" && bun install --frozen-lockfile) >/dev/null 2>&1; then pass "Lockfile sync: $dir" else - echo -e " ${YELLOW}lockfile out of sync — run: ${BOLD}(cd $dir && bun install)${RESET}${YELLOW} and commit bun.lock${RESET}" + echo -e " ${YELLOW}lockfile out of sync - run: ${BOLD}(cd $dir && bun install)${RESET}${YELLOW} and commit bun.lock${RESET}" fail "Lockfile sync: $dir" fi } @@ -124,7 +124,7 @@ fi MERGE_BASE=$(git merge-base "$MAIN_BRANCH" HEAD 2>/dev/null || echo "") if [ -z "$MERGE_BASE" ]; then - echo -e "${YELLOW}Could not find merge base with $MAIN_BRANCH — running all checks.${RESET}" + echo -e "${YELLOW}Could not find merge base with $MAIN_BRANCH - running all checks.${RESET}" RUN_ALL=true CHANGED_FILES="" else @@ -163,8 +163,11 @@ needs_operator() { } needs_opr8r() { has_changes '^opr8r/'; } -needs_vscode() { has_changes '^(vscode-extension/|icons/)'; } +needs_vscode() { has_changes '^(vscode-extension/|icons/|\.oxlintrc\.jsonc$)'; } needs_zed() { has_changes '^zed-extension/'; } +needs_relay() { has_changes '^crates/relay/'; } +needs_charts() { has_changes '^(charts/|\.github/workflows/build\.yaml$)'; } +needs_shell() { has_changes '^(scripts/|\.githooks/)'; } needs_coder() { has_changes '^(coder-module/|\.github/workflows/coder-module\.yaml$|scripts/ci/check-coder-module\.sh$)'; } needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json|src/collections/|collections/|src/schemas/|webcomponents/|src/workflow_gen/)'; } @@ -174,6 +177,16 @@ needs_bun_root() { has_changes '^(package\.json|bun\.lock)$'; } needs_bun_ui() { has_changes '^ui/(package\.json|bun\.lock)$'; } needs_bun_webcomp() { has_changes '^webcomponents/(package\.json|bun\.lock)$'; } +# Hand-written JS/TS, per subproject. oxlint/oxfmt and their configs live at the +# repo root, so touching either config re-checks every frontend subproject. +TS_CONFIG='^(package\.json|bun\.lock|\.oxlintrc\.jsonc|\.oxfmtrc\.json)$' +needs_ts_ui() { has_changes "^ui/|$TS_CONFIG"; } +needs_ts_webcomp() { has_changes "^webcomponents/|$TS_CONFIG"; } +needs_ts_vscode() { has_changes "^vscode-extension/|$TS_CONFIG"; } +needs_ts_agnt() { has_changes "^agnt-plugin/|$TS_CONFIG"; } +needs_ts_coder() { has_changes "^coder-module/.*\.ts$|$TS_CONFIG"; } +needs_ts_any() { needs_ts_ui || needs_ts_webcomp || needs_ts_vscode || needs_ts_agnt || needs_ts_coder; } + # --- 0. Bun lockfiles --- # # Run this first, cheaply, across every bun project so a stale lockfile fails @@ -191,7 +204,45 @@ else skip "Bun lockfiles" fi -# --- 1. Operator (main crate) --- +# --- 1. Frontend format + lint --- +# +# Root-installed oxfmt/oxlint cover every hand-written JS/TS subproject; +# generated output is excluded by .oxfmtrc.json / .oxlintrc.jsonc. + +if needs_ts_any; then + section "Frontend format + lint" + require_tool bun "frontend format + lint" + + step "Root toolchain install" + (bun install --frozen-lockfile) >/dev/null 2>&1 \ + && pass "Root toolchain install" || fail "Root toolchain install" + + # One formatter pass covers every subproject; a failure names the files. + run_step "oxfmt --check (all frontend)" bun run fmt:check + + if needs_ts_ui; then run_step "lint: ui" bun run lint:ui; else skip "lint: ui"; fi + if needs_ts_webcomp; then run_step "lint: webcomponents" bun run lint:webcomponents; else skip "lint: webcomponents"; fi + if needs_ts_agnt; then run_step "lint: agnt-plugin" bun run lint:agnt; else skip "lint: agnt-plugin"; fi + if needs_ts_coder; then run_step "lint: coder-module" bun run lint:coder-module; else skip "lint: coder-module"; fi +else + skip "Frontend format + lint" +fi + +# --- 2. Shell scripts --- +# +# Every committed script, not just the rendered coder-module one. Severity is +# capped at warning: the info-level findings are style notes CI does not gate. + +if needs_shell; then + section "Shell scripts" + require_tool shellcheck "shell script lint" + + run_step "shellcheck" bash -c 'shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/*' +else + skip "Shell scripts" +fi + +# --- 3. Operator (main crate) --- if needs_operator; then section "Operator (main crate)" @@ -200,13 +251,13 @@ if needs_operator; then require_tool cargo-deny "operator dependency audit" # The frontend is typed against types generated from Rust, so bindings come - # first — the same script .github/workflows/build.yaml runs as its gate. + # first - the same script .github/workflows/build.yaml runs as its gate. run_step "Bindings fresh" scripts/check-bindings-fresh.sh # CI additionally requires them committed; surface that here as a reminder BINDING_CHANGES="$(git status --porcelain --untracked-files=all bindings/ || true)" if [ -n "$BINDING_CHANGES" ]; then - echo -e " ${YELLOW}note: bindings/ has uncommitted changes — CI requires them committed:${RESET}" + echo -e " ${YELLOW}note: bindings/ has uncommitted changes - CI requires them committed:${RESET}" echo "$BINDING_CHANGES" | sed 's/^/ /' fi @@ -233,7 +284,7 @@ if needs_operator; then fi ) && pass "UI build + size check" || fail "UI build + size check" - run_step "cargo fmt" cargo fmt -- --check + run_step "cargo fmt" cargo fmt --all -- --check run_step "cargo clippy" cargo clippy --locked --all-targets --all-features -- -D warnings run_step "cargo test" cargo test --locked --all-features run_step "cargo deny" cargo deny --manifest-path Cargo.toml check @@ -241,7 +292,46 @@ else skip "Operator (main crate)" fi -# --- 2. opr8r --- +# --- 4. Helm chart --- +# +# build.yaml lint-test lints the chart and renders it with the optional +# features turned on, which is where template errors actually surface. + +if needs_charts; then + section "Helm chart" + require_tool helm "helm chart lint" + + run_step "helm lint" helm lint charts/operator + run_step "helm template" bash -c 'helm template operator charts/operator \ + --set ingress.enabled=true \ + --set ingress.host=operator.example.com \ + --set ingress.tls.secretName=operator-tls \ + --set networkPolicy.enabled=true \ + --set bootstrap.existingSecret=operator-bootstrap \ + > /dev/null' +else + skip "Helm chart" +fi + +# --- 5. crates/relay --- +# +# relay is a standalone crate (its own Cargo.lock), not a workspace member, so +# the root cargo fmt/clippy never reach it despite its strict [lints.clippy]. + +if needs_relay; then + section "crates/relay" + require_tool cargo "crates/relay" + require_tool cargo-deny "crates/relay dependency audit" + + run_step "relay fmt" bash -c "cd crates/relay && cargo fmt -- --check" + run_step "relay clippy" bash -c "cd crates/relay && cargo clippy --locked --all-targets --all-features -- -D warnings" + run_step "relay test" bash -c "cd crates/relay && cargo test --locked --all-features" + run_step "relay cargo deny" cargo deny --manifest-path crates/relay/Cargo.toml check +else + skip "crates/relay" +fi + +# --- 6. opr8r --- if needs_opr8r; then section "opr8r" @@ -256,7 +346,7 @@ else skip "opr8r" fi -# --- 3. vscode-extension --- +# --- 7. vscode-extension --- if needs_vscode; then section "vscode-extension" @@ -275,7 +365,7 @@ else skip "vscode-extension" fi -# --- 4. zed-extension --- +# --- 8. zed-extension --- if needs_zed; then section "zed-extension" @@ -295,7 +385,7 @@ else skip "zed-extension" fi -# --- 5. coder-module --- +# --- 9. coder-module --- if needs_coder; then section "coder-module" @@ -315,7 +405,7 @@ else skip "coder-module" fi -# --- 6. docs --- +# --- 10. docs --- if needs_docs; then section "docs" diff --git a/shared/types.ts b/shared/types.ts index aa1ef078..66a0f6d2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -366,6 +366,11 @@ export type UiConfig = { refresh_rate_ms: bigint, completed_history_hours: bigin 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, +/** + * Default named execution target. Per-launch and per-delegator choices + * take precedence. + */ +target: string | null, /** * Docker execution configuration */ @@ -415,9 +420,7 @@ export type RestApiConfig = { */ enabled: boolean, /** - * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so - * the server — which reports the project directory name — is not reachable - * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. */ host: string, /** @@ -429,10 +432,7 @@ port: number, */ cors_origins: Array, /** - * Externally reachable base URL (e.g. `https://operator.example.com`). - * - * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, - * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + * Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. */ public_url: string | null, }; @@ -603,9 +603,9 @@ model_server: string | null, * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). * * Export-only: Operator has no runtime client for those platforms, so a - * delegator carrying this CANNOT be launched locally — resolution errors out + * delegator carrying this CANNOT be launched locally - resolution errors out * (see `delegator_resolution`). It is stored, listed, serialized into an - * `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + * `AgentProfile`, and - for `platform == "agnt"` - surfaced in the * `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose * `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the * `id` must be the agent's UUID, not its display name). `None` = ordinary, @@ -697,8 +697,7 @@ provider: string, */ model: string, /** - * System prompt. Operator has no first-class system prompt, so this is - * preserved opaquely across import (see [`Delegator::unmapped_core`]). + * System prompt. This is preserved opaquely across import (see [`Delegator::unmapped_core`]). */ system_prompt?: string | null, /** @@ -714,25 +713,19 @@ mcp_servers: Array, */ tools: Array, /** - * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). - * `None` = a locally launchable agent, not bound to a remote platform. + * Declarative reference to a remote, named agent. `None` = a locally launchable agent, not bound to a remote target. */ remote_agent?: RemoteAgentRef | null, /** - * Operator-owned extension fields (typed). `None` when the agent carries no - * Operator-specific configuration. + * Operator-owned extension fields (typed). `None` when the agent carries no Operator-specific configuration. */ x_operator?: XOperator | null, /** - * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, - * `creditLimit`, ...). Operator never interprets this — pure pass-through. + * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, `creditLimit`, ...). */ x_agnt?: JsonValue | null, /** - * OpenAI-owned extension fields, opaque (`instructions`, `tools`, - * `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a - * second platform — never interpreted. This field is the whole per-tool cost - * of adding `OpenAI`: a passthrough bag, no mapping logic. + * OpenAI-owned extension fields, opaque (`instructions`, `tools`, `tool_resources`, `metadata`, thread refs, ...). */ x_openai?: JsonValue | null, }; @@ -1190,7 +1183,7 @@ contents: string, }; export type WorkflowFormatDto = { /** - * Stable slug (e.g. "claude", "agnt") — the value the `format` query param takes. + * Stable slug (e.g. "claude", "agnt") - the value the `format` query param takes. */ slug: string, /** @@ -1709,7 +1702,7 @@ export type VsCodeLaunchOptions = { */ delegator: string | null, /** - * Model to use (sonnet, opus, haiku) — fallback when no delegator + * Model to use (sonnet, opus, haiku) - fallback when no delegator */ model: VsCodeModelOption, /** @@ -1758,4 +1751,3 @@ worktreePath?: string, * Git branch name */ branch?: string, }; - diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index fdc3040c..2efc5dfa 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -134,8 +134,9 @@ fn adhoc_model_server_env( /// 2. `host` name set (deprecated) → ssh target of that name /// 3. `docker: Some(true)` (deprecated) → synthesized docker target /// 4. `docker: Some(false)` → local -/// 5. `launch.docker.enabled` → synthesized docker target -/// 6. → local +/// 5. `launch.target` → named global default +/// 6. `launch.docker.enabled` → synthesized docker target +/// 7. → local /// /// Deprecated combinations resolve deterministically (`target` wins over /// `host`/`docker`; `host` wins over `docker: true`) with one deprecation @@ -181,6 +182,9 @@ pub fn resolve_target( None => {} } } + if let Some(name) = &config.launch.target { + return resolve_named_target(config, name); + } if config.launch.docker.enabled { return Ok(TargetDef::docker(config.launch.docker.clone())); } @@ -926,7 +930,16 @@ mod tests { } #[test] - fn test_target_row5_enabled_true_now_targets_docker_for_auto_launches() { + fn test_target_row5_global_target_beats_docker_fallback() { + let mut config = Config::default(); + config.launch.target = Some("local".to_string()); + config.launch.docker.enabled = true; + let target = resolve_target(None, &config).unwrap(); + assert_eq!(target.kind, TargetKind::Local); + } + + #[test] + fn test_target_row6_enabled_true_now_targets_docker_for_auto_launches() { // BEHAVIOR CHANGE (approved): launch.docker.enabled was previously only // a TUI dialog gate; it is now a real resolution fallback, so REST/CLI/ // auto launches with enabled = true run in docker. @@ -942,7 +955,7 @@ mod tests { } #[test] - fn test_target_row6_default_is_local() { + fn test_target_row7_default_is_local() { let config = Config::default(); assert_eq!( resolve_target(None, &config).unwrap().kind, diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index dcef1e92..b06ef984 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -201,7 +201,7 @@ pub fn launch_in_cmux_with_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } @@ -377,7 +377,9 @@ pub fn launch_in_cmux_with_relaunch_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index ba83f64e..eb2346c0 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -178,8 +178,15 @@ fn wrap_for_target_impl( /// Whether Operator itself is running inside a container (docker/podman). fn is_containerized() -> bool { - std::path::Path::new("/.dockerenv").exists() - || std::path::Path::new("/run/.containerenv").exists() + container_signals( + std::path::Path::new("/.dockerenv").exists(), + std::path::Path::new("/run/.containerenv").exists(), + std::env::var_os("KUBERNETES_SERVICE_HOST").is_some(), + ) +} + +fn container_signals(docker: bool, podman: bool, kubernetes: bool) -> bool { + docker || podman || kubernetes } /// Build a docker command that wraps the LLM command. @@ -1151,6 +1158,14 @@ mod tests { ); } + #[test] + fn test_container_signals_include_kubernetes() { + assert!(container_signals(true, false, false)); + assert!(container_signals(false, true, false)); + assert!(container_signals(false, false, true)); + assert!(!container_signals(false, false, false)); + } + // ======================================== // get_default_model() tests // ======================================== diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index e355552e..c67c8ce2 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -951,7 +951,7 @@ impl Launcher { model, yolo: options.yolo_mode, session_id: session_id.map(str::to_string), - opr8r: step_command::resolve_opr8r_invocation(options.is_docker()), + opr8r: step_command::resolve_opr8r_invocation(options.resolves_on_target_path()), operator_relay: options.operator_relay, extra_flags: options.extra_flags.clone(), } @@ -1131,7 +1131,7 @@ impl Launcher { // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); if step_command::chain_step(&self.config, &ticket, &step_name) { llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); @@ -1440,7 +1440,9 @@ impl Launcher { // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); if step_command::chain_step(&self.config, &ticket, &step_name) { llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index b19b677c..f09483c1 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -101,6 +101,12 @@ impl LaunchOptions { matches!(self.target.kind, TargetKind::Docker(_)) } + /// Whether the launched command executes outside this machine and must + /// resolve executables from the target's PATH. + pub fn resolves_on_target_path(&self) -> bool { + !matches!(self.target.kind, TargetKind::Local) + } + /// The remote host this launch runs on, if any: the provisioned coder /// workspace when set, else an ssh target's declared host. pub fn remote_host(&self) -> Option { @@ -208,6 +214,17 @@ mod tests { } } + #[test] + fn test_resolves_on_target_path_for_execution_targets() { + assert!(!options_with(TargetKind::Local, false).resolves_on_target_path()); + assert!( + options_with(TargetKind::Docker(DockerConfig::default()), false) + .resolves_on_target_path() + ); + assert!(options_with(TargetKind::Coder(coder_config()), false).resolves_on_target_path()); + assert!(options_with(TargetKind::Ssh(ssh_target()), false).resolves_on_target_path()); + } + #[test] fn test_launch_mode_roundtrip() { let kinds = [ diff --git a/src/agents/launcher/step_command.rs b/src/agents/launcher/step_command.rs index 4a7ac78c..95c0cb30 100644 --- a/src/agents/launcher/step_command.rs +++ b/src/agents/launcher/step_command.rs @@ -176,10 +176,10 @@ pub fn chain_step(config: &Config, ticket: &Ticket, step_name: &str) -> bool { /// Resolve the opr8r invocation for the launch environment. /// -/// Inside a container the image ships `opr8r` on PATH; locally the binary -/// sits alongside `operator` (or on PATH as a fallback). -pub fn resolve_opr8r_invocation(containerized: bool) -> String { - if containerized { +/// Commands that execute on another target resolve `opr8r` from that target's +/// PATH; local commands prefer the binary alongside `operator`. +pub fn resolve_opr8r_invocation(on_target_path: bool) -> String { + if on_target_path { return "opr8r".to_string(); } locate_opr8r_binary().map_or_else(|| "opr8r".to_string(), |p| p.display().to_string()) @@ -517,10 +517,29 @@ mod tests { } #[test] - fn test_resolve_opr8r_invocation_containerized_uses_path_name() { + fn test_resolve_opr8r_invocation_on_target_uses_path_name() { assert_eq!(resolve_opr8r_invocation(true), "opr8r"); } + #[test] + fn test_resolve_opr8r_invocation_coder_uses_path_name() { + let options = crate::agents::launcher::LaunchOptions { + target: crate::config::TargetDef { + name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), + display_name: None, + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: "operator-agent".to_string(), + ..Default::default() + }), + }, + ..Default::default() + }; + assert_eq!( + resolve_opr8r_invocation(options.resolves_on_target_path()), + "opr8r" + ); + } + #[test] fn test_step_launch_context_roundtrip() { let ctx = make_ctx(); diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index 06abeb74..c64d7580 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -208,7 +208,7 @@ pub fn launch_in_tmux_with_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } @@ -474,7 +474,9 @@ pub fn launch_in_tmux_with_relaunch_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 6669d458..5d5df9be 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -136,7 +136,7 @@ pub fn launch_in_zellij_with_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation(options.resolves_on_target_path()); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } @@ -316,7 +316,9 @@ pub fn launch_in_zellij_with_relaunch_options( // Wrap in the opr8r step wrapper when the issuetype defines this step, // so completion reporting and exec-chain transitions engage. if step_command::chain_step(config, ticket, &step_name) { - let opr8r = step_command::resolve_opr8r_invocation(options.launch_options.is_docker()); + let opr8r = step_command::resolve_opr8r_invocation( + options.launch_options.resolves_on_target_path(), + ); llm_cmd = step_command::wrap_step(&opr8r, &ticket.id, &step_name, &session_uuid, &llm_cmd); } diff --git a/src/app/git_onboarding.rs b/src/app/git_onboarding.rs index 90bbe5d4..23915312 100644 --- a/src/app/git_onboarding.rs +++ b/src/app/git_onboarding.rs @@ -1,277 +1,47 @@ -//! Git provider onboarding logic. -//! -//! Detects CLI tools, grabs tokens, validates credentials, and resolves -//! the appropriate onboarding step for a given provider. - -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result}; - -use crate::api::cli_detection::onboarding_spec_for_slug; -use crate::config::{Config, GitProviderConfig}; - -/// The resolved onboarding step for a provider. -#[derive(Debug)] -pub enum OnboardingStep { - /// CLI not installed - open install page. - InstallCli { - install_url: String, - provider_display: String, - }, - /// CLI installed but no token - show PAT dialog. - CollectToken { - pat_url: String, - provider: String, - provider_display: String, - placeholder: String, - }, - /// CLI installed and authenticated - token ready to use. - AutoConfigured { - username: String, - token: String, - provider: String, - provider_display: String, - }, -} - -/// Check if a CLI tool is available on PATH (synchronous). -fn is_cli_installed(command: &str) -> bool { - Command::new(command) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Try to grab an auth token from a CLI tool (synchronous). -fn grab_cli_token(command: &str, args: &[&str]) -> Option { - let output = Command::new(command) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .ok()?; - - if output.status.success() { - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if token.is_empty() { - None - } else { - Some(token) - } - } else { - None - } -} - -/// Validate a GitHub personal access token and return the username. -pub fn validate_github_token(token: &str) -> Result { - let client = reqwest::blocking::Client::new(); - let resp = client - .get("https://api.github.com/user") - .header("Authorization", format!("Bearer {token}")) - .header("User-Agent", "operator") - .send() - .context("Failed to reach GitHub API")?; - - if !resp.status().is_success() { - anyhow::bail!("GitHub token validation failed (HTTP {})", resp.status()); - } - - let body: serde_json::Value = resp.json().context("Failed to parse GitHub response")?; - body["login"] - .as_str() - .map(std::string::ToString::to_string) - .context("GitHub response missing 'login' field") -} - -/// Validate a GitLab personal access token and return the username. -pub fn validate_gitlab_token(token: &str) -> Result { - let client = reqwest::blocking::Client::new(); - let resp = client - .get("https://gitlab.com/api/v4/user") - .header("Private-Token", token) - .header("User-Agent", "operator") - .send() - .context("Failed to reach GitLab API")?; - - if !resp.status().is_success() { - anyhow::bail!("GitLab token validation failed (HTTP {})", resp.status()); - } - - let body: serde_json::Value = resp.json().context("Failed to parse GitLab response")?; - body["username"] - .as_str() - .map(std::string::ToString::to_string) - .context("GitLab response missing 'username' field") -} - -/// Resolve the onboarding step for a provider. -/// -/// Checks CLI installation → CLI authentication → returns the appropriate step. -pub fn resolve_onboarding(provider: &str) -> Option { - let meta = onboarding_spec_for_slug(provider)?; - - if !is_cli_installed(meta.command) { - return Some(OnboardingStep::InstallCli { - install_url: meta.install_url.to_string(), - provider_display: meta.display_name.to_string(), - }); - } - - if let Some(token) = (!meta.auth_args.is_empty()) - .then(|| grab_cli_token(meta.command, meta.auth_args)) - .flatten() - { - // Validate the token - let username = match provider { - "github" => validate_github_token(&token), - "gitlab" => validate_gitlab_token(&token), - _ => return None, - }; - - if let Ok(username) = username { - return Some(OnboardingStep::AutoConfigured { +//! TUI orchestration for shared git provider onboarding. + +pub(crate) use crate::services::git_onboarding::{ + apply_git_provider, complete_git_onboarding, resolve_onboarding_with_config, + shell_export_block, token_env_for, validate_token_with_config, OnboardingStep, +}; + +impl crate::app::App { + pub(super) fn connect_git_provider_from_setup(&mut self, slug: &str) { + let step = resolve_onboarding_with_config(&self.config, slug); + let status = match step { + Some(OnboardingStep::InstallCli { + install_url, + provider_display, + }) => { + let _ = crate::app::status_actions::open_in_browser(&install_url); + format!("install the {provider_display} CLI, then retry") + } + Some(OnboardingStep::CollectToken { + pat_url, + provider, + provider_display, + placeholder, + }) => { + let _ = crate::app::status_actions::open_in_browser(&pat_url); + self.git_token_dialog + .show(&provider, &provider_display, &pat_url, &placeholder); + return; + } + Some(OnboardingStep::AutoConfigured { username, token, - provider: provider.to_string(), - provider_display: meta.display_name.to_string(), - }); - } - // CLI token is stale/invalid, fall through to manual entry - } - - Some(OnboardingStep::CollectToken { - pat_url: meta.pat_url.to_string(), - provider: provider.to_string(), - provider_display: meta.display_name.to_string(), - placeholder: meta.placeholder.to_string(), - }) -} - -/// Complete git onboarding by writing provider config and setting the env var. -pub fn complete_git_onboarding(config: &mut Config, provider: &str, token: &str) -> Result<()> { - match provider { - "github" => { - config.git.provider = Some(GitProviderConfig::GitHub); - config.git.github.enabled = true; - config.save()?; - std::env::set_var(&config.git.github.token_env, token); - } - "gitlab" => { - config.git.provider = Some(GitProviderConfig::GitLab); - config.git.gitlab.enabled = true; - config.save()?; - std::env::set_var(&config.git.gitlab.token_env, token); - } - "gitea" => { - config.git.provider = Some(GitProviderConfig::Gitea); - config.git.gitea.enabled = true; - config.save()?; - std::env::set_var(&config.git.gitea.token_env, token); - } - _ => anyhow::bail!("Unsupported provider: {provider}"), - } - Ok(()) -} - -/// Validate a token for the given provider, returning the username on success. -pub fn validate_token(provider: &str, token: &str) -> Result { - match provider { - "github" => validate_github_token(token), - "gitlab" => validate_gitlab_token(token), - _ => anyhow::bail!("Unsupported provider: {provider}"), - } -} - -pub fn resolve_onboarding_with_config(config: &Config, provider: &str) -> Option { - let mut step = resolve_onboarding(provider)?; - if provider == "gitea" { - let base = - crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com") - .ok()?; - if let OnboardingStep::CollectToken { pat_url, .. } = &mut step { - *pat_url = base.join("user/settings/applications").ok()?.to_string(); + provider, + .. + }) => match apply_git_provider(&mut self.config, &provider, &token) { + Ok(()) => format!("connected as {username}"), + Err(error) => format!("failed: {error}"), + }, + None => "unsupported provider".to_string(), + }; + let export_hint = token_env_for(&self.config, slug).map(|env| shell_export_block(&env)); + if let Some(setup) = self.setup_screen.as_mut() { + setup.set_git_provider_status(slug, status); + setup.git_export_hint = export_hint; } } - Some(step) -} - -pub fn validate_token_with_config(config: &Config, provider: &str, token: &str) -> Result { - if provider != "gitea" { - return validate_token(provider, token); - } - let base = crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com")?; - let git = crate::config::GitExecutionConfig { - credentials: Some(crate::config::GitCredentialConfig { - repository_url: base.join("operator/authentication")?.to_string(), - username: "operator".into(), - token_env: config.git.gitea.token_env.clone(), - }), - ..Default::default() - }; - let runtime = crate::git::runtime::GitRuntime::create_with_token(&git, Some(token))?; - let output = Command::new(crate::api::cli_detection::binary_for( - crate::types::pr::GitProvider::Gitea, - )) - .args(["api", "--login", "operator", "user"]) - .env("XDG_CONFIG_HOME", &runtime.path) - .output() - .context("Gitea requires tea with the api command")?; - anyhow::ensure!(output.status.success(), "Gitea token validation failed"); - let body: serde_json::Value = serde_json::from_slice(&output.stdout)?; - body["login"] - .as_str() - .map(str::to_owned) - .context("Gitea response missing login") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_meta_for_github() { - let meta = onboarding_spec_for_slug("github").unwrap(); - assert_eq!(meta.command, "gh"); - assert_eq!(meta.display_name, "GitHub"); - assert_eq!( - meta.pat_url, - "https://github.com/settings/personal-access-tokens/new" - ); - } - - #[test] - fn test_meta_for_gitlab() { - let meta = onboarding_spec_for_slug("gitlab").unwrap(); - assert_eq!(meta.command, "glab"); - assert_eq!(meta.display_name, "GitLab"); - assert_eq!( - meta.pat_url, - "https://gitlab.com/-/user_settings/personal_access_tokens" - ); - } - - #[test] - fn test_meta_for_unknown_returns_none() { - assert!(onboarding_spec_for_slug("bitbucket").is_none()); - assert!(onboarding_spec_for_slug("").is_none()); - } - - #[test] - fn test_is_cli_installed_nonexistent() { - assert!(!is_cli_installed("nonexistent-cli-tool-xyz-12345")); - } - - #[test] - fn test_grab_cli_token_nonexistent() { - assert!(grab_cli_token("nonexistent-cli-tool-xyz-12345", &["auth", "token"]).is_none()); - } - - #[test] - fn test_resolve_onboarding_unknown_provider() { - assert!(resolve_onboarding("bitbucket").is_none()); - } } diff --git a/src/app/kanban_onboarding.rs b/src/app/kanban_onboarding.rs index de978e77..61193a24 100644 --- a/src/app/kanban_onboarding.rs +++ b/src/app/kanban_onboarding.rs @@ -37,6 +37,16 @@ pub(crate) struct LinearCredsInflight { impl App { /// Show the kanban onboarding dialog (entry point from the kanban view). + /// `write_config` persists straight to disk (re-reading it first, so + /// concurrent writers are not clobbered). Pick the result back up, or the + /// next `self.config.save()` would write the section away again. + fn reload_config_after_kanban_write(&mut self) { + match crate::config::Config::load(None) { + Ok(fresh) => self.config = fresh, + Err(e) => tracing::warn!(error = %e, "Failed to reload config after kanban write"), + } + } + pub(super) fn show_kanban_onboarding_dialog(&mut self) { self.kanban_onboarding_dialog.show(); self.kanban_onboarding_creds = KanbanOnboardingCreds::default(); @@ -260,6 +270,7 @@ impl App { }; kanban_onboarding::write_config(write_req, None) .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; + self.reload_config_after_kanban_write(); // Set session env let env_req = SetKanbanSessionEnvRequest { @@ -310,6 +321,7 @@ impl App { }; kanban_onboarding::write_config(write_req, None) .map_err(|e| anyhow::anyhow!("write_config failed: {e:?}"))?; + self.reload_config_after_kanban_write(); let env_req = SetKanbanSessionEnvRequest { provider: KanbanProviderKind::Linear, diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index c156c872..de8d2292 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -17,6 +17,118 @@ impl App { let code = key.code; let mods = key.modifiers; + // Modal credential dialogs outrank the setup screen: both can be opened from inside the wizard, + // whose bindings would otherwise eat their text input (`c` quits, `i` initializes). + if self.git_token_dialog.visible { + match code { + KeyCode::Esc => { + self.git_token_dialog.hide(); + } + KeyCode::Enter => { + let token = self.git_token_dialog.token().to_string(); + if token.is_empty() { + self.git_token_dialog.set_error("Token cannot be empty"); + } else { + let provider = self.git_token_dialog.provider.clone(); + let provider_display = self.git_token_dialog.provider_display.clone(); + match git_onboarding::validate_token_with_config( + &self.config, + &provider, + &token, + ) { + Ok(username) => { + // The wizard persists once at Confirm; saving + // here would strand a partial config on cancel. + let in_setup = self.setup_screen.is_some(); + let applied = if in_setup { + git_onboarding::apply_git_provider( + &mut self.config, + &provider, + &token, + ) + } else { + git_onboarding::complete_git_onboarding( + &mut self.config, + &provider, + &token, + ) + }; + match applied { + Ok(()) => { + self.git_token_dialog.hide(); + if let Some(setup) = self.setup_screen.as_mut() { + setup.set_git_provider_status( + &provider, + format!("connected as {username}"), + ); + } else { + self.dashboard.update_config(&self.config); + self.refresh_data()?; + self.dashboard.set_status(&format!( + "{provider_display} connected as {username}" + )); + } + } + Err(e) => { + self.git_token_dialog + .set_error(&format!("Failed to save config: {e}")); + } + } + } + Err(e) => { + self.git_token_dialog + .set_error(&format!("Token validation failed: {e}")); + } + } + } + } + KeyCode::Char(c) => { + self.git_token_dialog.handle_char(c); + } + KeyCode::Backspace => { + self.git_token_dialog.handle_backspace(); + } + KeyCode::Delete => { + self.git_token_dialog.handle_delete(); + } + KeyCode::Left => { + self.git_token_dialog.cursor_left(); + } + KeyCode::Right => { + self.git_token_dialog.cursor_right(); + } + KeyCode::Home => { + self.git_token_dialog.cursor_home(); + } + KeyCode::End => { + self.git_token_dialog.cursor_end(); + } + _ => {} + } + return Ok(()); + } + + // Sync confirm dialog handling + if self.sync_confirm_dialog.visible { + if let Some(result) = self.sync_confirm_dialog.handle_key(code) { + match result { + SyncConfirmResult::Confirmed => { + self.run_kanban_sync_all().await?; + } + SyncConfirmResult::Cancelled => { + // Already hidden by handle_key + } + } + } + return Ok(()); + } + + if self.kanban_onboarding_dialog.visible { + let action = self.kanban_onboarding_dialog.handle_key(code); + self.handle_kanban_onboarding_action(action).await?; + return Ok(()); + } + // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { // The password step needs raw characters, and the wizard bindings @@ -39,6 +151,16 @@ impl App { setup.handle_password_key(code); return Ok(()); } + if setup.step == crate::ui::setup::SetupStep::ExecutionTarget + && setup.execution_target_state.selected() == Some(1) + && matches!( + code, + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete + ) + { + setup.handle_execution_target_key(code); + return Ok(()); + } match code { KeyCode::Char('i' | 'I') if setup.confirm_selected => { @@ -72,6 +194,21 @@ impl App { let timeout = templates.collections_fetch_timeout_secs; setup.load_hosted_collections(url.as_deref(), timeout).await; } + if matches!(setup.step, crate::ui::setup::SetupStep::ModelServer) + && !setup.model_servers_probed + { + setup.probe_model_servers(&self.config).await; + } + // Drain both requests before touching `self`, so + // the borrow on `setup_screen` has ended. + let open_kanban = setup.take_kanban_dialog_request(); + let git_slug = setup.take_git_connect_request(); + if open_kanban { + self.show_kanban_onboarding_dialog(); + } + if let Some(slug) = git_slug { + self.connect_git_provider_from_setup(&slug); + } } } } @@ -323,99 +460,6 @@ impl App { return Ok(()); } - // Git token dialog handling - if self.git_token_dialog.visible { - match code { - KeyCode::Esc => { - self.git_token_dialog.hide(); - } - KeyCode::Enter => { - let token = self.git_token_dialog.token().to_string(); - if token.is_empty() { - self.git_token_dialog.set_error("Token cannot be empty"); - } else { - let provider = self.git_token_dialog.provider.clone(); - let provider_display = self.git_token_dialog.provider_display.clone(); - match git_onboarding::validate_token_with_config( - &self.config, - &provider, - &token, - ) { - Ok(username) => { - match git_onboarding::complete_git_onboarding( - &mut self.config, - &provider, - &token, - ) { - Ok(()) => { - self.git_token_dialog.hide(); - self.dashboard.update_config(&self.config); - self.refresh_data()?; - self.dashboard.set_status(&format!( - "{provider_display} connected as {username}" - )); - } - Err(e) => { - self.git_token_dialog - .set_error(&format!("Failed to save config: {e}")); - } - } - } - Err(e) => { - self.git_token_dialog - .set_error(&format!("Token validation failed: {e}")); - } - } - } - } - KeyCode::Char(c) => { - self.git_token_dialog.handle_char(c); - } - KeyCode::Backspace => { - self.git_token_dialog.handle_backspace(); - } - KeyCode::Delete => { - self.git_token_dialog.handle_delete(); - } - KeyCode::Left => { - self.git_token_dialog.cursor_left(); - } - KeyCode::Right => { - self.git_token_dialog.cursor_right(); - } - KeyCode::Home => { - self.git_token_dialog.cursor_home(); - } - KeyCode::End => { - self.git_token_dialog.cursor_end(); - } - _ => {} - } - return Ok(()); - } - - // Sync confirm dialog handling - if self.sync_confirm_dialog.visible { - if let Some(result) = self.sync_confirm_dialog.handle_key(code) { - match result { - SyncConfirmResult::Confirmed => { - self.run_kanban_sync_all().await?; - } - SyncConfirmResult::Cancelled => { - // Already hidden by handle_key - } - } - } - return Ok(()); - } - - // Kanban onboarding dialog handling - if self.kanban_onboarding_dialog.visible { - let action = self.kanban_onboarding_dialog.handle_key(code); - self.handle_kanban_onboarding_action(action).await?; - return Ok(()); - } - // Normal mode match code { KeyCode::Char('q') => { diff --git a/src/app/mod.rs b/src/app/mod.rs index be2caa1f..2ba759f7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -116,45 +116,13 @@ pub struct App { impl App { pub async fn new(mut config: Config, start_web: bool, open_ui: bool) -> Result { - // Refresh LLM tool detection every startup so config edits and - // runtime-loaded tool JSONs take effect (cached tools skip re-probe) - let refreshed = crate::llm::refresh_tool_detection(&config.llm_tools); - let detection_changed = - serde_json::to_value(&refreshed).ok() != serde_json::to_value(&config.llm_tools).ok(); - config.llm_tools = refreshed; - - // Log detected tools - for tool in &config.llm_tools.detected { - tracing::info!( - tool = %tool.name, - version = %tool.version, - path = %tool.path, - "LLM tool detected" - ); - } - - // Log available providers - for provider in &config.llm_tools.providers { - tracing::debug!( - tool = %provider.tool, - model = %provider.model, - "LLM provider available" - ); - } - - // Save only when detection results changed, to avoid rewriting - // config.toml on every boot - if detection_changed { - if let Err(e) = config.save() { - tracing::warn!("Failed to save LLM detection results: {}", e); - } - } + crate::llm::refresh_config_detection(&mut config); let dashboard = Dashboard::new(&config); // Check if tickets directory exists let tickets_path = config.tickets_path(); - let needs_setup = !tickets_path.join("queue").exists(); + let needs_setup = !crate::startup::workspace_initialized(&config); // For setup screen, we discover projects dynamically // After setup, we use the projects list from config diff --git a/src/app/tickets.rs b/src/app/tickets.rs index ae21ae0e..8ed13242 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -1,13 +1,9 @@ use anyhow::Result; -use std::fs; -use crate::agents::{generate_status_script, generate_tmux_conf}; use crate::agents::{AgentTicketCreator, AssessTicketCreator}; use crate::auth::store::AuthStore; use crate::queue::TicketCreator; -use crate::setup::filter_schema_fields; use crate::state::State; -use crate::templates::TemplateType; use crate::ui::create_dialog::CreateDialogResult; use crate::ui::projects_dialog::{ProjectAction, ProjectsDialogResult}; use crate::ui::with_suspended_tui; @@ -15,108 +11,43 @@ use crate::ui::with_suspended_tui; use super::{App, AppTerminal}; impl App { - /// Initialize the tickets directory with default templates and save config - pub(super) fn initialize_tickets(&mut self) -> Result<()> { - let tickets_path = self.config.tickets_path(); - - // Create directories - fs::create_dir_all(tickets_path.join("queue"))?; - fs::create_dir_all(tickets_path.join("in-progress"))?; - fs::create_dir_all(tickets_path.join("completed"))?; - fs::create_dir_all(tickets_path.join("templates"))?; - fs::create_dir_all(tickets_path.join("operator"))?; - - // Get selected issuetype collection and configured fields from setup screen - let (selected_preset, selected_collection, task_fields) = self - .setup_screen - .as_ref() - .map(|s| (s.preset(), s.collection(), s.configured_task_fields())) - .unwrap_or_else(|| { - ( - crate::config::CollectionPreset::Simple, - vec!["TASK".to_string()], - vec!["priority".to_string(), "context".to_string()], - ) - }); - - // Update config with selected preset and collection - self.config.templates.preset = selected_preset; - if selected_preset == crate::config::CollectionPreset::Custom { - self.config.templates.collection = selected_collection.clone(); - } else { - self.config.templates.collection.clear(); - } - - // Write template files (only for selected types) - for template_type in TemplateType::all() { - let type_str = template_type.as_str(); - if !selected_collection.contains(&type_str.to_string()) { - continue; - } + /// Build setup options from the wizard's collected choices. + fn setup_options(&self) -> crate::setup::SetupOptions { + let Some(screen) = self.setup_screen.as_ref() else { + return crate::setup::SetupOptions::default(); + }; - let filename = match template_type { - TemplateType::Feature => "feature.md", - TemplateType::Fix => "fix.md", - TemplateType::Task => "task.md", - TemplateType::Spike => "spike.md", - TemplateType::Investigation => "investigation.md", - TemplateType::Assess => "assess.md", - TemplateType::Sync => "sync.md", - TemplateType::Init => "init.md", - }; - let filepath = tickets_path.join("templates").join(filename); - fs::write(&filepath, template_type.template_content())?; - - // Also write the JSON schema (with field filtering applied) - let schema_filename = match template_type { - TemplateType::Feature => "feature.json", - TemplateType::Fix => "fix.json", - TemplateType::Task => "task.json", - TemplateType::Spike => "spike.json", - TemplateType::Investigation => "investigation.json", - TemplateType::Assess => "assess.json", - TemplateType::Sync => "sync.json", - TemplateType::Init => "init.json", - }; - let schema_filepath = tickets_path.join("templates").join(schema_filename); - let filtered_schema = filter_schema_fields(template_type.schema(), &task_fields)?; - fs::write(&schema_filepath, filtered_schema)?; - } + let hosted: Vec = screen + .selected_hosted_collections() + .into_iter() + .map(|r| (r.manifest.clone(), r.files.clone(), r.icon_svg.clone())) + .collect(); + let active_collection = match hosted.as_slice() { + [single] => Some(single.0.id.clone()), + _ => None, + }; - // If the user picked hosted collections, scaffold each into its own - // collection-scoped directory (manifest + verified issuetype files). The - // loader discovers every templates//collection.json; when exactly one - // was chosen it also becomes the active collection. - let hosted: Vec<_> = self - .setup_screen - .as_ref() - .map(|s| { - s.selected_hosted_collections() - .into_iter() - .map(|r| (r.manifest.clone(), r.files.clone(), r.icon_svg.clone())) - .collect() - }) - .unwrap_or_default(); - for (manifest, files, icon_svg) in &hosted { - crate::startup::templates::write_fetched_collection( - &tickets_path.join("templates"), - manifest, - files, - icon_svg.as_deref(), - )?; + crate::setup::SetupOptions { + preset: screen.preset(), + task_fields: screen.configured_task_fields(), + use_worktrees: screen.use_worktrees, + wrapper: Some(screen.selected_wrapper), + acceptance_criteria: Some(screen.acceptance_criteria_text.clone()), + custom_collection: screen.collection(), + active_collection, + hosted_collections: hosted, + model_servers: screen.declared_model_servers(), + execution_target: Some(screen.selected_execution_target()), + ..Default::default() } - if let [single] = hosted.as_slice() { - self.config.templates.active_collection = Some(single.0.id.clone()); - } - - // Generate tmux configuration files - self.generate_tmux_config()?; + } - // Discover projects (one-time scan during setup) - // Use full discovery to get git info for filtering - let discovered_full = self.config.discover_projects_full(); - let discovered_projects: Vec = - discovered_full.iter().map(|p| p.name.clone()).collect(); + /// Initialize the tickets directory with default templates and save config + pub(super) fn initialize_tickets(&mut self) -> Result<()> { + let options = self.setup_options(); + let result = crate::setup::initialize_workspace(&mut self.config, &options)?; + let discovered_full = result.discovered; + let discovered_projects = self.config.projects.clone(); // Create the admin account before the config is written. if let Some(password) = self @@ -128,13 +59,11 @@ impl App { persist_admin_password(&store, Some(password))?; } - // Update config with discovered projects and save - self.config.projects = discovered_projects.clone(); self.config.save()?; // Reload the issue type registry so the chosen collection is active // without requiring a restart (mirrors App::new's load path). - let mut registry = crate::startup::templates::load_registry(&tickets_path); + let mut registry = crate::startup::templates::load_registry(&self.config.tickets_path()); if let Some(ref active) = self.config.templates.active_collection { if let Err(e) = registry.activate_collection(active) { tracing::warn!("Failed to activate collection '{}': {}", active, e); @@ -220,41 +149,6 @@ impl App { Ok(()) } - /// Generate custom tmux config and status script - pub(super) fn generate_tmux_config(&mut self) -> Result<()> { - let state_path = self.config.state_path(); - let tmux_conf_path = self.config.tmux_config_path(); - let status_script_path = self.config.tmux_status_script_path(); - - // Generate tmux.conf - let tmux_conf_content = generate_tmux_conf(&status_script_path, &state_path); - fs::write(&tmux_conf_path, tmux_conf_content)?; - - // Generate status script - let status_script_content = generate_status_script(); - fs::write(&status_script_path, status_script_content)?; - - // Make status script executable - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&status_script_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&status_script_path, perms)?; - } - - // Mark config as generated - self.config.tmux.config_generated = true; - - tracing::info!( - tmux_conf = %tmux_conf_path.display(), - status_script = %status_script_path.display(), - "Generated tmux configuration files" - ); - - Ok(()) - } - /// Create a new ticket from the dialog result pub(super) fn create_ticket( &mut self, diff --git a/src/auth/schema.rs b/src/auth/schema.rs index 0ed1cd65..0dc5ef05 100644 --- a/src/auth/schema.rs +++ b/src/auth/schema.rs @@ -7,7 +7,11 @@ //! silently inconsistent with new ones. use anyhow::{Context, Result}; -use rusqlite::{Connection, TransactionBehavior}; +use rusqlite::{Connection, ErrorCode, TransactionBehavior}; +use std::time::{Duration, Instant}; + +const BUSY_TIMEOUT: Duration = Duration::from_secs(5); +const WAL_RETRY_INTERVAL: Duration = Duration::from_millis(10); /// Ordered schema migrations. **Append only.** const MIGRATIONS: &[&str] = &[ @@ -184,21 +188,35 @@ pub fn migrate(conn: &mut Connection) -> Result<()> { /// Connection pragmas applied on open. pub fn apply_pragmas(conn: &Connection) -> Result<()> { + conn.busy_timeout(BUSY_TIMEOUT) + .context("setting busy timeout")?; // WAL keeps a reader from blocking the writer, which matters because the // TUI reads auth state on the same database the API server writes. - conn.pragma_update(None, "journal_mode", "WAL") - .context("enabling WAL")?; + enable_wal(conn, BUSY_TIMEOUT).context("enabling WAL")?; conn.pragma_update(None, "foreign_keys", "ON") .context("enabling foreign keys")?; conn.pragma_update(None, "synchronous", "NORMAL") .context("setting synchronous")?; - // Wait for a concurrent writer rather than failing instantly. Two Operator - // processes on one workspace is normal, not exceptional. - conn.busy_timeout(std::time::Duration::from_secs(5)) - .context("setting busy timeout")?; Ok(()) } +fn enable_wal(conn: &Connection, timeout: Duration) -> rusqlite::Result<()> { + let deadline = Instant::now() + timeout; + loop { + match conn.pragma_update(None, "journal_mode", "WAL") { + Err(error) if error.sqlite_error_code() == Some(ErrorCode::DatabaseBusy) => { + // WAL conversion can bypass SQLite's busy handler to avoid deadlock. + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(error); + } + std::thread::sleep(WAL_RETRY_INTERVAL.min(remaining)); + } + result => return result, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -358,6 +376,80 @@ mod tests { assert_eq!(version as usize, MIGRATIONS.len()); } + #[test] + fn test_simultaneous_first_opens_all_reach_wal() { + // The one-time WAL conversion takes an exclusive lock and reports + // `SQLITE_BUSY` without consulting the busy handler, so `busy_timeout` + // does not cover it. Repeat synchronized first opens to exercise retries. + const OPENERS: usize = 16; + const ROUNDS: usize = 30; + + let mut failures: Vec = Vec::new(); + for _ in 0..ROUNDS { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.sqlite3"); + let start = std::sync::Barrier::new(OPENERS); + + failures.extend(std::thread::scope(|scope| { + let handles: Vec<_> = (0..OPENERS) + .map(|_| { + let path = path.clone(); + let start = &start; + scope.spawn(move || { + let conn = Connection::open(&path); + start.wait(); + let conn = conn?; + apply_pragmas(&conn)?; + let mode: String = + conn.query_row("PRAGMA journal_mode", [], |r| r.get(0))?; + anyhow::ensure!(mode == "wal", "journal_mode is {mode}, not wal"); + anyhow::Ok(()) + }) + }) + .collect(); + handles + .into_iter() + .filter_map(|h| h.join().expect("thread should not panic").err()) + .map(|e| format!("{e:#}")) + .collect::>() + })); + } + + assert!( + failures.is_empty(), + "every simultaneous opener must reach WAL, got: {failures:#?}" + ); + } + + #[test] + fn test_wal_retry_expires_and_recovers_after_lock_release() { + const RETRY_TIMEOUT: Duration = Duration::from_millis(30); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.sqlite3"); + let mut holder = Connection::open(&path).unwrap(); + holder + .execute_batch("CREATE TABLE lock_test (id INTEGER)") + .unwrap(); + let conn = Connection::open(&path).unwrap(); + conn.busy_timeout(Duration::ZERO).unwrap(); + let tx = holder + .transaction_with_behavior(TransactionBehavior::Exclusive) + .unwrap(); + + let started = Instant::now(); + let error = enable_wal(&conn, RETRY_TIMEOUT).unwrap_err(); + assert_eq!(error.sqlite_error_code(), Some(ErrorCode::DatabaseBusy)); + assert!(started.elapsed() >= RETRY_TIMEOUT); + + tx.commit().unwrap(); + apply_pragmas(&conn).unwrap(); + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + assert_eq!(mode, "wal"); + } + #[test] fn test_downgrade_is_refused_rather_than_silently_accepted() { let mut conn = Connection::open_in_memory().unwrap(); diff --git a/src/auth/scope.rs b/src/auth/scope.rs index 0ff76578..be7d4340 100644 --- a/src/auth/scope.rs +++ b/src/auth/scope.rs @@ -99,6 +99,11 @@ pub static ROUTE_RULES: &[RouteRule] = &[ read("GET", "/api/v1/status"), read("GET", "/api/v1/sections"), read("GET", "/api/v1/integrations"), + // --- Setup -------------------------------------------------------------- + read("GET", "/api/v1/setup/status"), + read("GET", "/api/v1/setup/steps"), + read("GET", "/api/v1/setup/collections"), + admin("POST", "/api/v1/setup/initialize"), // --- Issue types -------------------------------------------------------- read("GET", "/api/v1/issuetypes"), write("POST", "/api/v1/issuetypes"), @@ -160,6 +165,11 @@ pub static ROUTE_RULES: &[RouteRule] = &[ // Writing provider config and setting process env are administration. admin("PUT", "/api/v1/kanban/config"), admin("POST", "/api/v1/kanban/session-env"), + // --- Git onboarding ----------------------------------------------------- + read("GET", "/api/v1/git/providers"), + execute("POST", "/api/v1/git/validate"), + admin("PUT", "/api/v1/git/config"), + admin("POST", "/api/v1/git/session-env"), // --- Skills / LLM tools ------------------------------------------------- read("GET", "/api/v1/skills"), read("GET", "/api/v1/llm-tools"), diff --git a/src/config.rs b/src/config.rs index 908f96e7..3a157da5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -213,6 +213,9 @@ pub struct LaunchConfig { pub confirm_autonomous: bool, pub confirm_paired: bool, pub launch_delay_ms: u64, + /// Default named execution target. Per-launch and per-delegator choices take precedence. + #[serde(default)] + pub target: Option, /// Docker execution configuration #[serde(default)] pub docker: DockerConfig, @@ -456,7 +459,19 @@ fn default_acp_max_sessions() -> usize { } /// Predefined issue type collections -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + TS, + utoipa::ToSchema, +)] #[ts(export)] #[serde(rename_all = "snake_case")] pub enum CollectionPreset { @@ -683,11 +698,17 @@ fn env_source() -> config::Environment { } impl Config { - /// Path to the operator config file within .tickets/ + /// Bootstrap-only config location, relative to cwd. `load()` needs a path + /// before a `Config` exists; everything else uses `operator_config_path_for`. pub fn operator_config_path() -> PathBuf { PathBuf::from(".tickets/operator/config.toml") } + /// Where this config persists: derived from `paths.state`, not the cwd. + pub fn operator_config_path_for(&self) -> PathBuf { + self.state_path().join("config.toml") + } + pub fn load(config_path: Option<&str>) -> Result { // Start with embedded defaults so operator works without config files let defaults = Config::default(); @@ -761,10 +782,11 @@ impl Config { Ok(cfg) } - /// Save config to .tickets/operator/config.toml + /// Save config to `paths.state`/config.toml. pub fn save(&self) -> Result<()> { + validate_targets(self)?; crate::git::identity::validate_config(self)?; - let config_path = Self::operator_config_path(); + let config_path = self.operator_config_path_for(); // Ensure parent directory exists if let Some(parent) = config_path.parent() { @@ -775,9 +797,7 @@ impl Config { let toml_str = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?; - // Write to a sibling temp file and rename over the target. A plain - // write truncates first, so a crash or a full disk mid-write leaves a - // half-written config.toml that will not parse. prevents startup failure later + // Write to a sibling temp file and rename over the target. prevents startup failure later let temp_path = config_path.with_extension(format!("toml.tmp.{}", uuid::Uuid::new_v4())); std::fs::write(&temp_path, toml_str).context("Failed to write config file")?; if let Err(e) = std::fs::rename(&temp_path, &config_path) { @@ -867,15 +887,6 @@ impl Config { pub fn discover_projects(&self) -> Vec { crate::projects::discover_projects(&self.projects_path()) } - - /// Discover projects with full git and LLM tool information - /// - /// Returns projects found by scanning for .git directories and LLM marker files. - /// Each project includes git repo info (remote URL, default branch, GitHub info) - /// and a list of available LLM tools. - pub fn discover_projects_full(&self) -> Vec { - crate::projects::discover_projects_with_git(&self.projects_path()) - } } impl Default for Config { @@ -920,6 +931,7 @@ impl Default for Config { confirm_autonomous: true, confirm_paired: true, launch_delay_ms: 2000, + target: None, docker: DockerConfig::default(), yolo: YoloConfig::default(), }, @@ -1018,6 +1030,43 @@ mod tests { let cfg = config_from_env(&[("OPERATOR_REST_API__HOST", "0.0.0.0")]); assert_eq!(cfg.rest_api.port, default_rest_port()); } + + // --- Save destination --- + + #[test] + fn test_operator_config_path_for_derives_from_state_path() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.paths.state = dir.path().to_string_lossy().to_string(); + + assert_eq!( + cfg.operator_config_path_for(), + dir.path().join("config.toml") + ); + } + + #[test] + fn test_operator_config_path_for_matches_legacy_path_on_defaults() { + let cfg = Config::default(); + let cwd = std::env::current_dir().unwrap(); + + assert_eq!( + cfg.operator_config_path_for(), + cwd.join(Config::operator_config_path()) + ); + } + + #[test] + fn test_save_writes_under_paths_state_not_cwd() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.paths.state = dir.path().join("state").to_string_lossy().to_string(); + + cfg.save().unwrap(); + + assert!(dir.path().join("state/config.toml").exists()); + assert!(!dir.path().join(".tickets/operator/config.toml").exists()); + } } #[cfg(test)] diff --git a/src/config/sessions.rs b/src/config/sessions.rs index 7cb73906..1e456e52 100644 --- a/src/config/sessions.rs +++ b/src/config/sessions.rs @@ -3,7 +3,19 @@ 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)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + JsonSchema, + TS, + utoipa::ToSchema, +)] #[serde(rename_all = "lowercase")] #[ts(export)] pub enum SessionWrapperType { diff --git a/src/config/targets.rs b/src/config/targets.rs index 721113d7..377a524a 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -18,6 +18,11 @@ use super::DockerConfig; pub const TARGET_LOCAL: &str = "local"; /// Reserved target name synthesized from `[launch.docker]`. pub const TARGET_DOCKER: &str = "docker"; +pub const DEFAULT_CODER_TARGET_NAME: &str = "coder-agents"; +pub const DEFAULT_CODER_URL_ENV: &str = "CODER_URL"; +pub const DEFAULT_CODER_TOKEN_ENV: &str = "CODER_SESSION_TOKEN"; +const DEFAULT_CODER_NAME_PREFIX: &str = "op"; +const DEFAULT_CODER_CREATE_TIMEOUT_SECS: u64 = 300; /// A named execution target agents can be launched on. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] @@ -154,20 +159,36 @@ pub struct CoderConfig { pub parameters: std::collections::HashMap, } +impl Default for CoderConfig { + fn default() -> Self { + Self { + template: String::new(), + url_env: default_coder_url_env(), + token_env: default_coder_token_env(), + name_prefix: default_coder_name_prefix(), + workdir: None, + stop_on_complete: true, + create_timeout_secs: default_coder_create_timeout_secs(), + callback_url: None, + parameters: std::collections::HashMap::new(), + } + } +} + fn default_coder_url_env() -> String { - "CODER_URL".to_string() + DEFAULT_CODER_URL_ENV.to_string() } fn default_coder_token_env() -> String { - "CODER_SESSION_TOKEN".to_string() + DEFAULT_CODER_TOKEN_ENV.to_string() } fn default_coder_name_prefix() -> String { - "op".to_string() + DEFAULT_CODER_NAME_PREFIX.to_string() } fn default_coder_create_timeout_secs() -> u64 { - 300 + DEFAULT_CODER_CREATE_TIMEOUT_SECS } fn default_true() -> bool { @@ -204,6 +225,27 @@ pub fn validate_targets(config: &super::Config) -> anyhow::Result<()> { ); } } + if let TargetKind::Coder(coder) = &target.kind { + if coder.template.trim().is_empty() { + anyhow::bail!("Coder target '{}' requires a template", target.name); + } + if coder.url_env.trim().is_empty() || coder.token_env.trim().is_empty() { + anyhow::bail!( + "Coder target '{}' requires url_env and token_env names", + target.name + ); + } + } + } + + if let Some(name) = &config.launch.target { + if !known_target_name(config, name) { + anyhow::bail!( + "launch.target references unknown target '{}' (known: {})", + name, + known_target_names(config).join(", ") + ); + } } for delegator in &config.delegators { @@ -443,6 +485,17 @@ create_timeout_secs = 600 } } + fn coder_def(template: &str) -> TargetDef { + TargetDef { + name: "coder-agents".to_string(), + display_name: None, + kind: TargetKind::Coder(CoderConfig { + template: template.to_string(), + ..Default::default() + }), + } + } + #[test] fn test_validate_targets_duplicate_names_error() { let config = config_with_targets(vec![ssh_def("a"), ssh_def("a")]); @@ -473,6 +526,22 @@ create_timeout_secs = 600 assert!(err.contains("collides"), "{err}"); } + #[test] + fn test_validate_targets_rejects_incomplete_coder_target() { + let config = config_with_targets(vec![coder_def(" ")]); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("requires a template"), "{err}"); + + let mut target = coder_def("operator-agent"); + let TargetKind::Coder(coder) = &mut target.kind else { + unreachable!(); + }; + coder.token_env.clear(); + let config = config_with_targets(vec![target]); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("requires url_env and token_env"), "{err}"); + } + #[test] fn test_validate_targets_unknown_delegator_reference_error() { let mut config = config_with_targets(vec![]); @@ -501,6 +570,15 @@ create_timeout_secs = 600 ); } + #[test] + fn test_validate_targets_unknown_global_reference_error() { + let mut config = config_with_targets(vec![]); + config.launch.target = Some("nope".to_string()); + let err = validate_targets(&config).unwrap_err().to_string(); + assert!(err.contains("launch.target"), "{err}"); + assert!(err.contains("unknown target 'nope'"), "{err}"); + } + #[test] fn test_validate_targets_builtin_and_host_references_ok() { let mut config = config_with_targets(vec![ssh_def("gpu-vm")]); diff --git a/src/docs_gen/startup.rs b/src/docs_gen/startup.rs index 04524689..f729f32e 100644 --- a/src/docs_gen/startup.rs +++ b/src/docs_gen/startup.rs @@ -2,7 +2,7 @@ use super::markdown::{heading, table}; use super::{format_header, DocGenerator}; -use crate::startup::SETUP_STEPS; +use crate::startup::steps::setup_steps; use anyhow::Result; /// Generates setup wizard documentation from the startup step registry @@ -14,7 +14,7 @@ impl DocGenerator for StartupDocGenerator { } fn source(&self) -> &'static str { - "src/startup/mod.rs" + "src/startup/steps.rs" } fn output_path(&self) -> &'static str { @@ -37,7 +37,7 @@ impl DocGenerator for StartupDocGenerator { output.push_str(&heading(2, "Step Details")); output.push('\n'); - for (i, step) in SETUP_STEPS.iter().enumerate() { + for (i, step) in setup_steps().iter().enumerate() { output.push_str(&heading(3, &format!("{}. {}", i + 1, step.name))); output.push_str(&format!("*{}*\n\n", step.description)); output.push_str(step.help_text); @@ -68,7 +68,7 @@ impl DocGenerator for StartupDocGenerator { impl StartupDocGenerator { fn generate_overview_table(&self) -> String { let headers = &["Step", "Name", "Description"]; - let rows: Vec> = SETUP_STEPS + let rows: Vec> = setup_steps() .iter() .enumerate() .map(|(i, step)| { @@ -95,7 +95,7 @@ mod tests { // Should have the auto-generated header assert!(result.contains("AUTO-GENERATED FROM")); - assert!(result.contains("startup/mod.rs")); + assert!(result.contains("startup/steps.rs")); // Should have the main heading assert!(result.contains("title: \"Setup Wizard\"")); @@ -117,7 +117,7 @@ mod tests { let result = generator.generate().unwrap(); // Check that all step names appear in the documentation - for step in SETUP_STEPS { + for step in setup_steps() { assert!( result.contains(step.name), "Step '{}' should be documented", @@ -134,7 +134,7 @@ mod tests { // Count table rows (lines starting with |) let row_count = overview.lines().filter(|l| l.starts_with('|')).count(); // Should have header + separator + all steps - assert_eq!(row_count, SETUP_STEPS.len() + 2); + assert_eq!(row_count, setup_steps().len() + 2); } #[test] @@ -143,7 +143,7 @@ mod tests { let result = generator.generate().unwrap(); // Check that numbered headings exist - for i in 1..=SETUP_STEPS.len() { + for i in 1..=setup_steps().len() { assert!( result.contains(&format!("### {i}.")), "Step {i} should be numbered" diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index a8c5efc2..e28ac7c6 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -432,6 +432,21 @@ pub fn all_integrations() -> Vec { } /// Find the catalog entry for a `(vertical, slug)` pair, if present. +/// Entries a first-run wizard may offer for this vertical. +/// +/// `Alpha`+ and documented: onboarding links out to each provider's page, and +/// `Proto` entries are by definition not advertised. This is what keeps the TUI +/// wizard, the web wizard and the docs offering the same providers - promoting +/// one is a status bump here, not an edit in each surface. +pub fn onboardable(vertical: Vertical) -> Vec { + all_integrations() + .into_iter() + .filter(|e| { + e.vertical == vertical && e.status >= SupportStatus::Alpha && e.docs_path.is_some() + }) + .collect() +} + pub fn entry_for(vertical: Vertical, slug: &str) -> Option { all_integrations() .into_iter() @@ -530,4 +545,59 @@ mod tests { assert_eq!(jira.status, SupportStatus::Beta); assert!(entry_for(Vertical::Kanban, "nope").is_none()); } + + // --- Onboarding surface --- + + fn slugs(vertical: Vertical) -> Vec<&'static str> { + onboardable(vertical).iter().map(|e| e.slug).collect() + } + + #[test] + fn test_onboardable_git_is_the_alpha_or_better_set() { + assert_eq!(slugs(Vertical::Git), vec!["github", "gitlab", "gitea"]); + } + + #[test] + fn test_onboardable_model_excludes_proto_entries() { + let model = slugs(Vertical::Model); + assert!(model.contains(&"anthropic-api")); + assert!(model.contains(&"ollama")); + assert!(!model.contains(&"openai-compat"), "openai-compat is Proto"); + assert!(!model.contains(&"lmstudio"), "lmstudio is Proto"); + } + + /// Onboarding links out to each provider's page, so an entry without docs + /// must never reach a wizard. + #[test] + fn test_onboardable_entries_are_all_documented() { + for vertical in Vertical::ALL { + for entry in onboardable(vertical) { + assert!( + entry.docs_path.is_some(), + "{}/{} is offered for onboarding but has no docs page", + vertical.slug(), + entry.slug + ); + } + } + } + + #[test] + fn test_onboardable_never_includes_proto() { + for vertical in Vertical::ALL { + for entry in onboardable(vertical) { + assert!(entry.status >= SupportStatus::Alpha, "{}", entry.slug); + } + } + } + + #[test] + fn test_onboardable_preserves_catalog_order() { + let all: Vec<&str> = all_integrations() + .iter() + .filter(|e| e.vertical == Vertical::Model && e.status >= SupportStatus::Alpha) + .map(|e| e.slug) + .collect(); + assert_eq!(slugs(Vertical::Model), all); + } } diff --git a/src/lib.rs b/src/lib.rs index 5cdbd8b6..18618063 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,8 @@ pub mod editors; pub mod git; pub mod queue; pub mod rest; +pub mod setup; +pub mod startup; pub mod state; pub mod types; @@ -24,9 +26,8 @@ mod llm; mod notifications; mod permissions; mod pr_config; -mod projects; -mod services; -mod startup; +pub mod projects; +pub mod services; mod steps; #[allow(dead_code)] pub mod taxonomy; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index fa11691a..ce36ea6c 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -17,3 +17,30 @@ pub use detection::verify_tool_health; #[allow(unused_imports)] // Used by main.rs binary pub use detection::{detect_all_tools, refresh_tool_detection}; pub use skill_deployer::deploy_skills; + +/// Refresh cached tool detection and persist only when the detected state changed. +#[allow(dead_code)] // Shared by the binary's TUI and API entry points. +pub fn refresh_config_detection(config: &mut crate::config::Config) -> bool { + let refreshed = refresh_tool_detection(&config.llm_tools); + let changed = + serde_json::to_value(&refreshed).ok() != serde_json::to_value(&config.llm_tools).ok(); + config.llm_tools = refreshed; + + for tool in &config.llm_tools.detected { + tracing::info!( + tool = %tool.name, + version = %tool.version, + path = %tool.path, + "LLM tool detected" + ); + } + for provider in &config.llm_tools.providers { + tracing::debug!(tool = %provider.tool, model = %provider.model, "LLM provider available"); + } + if changed { + if let Err(error) = config.save() { + tracing::warn!(%error, "Failed to save LLM detection results"); + } + } + changed +} diff --git a/src/llm/tools/tool_config.schema.json b/src/llm/tools/tool_config.schema.json index 3cf9d4c7..37a475e9 100644 --- a/src/llm/tools/tool_config.schema.json +++ b/src/llm/tools/tool_config.schema.json @@ -161,7 +161,7 @@ "type": "string", "enum": ["which", "always"], "default": "which", - "description": "\"which\" gates detection on the binary being found in PATH, and that verified presence earns the tool its health. \"always\" skips the PATH lookup and uses tool_name verbatim as the invocation path — for tools not installed locally (e.g. invoked over SSH) or binaries without a stable PATH entry; because nothing is locally verifiable, an always-mode tool is unhealthy until a health_command passes." + "description": "\"which\" gates detection on the binary being found in PATH, and that verified presence earns the tool its health. \"always\" skips the PATH lookup and uses tool_name verbatim as the invocation path - for tools not installed locally (e.g. invoked over SSH) or binaries without a stable PATH entry; because nothing is locally verifiable, an always-mode tool is unhealthy until a health_command passes." }, "health_command": { "type": "string", diff --git a/src/main.rs b/src/main.rs index 1b3258a7..a103dde1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1070,6 +1070,8 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R } async fn cmd_api(config: &Config, port: Option, open: bool) -> Result<()> { + let mut config = config.clone(); + crate::llm::refresh_config_detection(&mut config); let port = port.unwrap_or(config.rest_api.port); println!("Starting REST API server..."); @@ -1219,6 +1221,7 @@ fn cmd_setup( println!(); let result = initialize_workspace(&mut config, &options)?; + config.save()?; // Report results if !result.directories_created.is_empty() { diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index e445c453..4bd588b4 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -507,6 +507,7 @@ pub struct LaunchConfiguration { pub confirm_autonomous: bool, pub confirm_paired: bool, pub launch_delay_ms: u64, + pub target: Option, pub docker_enabled: bool, pub docker_image: String, pub yolo_enabled: bool, @@ -616,6 +617,7 @@ pub struct LaunchConfigurationPatch { pub confirm_autonomous: Option, pub confirm_paired: Option, pub launch_delay_ms: Option, + pub target: Option, pub docker_enabled: Option, pub docker_image: Option, pub yolo_enabled: Option, @@ -627,6 +629,7 @@ impl LaunchConfigurationPatch { self.confirm_autonomous.is_none() && self.confirm_paired.is_none() && self.launch_delay_ms.is_none() + && self.target.is_none() && self.docker_enabled.is_none() && self.docker_image.is_none() && self.yolo_enabled.is_none() diff --git a/src/rest/dto/git_onboarding.rs b/src/rest/dto/git_onboarding.rs new file mode 100644 index 00000000..9ac375de --- /dev/null +++ b/src/rest/dto/git_onboarding.rs @@ -0,0 +1,77 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum GitOnboardingState { + CliMissing, + TokenRequired, + Authenticated, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct GitProviderOnboardingResponse { + pub slug: String, + pub label: String, + pub docs_url: String, + pub configured: bool, + pub command: String, + pub token_env: String, + pub state: GitOnboardingState, + pub action_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateGitTokenRequest { + pub provider: String, + #[schema(write_only, format = Password)] + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ValidateGitTokenResponse { + pub valid: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteGitConfigRequest { + pub provider: String, + pub token_env: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WriteGitConfigResponse { + pub provider: String, + pub token_env: String, + pub shell_export_block: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetGitSessionEnvRequest { + pub provider: String, + #[schema(write_only, format = Password)] + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetGitSessionEnvResponse { + pub shell_export_block: String, +} diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index ed1119ba..a60436f1 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -10,20 +10,24 @@ pub mod agents; pub mod auth; pub mod configuration; +pub mod git_onboarding; pub mod integrations; pub mod issue_types; pub mod kanban; pub mod sections; +pub mod setup; pub mod tickets; pub mod workflow; pub use agents::*; pub use auth::*; pub use configuration::*; +pub use git_onboarding::*; pub use integrations::*; pub use issue_types::*; pub use kanban::*; pub use sections::*; +pub use setup::*; pub use tickets::*; pub use workflow::*; diff --git a/src/rest/dto/setup.rs b/src/rest/dto/setup.rs new file mode 100644 index 00000000..dfef9aba --- /dev/null +++ b/src/rest/dto/setup.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::config::{CollectionPreset, SessionWrapperType}; +use crate::startup::steps::SetupStep; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupStatusResponse { + pub initialized: bool, + pub admin_configured: bool, + pub config_path: String, + pub tickets_path: String, + pub projects_by_tool: HashMap>, + pub default_acceptance_criteria: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupStepResponse { + pub slug: SetupStep, + pub name: String, + pub description: String, + pub help_text: String, + pub order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupCollectionResponse { + pub id: String, + pub name: String, + pub description: String, + pub types: Vec, + pub default_selected: Vec, + pub origin: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + pub checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct HostedCollectionSelection { + pub id: String, + pub checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "lowercase")] +#[ts(export)] +pub enum SetupExecutionTarget { + Local, + Coder { name: String, template: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupInitializeRequest { + pub preset: CollectionPreset, + #[serde(default)] + pub task_fields: Vec, + pub wrapper: SessionWrapperType, + pub execution_target: SetupExecutionTarget, + #[serde(default)] + pub use_worktrees: bool, + pub acceptance_criteria: String, + #[serde(default)] + pub model_servers: Vec, + #[serde(default)] + pub hosted_collections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SetupInitializeResponse { + pub initialized: bool, + pub config_path: String, + pub tickets_path: String, + pub files_created: Vec, + pub files_skipped: Vec, + pub projects: Vec, +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index dd9ede1b..2acffa70 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -94,6 +94,18 @@ fn auth_router() -> OpenApiRouter { .routes(routes!(routes::auth::revoke_access_key)) } +fn first_run_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(routes::setup::status)) + .routes(routes!(routes::setup::steps)) + .routes(routes!(routes::setup::collections)) + .routes(routes!(routes::setup::initialize)) + .routes(routes!(routes::git_onboarding::providers)) + .routes(routes!(routes::git_onboarding::validate)) + .routes(routes!(routes::git_onboarding::write_config)) + .routes(routes!(routes::git_onboarding::set_session_env)) +} + /// Build the documented API surface as a `utoipa_axum::OpenApiRouter`. /// /// Every always-on route is mounted here via `routes!`, so mounting a route @@ -115,6 +127,8 @@ fn documented_router() -> OpenApiRouter { .routes(routes!(routes::sections::list)) // Vertical integration catalog + support status .routes(routes!(routes::integrations::catalog)) + // First-run setup + .merge(first_run_router()) // Issue type endpoints .routes(routes!( routes::issuetypes::list, diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 862a3b2d..cb54b63c 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -29,9 +29,10 @@ use crate::rest::dto::{ DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, DeviceApprovalRequest, DeviceApprovalResponse, DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceSummary, ExternalIssueTypeSummary, FieldResponse, ForgotPasswordRequest, ForgotPasswordResponse, - HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, - KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, - KanbanTicketCard, LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, + GitOnboardingState, GitProviderOnboardingResponse, HealthResponse, HostedCollectionSelection, + IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, + KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, + LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, ListKanbanStatusesRequest, ListKanbanStatusesResponse, LoginRequest, LoginResponse, LogoutResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, NextStepInfo, @@ -39,13 +40,16 @@ use crate::rest::dto::{ QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ResetPasswordRequest, ResetPasswordResponse, ReviewResponse, RevokeAccessKeyResponse, Scope, SectionDto, SectionRowDto, SessionListResponse, SessionSummary, SetDefaultLlmRequest, - SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, - StatusResponse, StepCompleteRequest, StepCompleteResponse, StepResponse, - SyncKanbanIssueTypesResponse, TicketDetailResponse, TokenRequest, TokenResponse, + SetGitSessionEnvRequest, SetGitSessionEnvResponse, SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, SetupCollectionResponse, SetupExecutionTarget, + SetupInitializeRequest, SetupInitializeResponse, SetupStatusResponse, SetupStepResponse, + SkillEntry, SkillsResponse, StatusResponse, StepCompleteRequest, StepCompleteResponse, + StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, TokenRequest, TokenResponse, UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, - UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, - ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, - WorkflowPreviewResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, + UpdateTicketStatusResponse, ValidateGitTokenRequest, ValidateGitTokenResponse, + ValidateKanbanCredentialsRequest, ValidateKanbanCredentialsResponse, WorkflowExportResponse, + WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, WriteGitConfigRequest, + WriteGitConfigResponse, WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; // AgentProfile interchange types live in `crate::config`, not `rest::dto`. use crate::config::{AgentProfile, DelegatorLaunchConfig, RemoteAgentRef, XOperator}; @@ -205,6 +209,26 @@ use crate::rest::error::ErrorResponse; AccessKeySummary, AccessKeyListResponse, RevokeAccessKeyResponse, + // Setup + SetupStatusResponse, + SetupStepResponse, + SetupCollectionResponse, + HostedCollectionSelection, + SetupExecutionTarget, + SetupInitializeRequest, + SetupInitializeResponse, + crate::startup::steps::SetupStep, + crate::config::CollectionPreset, + crate::config::SessionWrapperType, + // Git onboarding + GitOnboardingState, + GitProviderOnboardingResponse, + ValidateGitTokenRequest, + ValidateGitTokenResponse, + WriteGitConfigRequest, + WriteGitConfigResponse, + SetGitSessionEnvRequest, + SetGitSessionEnvResponse, ) ), modifiers(&SecurityAddon), @@ -231,6 +255,8 @@ use crate::rest::error::ErrorResponse; (name = "Configuration", description = "Operator configuration read/write"), (name = "Kanban", description = "Kanban provider issue types and onboarding"), (name = "Auth", description = "Bootstrap, sessions, OAuth device flow, and access keys"), + (name = "Setup", description = "First-run workspace initialization"), + (name = "Git", description = "Git provider onboarding"), ) )] pub struct ApiDoc; diff --git a/src/rest/routes/collections.rs b/src/rest/routes/collections.rs index 2c38b374..77248022 100644 --- a/src/rest/routes/collections.rs +++ b/src/rest/routes/collections.rs @@ -103,9 +103,20 @@ pub async fn activate( State(state): State, Path(name): Path, ) -> Result, ApiError> { - let mut registry = state.registry.write().await; + if state.registry.read().await.get_collection(&name).is_none() { + return Err(ApiError::NotFound(format!("Collection '{name}' not found"))); + } + state + .mutate_config({ + let name = name.clone(); + move |config| { + config.templates.active_collection = Some(name); + Ok(()) + } + }) + .await?; - // Activate the collection + let mut registry = state.registry.write().await; registry .activate_collection(&name) .map_err(|e| ApiError::NotFound(format!("Failed to activate collection: {e}")))?; @@ -124,10 +135,12 @@ mod tests { use crate::config::Config; fn make_state() -> ApiState { - let config = Config::default(); - // Use a unique temp directory for each test to avoid state pollution + let mut config = Config::default(); let temp_dir = tempfile::TempDir::new().unwrap(); - ApiState::new(config, temp_dir.keep()) + let root = temp_dir.keep(); + config.paths.tickets = root.join("tickets").display().to_string(); + config.paths.state = root.join("state").display().to_string(); + ApiState::new(config, root.join("tickets")) } #[tokio::test] @@ -183,5 +196,11 @@ mod tests { // Verify it's now active let registry = state.registry.read().await; assert_eq!(registry.active_collection_name(), "simple"); + assert_eq!( + state.config().templates.active_collection.as_deref(), + Some("simple") + ); + let saved = std::fs::read_to_string(state.config().operator_config_path_for()).unwrap(); + assert!(saved.contains("active_collection = \"simple\"")); } } diff --git a/src/rest/routes/configuration.rs b/src/rest/routes/configuration.rs index 7aa0eb59..429a609c 100644 --- a/src/rest/routes/configuration.rs +++ b/src/rest/routes/configuration.rs @@ -44,6 +44,7 @@ fn response(config: &Config) -> ConfigurationResponse { confirm_autonomous: config.launch.confirm_autonomous, confirm_paired: config.launch.confirm_paired, launch_delay_ms: config.launch.launch_delay_ms, + target: config.launch.target.clone(), docker_enabled: config.launch.docker.enabled, docker_image: config.launch.docker.image.clone(), yolo_enabled: config.launch.yolo.enabled, @@ -134,6 +135,9 @@ fn apply_patch(config: &mut Config, patch: UpdateConfigurationRequest) { if let Some(value) = launch.launch_delay_ms { config.launch.launch_delay_ms = value; } + if let Some(value) = launch.target { + config.launch.target = Some(value); + } if let Some(value) = launch.docker_enabled { config.launch.docker.enabled = value; } @@ -191,6 +195,8 @@ pub async fn patch_config( let updated = state .mutate_config(move |config| { apply_patch(config, patch); + crate::config::validate_targets(config) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; Ok(response(config)) }) .await?; diff --git a/src/rest/routes/git_onboarding.rs b/src/rest/routes/git_onboarding.rs new file mode 100644 index 00000000..76c65fa4 --- /dev/null +++ b/src/rest/routes/git_onboarding.rs @@ -0,0 +1,234 @@ +use axum::{extract::State, Json}; + +use crate::integrations::catalog::{onboardable, Vertical}; +use crate::rest::dto::{ + GitOnboardingState, GitProviderOnboardingResponse, SetGitSessionEnvRequest, + SetGitSessionEnvResponse, ValidateGitTokenRequest, ValidateGitTokenResponse, + WriteGitConfigRequest, WriteGitConfigResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; +use crate::services::git_onboarding::{ + self, configure_git_provider, resolve_onboarding_with_config, OnboardingStep, +}; + +fn provider_is_offered(provider: &str) -> bool { + onboardable(Vertical::Git) + .iter() + .any(|entry| entry.slug == provider) +} + +fn validate_env_name(name: &str) -> Result<(), ApiError> { + let mut chars = name.chars(); + let valid_first = chars + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()); + if !valid_first || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + return Err(ApiError::ValidationError(format!( + "'{name}' is not a valid environment variable name" + ))); + } + Ok(()) +} + +#[utoipa::path( + get, + path = "/api/v1/git/providers", + tag = "Git", + operation_id = "git_providers", + responses((status = 200, body = [GitProviderOnboardingResponse])) +)] +pub async fn providers( + State(state): State, +) -> Result>, ApiError> { + let config = state.config(); + tokio::task::spawn_blocking(move || { + onboardable(Vertical::Git) + .into_iter() + .filter_map(|entry| { + let step = resolve_onboarding_with_config(&config, entry.slug)?; + let token_env = git_onboarding::token_env_for(&config, entry.slug)?; + let command = crate::api::cli_detection::onboarding_spec_for_slug(entry.slug)? + .command + .to_string(); + let configured = git_onboarding::provider_is_configured(&config, entry.slug); + let (state, action_url, username) = match step { + OnboardingStep::InstallCli { install_url, .. } => { + (GitOnboardingState::CliMissing, install_url, None) + } + OnboardingStep::CollectToken { pat_url, .. } => { + (GitOnboardingState::TokenRequired, pat_url, None) + } + OnboardingStep::AutoConfigured { username, .. } => ( + GitOnboardingState::Authenticated, + entry.docs_url().unwrap_or_default(), + Some(username), + ), + }; + Some(GitProviderOnboardingResponse { + slug: entry.slug.to_string(), + label: entry.label.to_string(), + docs_url: entry.docs_url().unwrap_or_default(), + configured, + command, + token_env, + state, + action_url, + username, + }) + }) + .collect() + }) + .await + .map(Json) + .map_err(|error| ApiError::InternalError(format!("Git detection task failed: {error}"))) +} + +#[utoipa::path( + post, + path = "/api/v1/git/validate", + tag = "Git", + operation_id = "git_validate", + request_body = ValidateGitTokenRequest, + responses((status = 200, body = ValidateGitTokenResponse)) +)] +pub async fn validate( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + if !provider_is_offered(&request.provider) { + return Err(ApiError::ValidationError(format!( + "Git provider '{}' is not available for onboarding", + request.provider + ))); + } + let config = state.config(); + let provider = request.provider; + let token = request.token; + let result = tokio::task::spawn_blocking(move || { + git_onboarding::validate_token_with_config(&config, &provider, &token) + }) + .await + .map_err(|error| ApiError::InternalError(format!("Git validation task failed: {error}")))?; + Ok(Json(match result { + Ok(username) => ValidateGitTokenResponse { + valid: true, + username: Some(username), + error: None, + }, + Err(error) => ValidateGitTokenResponse { + valid: false, + username: None, + error: Some(error.to_string()), + }, + })) +} + +#[utoipa::path( + put, + path = "/api/v1/git/config", + tag = "Git", + operation_id = "git_write_config", + request_body = WriteGitConfigRequest, + responses((status = 200, body = WriteGitConfigResponse)) +)] +pub async fn write_config( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + if !provider_is_offered(&request.provider) { + return Err(ApiError::ValidationError(format!( + "Git provider '{}' is not available for onboarding", + request.provider + ))); + } + validate_env_name(&request.token_env)?; + + let detection_config = state.config(); + let detection_provider = request.provider.clone(); + let adopted = tokio::task::spawn_blocking(move || { + match resolve_onboarding_with_config(&detection_config, &detection_provider) { + Some(OnboardingStep::AutoConfigured { + username, token, .. + }) => Some((username, token)), + _ => None, + } + }) + .await + .map_err(|error| ApiError::InternalError(format!("Git detection task failed: {error}")))?; + + if let Some((_, token)) = &adopted { + std::env::set_var(&request.token_env, token); + } + let provider = request.provider; + let token_env = request.token_env; + let username = adopted.map(|(username, _)| username); + let response = state + .mutate_config(move |config| { + configure_git_provider(config, &provider, &token_env) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + Ok(WriteGitConfigResponse { + provider, + shell_export_block: git_onboarding::shell_export_block(&token_env), + token_env, + username, + }) + }) + .await?; + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/api/v1/git/session-env", + tag = "Git", + operation_id = "git_set_session_env", + request_body = SetGitSessionEnvRequest, + responses((status = 200, body = SetGitSessionEnvResponse)) +)] +pub async fn set_session_env( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + if !provider_is_offered(&request.provider) { + return Err(ApiError::ValidationError(format!( + "Git provider '{}' is not available for onboarding", + request.provider + ))); + } + let token_env = git_onboarding::token_env_for(&state.config(), &request.provider) + .ok_or_else(|| ApiError::ValidationError("Unsupported git provider".to_string()))?; + validate_env_name(&token_env)?; + tokio::task::spawn_blocking({ + let token_env = token_env.clone(); + move || std::env::set_var(token_env, request.token) + }) + .await + .map_err(|error| ApiError::InternalError(format!("Git environment task failed: {error}")))?; + Ok(Json(SetGitSessionEnvResponse { + shell_export_block: git_onboarding::shell_export_block(&token_env), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_validate_env_name_rejects_shell_syntax() { + assert!(validate_env_name("TOKEN;echo").is_err()); + assert!(validate_env_name("9TOKEN").is_err()); + assert!(validate_env_name("TOKEN_NAME").is_ok()); + } + + #[test] + fn test_git_provider_set_matches_catalog() { + let offered: HashSet<_> = onboardable(Vertical::Git) + .into_iter() + .map(|entry| entry.slug) + .collect(); + assert!(offered.iter().all(|slug| provider_is_offered(slug))); + } +} diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index d37fcb20..b4f786f0 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -107,9 +107,7 @@ pub async fn write_config( .mutate_config(move |config| { let section_header = kanban_onboarding::apply_config_request(config, req)?; Ok(WriteKanbanConfigResponse { - written_path: crate::config::Config::operator_config_path() - .display() - .to_string(), + written_path: config.operator_config_path_for().display().to_string(), section_header, }) }) diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 6507ecd0..6603bc3b 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod collections; pub mod configuration; pub mod delegators; +pub mod git_onboarding; pub mod health; pub mod integrations; pub mod issuetypes; @@ -17,6 +18,7 @@ pub mod probes; pub mod projects; pub mod queue; pub mod sections; +pub mod setup; pub mod skills; pub mod steps; pub mod tickets; diff --git a/src/rest/routes/setup.rs b/src/rest/routes/setup.rs new file mode 100644 index 00000000..2555cf23 --- /dev/null +++ b/src/rest/routes/setup.rs @@ -0,0 +1,418 @@ +use std::collections::{HashMap, HashSet}; + +use axum::{extract::State, Json}; + +use crate::api::providers::model_server::ModelServerKind; +use crate::collections::fetch::{CollectionOrigin, ResolvedCollection}; +use crate::config::{CollectionPreset, ModelServer}; +use crate::integrations::catalog::{onboardable, Vertical}; +use crate::rest::dto::{ + SetupCollectionResponse, SetupExecutionTarget, SetupInitializeRequest, SetupInitializeResponse, + SetupStatusResponse, SetupStepResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; +use crate::setup::{initialize_workspace, FetchedCollection, SetupOptions, COMMON_OPTIONAL_FIELDS}; +use crate::startup::steps::SetupStep; + +async fn resolve_collections(config: &crate::config::Config) -> Vec { + let manifest = config + .templates + .collections_fetch_enabled + .then_some(config.templates.collections_manifest_url.as_deref()) + .flatten(); + crate::collections::fetch::resolve_for_setup( + manifest, + config.templates.collections_fetch_timeout_secs, + ) + .await +} + +#[utoipa::path( + get, + path = "/api/v1/setup/status", + tag = "Setup", + operation_id = "setup_status", + responses((status = 200, body = SetupStatusResponse)) +)] +pub async fn status(State(state): State) -> Result, ApiError> { + let config = state.config(); + let projects_path = config.projects_path(); + let store = state.auth.store.clone(); + let (projects_by_tool, admin_configured) = tokio::task::spawn_blocking(move || { + let projects = crate::projects::discover_projects_by_tool(&projects_path); + let configured = store + .bootstrap_state() + .is_ok_and(|status| status != crate::rest::dto::BootstrapState::Uninitialized); + (projects, configured) + }) + .await + .map_err(|error| ApiError::InternalError(format!("Setup status task failed: {error}")))?; + + Ok(Json(SetupStatusResponse { + initialized: crate::startup::workspace_initialized(&config), + admin_configured, + config_path: config.operator_config_path_for().display().to_string(), + tickets_path: config.tickets_path().display().to_string(), + projects_by_tool, + default_acceptance_criteria: include_str!("../../templates/ACCEPTANCE_CRITERIA.md") + .to_string(), + })) +} + +#[utoipa::path( + get, + path = "/api/v1/setup/steps", + tag = "Setup", + operation_id = "setup_steps", + responses((status = 200, body = [SetupStepResponse])) +)] +pub async fn steps() -> Json> { + Json( + SetupStep::ALL + .into_iter() + .enumerate() + .map(|(order, slug)| { + let info = slug.info(); + SetupStepResponse { + slug, + name: info.name.to_string(), + description: info.description.to_string(), + help_text: info.help_text.to_string(), + order, + } + }) + .collect(), + ) +} + +#[utoipa::path( + get, + path = "/api/v1/setup/collections", + tag = "Setup", + operation_id = "setup_collections", + responses((status = 200, body = [SetupCollectionResponse])) +)] +pub async fn collections(State(state): State) -> Json> { + let resolved = resolve_collections(&state.config()).await; + Json( + resolved + .into_iter() + .map(|collection| { + let types = collection.manifest.type_keys(); + let origin = match collection.origin { + CollectionOrigin::Hosted => "hosted", + CollectionOrigin::Embedded => "embedded", + }; + SetupCollectionResponse { + id: collection.manifest.id, + name: collection.manifest.name, + description: collection.manifest.description, + types, + default_selected: collection.manifest.default_selected, + origin: origin.to_string(), + note: collection.note, + checksum: collection.manifest.checksum.unwrap_or_default(), + } + }) + .collect(), + ) +} + +fn validate_task_fields(fields: &[String]) -> Result, ApiError> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for field in fields { + if !COMMON_OPTIONAL_FIELDS.contains(&field.as_str()) { + return Err(ApiError::ValidationError(format!( + "Unknown TASK field '{field}'" + ))); + } + if seen.insert(field.clone()) { + out.push(field.clone()); + } + } + Ok(out) +} + +fn selected_models(slugs: &[String]) -> Result, ApiError> { + let offered: HashSet<&str> = onboardable(Vertical::Model) + .into_iter() + .map(|entry| entry.slug) + .collect(); + let mut seen = HashSet::new(); + let mut servers = Vec::new(); + for slug in slugs { + if !offered.contains(slug.as_str()) || !seen.insert(slug.clone()) { + if seen.contains(slug) { + continue; + } + return Err(ApiError::ValidationError(format!( + "Model provider '{slug}' is not available for onboarding" + ))); + } + let kind = ModelServerKind::from_slug(slug) + .ok_or_else(|| ApiError::ValidationError(format!("Unknown model provider '{slug}'")))?; + if !kind.connectable_from_defaults() { + return Err(ApiError::ValidationError(format!( + "Model provider '{slug}' requires a custom base URL" + ))); + } + servers.push(ModelServer { + name: slug.clone(), + kind: slug.clone(), + base_url: kind.default_base_url().map(str::to_string), + api_key_env: kind.default_api_key_env().map(str::to_string), + extra_env: HashMap::new(), + display_name: Some(kind.display_name().to_string()), + }); + } + Ok(servers) +} + +fn selected_collections( + request: &SetupInitializeRequest, + resolved: Vec, +) -> Result<(Vec, Vec, Option), ApiError> { + if request.preset != CollectionPreset::Custom { + if !request.hosted_collections.is_empty() { + return Err(ApiError::ValidationError( + "Hosted collections require the custom preset".to_string(), + )); + } + return Ok((Vec::new(), Vec::new(), None)); + } + if request.hosted_collections.is_empty() { + return Err(ApiError::ValidationError( + "The custom preset requires at least one hosted collection".to_string(), + )); + } + + let mut fetched = Vec::new(); + let mut issue_types = Vec::new(); + for selected in &request.hosted_collections { + let collection = resolved + .iter() + .find(|candidate| candidate.manifest.id == selected.id) + .ok_or_else(|| { + ApiError::Conflict(format!( + "Collection '{}' is no longer available; reload the collection list", + selected.id + )) + })?; + if collection.manifest.checksum.as_deref() != Some(selected.checksum.as_str()) { + return Err(ApiError::Conflict(format!( + "Collection '{}' changed; reload the collection list", + selected.id + ))); + } + let keys = if collection.manifest.default_selected.is_empty() { + collection.manifest.type_keys() + } else { + collection.manifest.default_selected.clone() + }; + for key in keys { + if !issue_types.contains(&key) { + issue_types.push(key); + } + } + fetched.push(( + collection.manifest.clone(), + collection.files.clone(), + collection.icon_svg.clone(), + )); + } + let active = (fetched.len() == 1).then(|| fetched[0].0.id.clone()); + Ok((fetched, issue_types, active)) +} + +fn selected_execution_target( + target: SetupExecutionTarget, + wrapper: crate::config::SessionWrapperType, +) -> Result { + match target { + SetupExecutionTarget::Local => Ok(crate::config::TargetDef::local()), + SetupExecutionTarget::Coder { name, template } => { + let name = name.trim(); + let template = template.trim(); + if wrapper == crate::config::SessionWrapperType::Zellij { + return Err(ApiError::ValidationError( + "Coder targets cannot use the Zellij session wrapper".to_string(), + )); + } + if name.is_empty() + || matches!( + name, + crate::config::TARGET_LOCAL | crate::config::TARGET_DOCKER + ) + { + return Err(ApiError::ValidationError( + "Coder target name is empty or reserved".to_string(), + )); + } + if template.is_empty() { + return Err(ApiError::ValidationError( + "Coder template is required".to_string(), + )); + } + Ok(crate::config::TargetDef { + name: name.to_string(), + display_name: Some("Coder".to_string()), + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: template.to_string(), + ..Default::default() + }), + }) + } + } +} + +#[utoipa::path( + post, + path = "/api/v1/setup/initialize", + tag = "Setup", + operation_id = "setup_initialize", + request_body = SetupInitializeRequest, + responses( + (status = 200, body = SetupInitializeResponse), + (status = 409, description = "Workspace already initialized") + ) +)] +pub async fn initialize( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let task_fields = validate_task_fields(&request.task_fields)?; + let model_servers = selected_models(&request.model_servers)?; + let resolved = if request.preset == CollectionPreset::Custom { + resolve_collections(&state.config()).await + } else { + Vec::new() + }; + let (hosted_collections, custom_collection, active_collection) = + selected_collections(&request, resolved)?; + let execution_target = selected_execution_target(request.execution_target, request.wrapper)?; + let options = SetupOptions { + preset: request.preset, + task_fields, + use_worktrees: request.use_worktrees, + wrapper: Some(request.wrapper), + execution_target: Some(execution_target), + acceptance_criteria: Some(request.acceptance_criteria), + custom_collection, + active_collection, + hosted_collections, + model_servers, + ..Default::default() + }; + let tickets_path = state.config().tickets_path(); + let result = state + .mutate_config(move |config| { + if crate::startup::workspace_initialized(config) { + return Err(ApiError::Conflict( + "Workspace is already initialized".to_string(), + )); + } + initialize_workspace(config, &options).map_err(ApiError::from) + }) + .await?; + + let registry = crate::startup::templates::load_registry(&tickets_path); + *state.registry.write().await = registry; + + Ok(Json(SetupInitializeResponse { + initialized: true, + config_path: result.config_path.display().to_string(), + tickets_path: tickets_path.display().to_string(), + files_created: result + .files_created + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + files_skipped: result + .files_skipped + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + projects: result + .discovered + .into_iter() + .map(|project| project.name) + .collect(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn initialize_request() -> SetupInitializeRequest { + SetupInitializeRequest { + preset: CollectionPreset::Simple, + task_fields: vec!["priority".to_string()], + wrapper: crate::config::SessionWrapperType::Tmux, + execution_target: SetupExecutionTarget::Local, + use_worktrees: true, + acceptance_criteria: "Ship only when verified.".to_string(), + model_servers: Vec::new(), + hosted_collections: Vec::new(), + } + } + + #[test] + fn test_validate_task_fields_rejects_unknown_field() { + let error = validate_task_fields(&["mystery".to_string()]).unwrap_err(); + assert!(matches!(error, ApiError::ValidationError(_))); + } + + #[test] + fn test_setup_steps_match_catalog_order() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let response = runtime.block_on(steps()); + assert_eq!(response.0.len(), SetupStep::ALL.len()); + for (order, step) in response.0.iter().enumerate() { + assert_eq!(step.slug, SetupStep::ALL[order]); + assert_eq!(step.order, order); + } + } + + #[test] + fn test_coder_target_rejects_zellij() { + let result = selected_execution_target( + SetupExecutionTarget::Coder { + name: "coder-agents".to_string(), + template: "operator".to_string(), + }, + crate::config::SessionWrapperType::Zellij, + ); + assert!(matches!(result, Err(ApiError::ValidationError(_)))); + } + + #[tokio::test] + async fn test_initialize_persists_and_rejects_reinitialization() { + let temp = tempfile::TempDir::new().unwrap(); + let mut config = crate::config::Config::default(); + config.paths.tickets = temp.path().join("tickets").display().to_string(); + config.paths.state = temp.path().join("state").display().to_string(); + config.paths.projects = temp.path().join("projects").display().to_string(); + let state = ApiState::new(config, temp.path().join("tickets")); + + let response = initialize(State(state.clone()), Json(initialize_request())) + .await + .unwrap(); + assert!(response.initialized); + assert!(state.config().operator_config_path_for().is_file()); + assert!(crate::startup::workspace_initialized(&state.config())); + assert_eq!( + state.config().sessions.wrapper, + crate::config::SessionWrapperType::Tmux + ); + assert_eq!( + state.config().launch.target.as_deref(), + Some(crate::config::TARGET_LOCAL) + ); + + let second = initialize(State(state), Json(initialize_request())).await; + assert!(matches!(second, Err(ApiError::Conflict(_)))); + } +} diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index dade2ac3..f30062a5 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1312,7 +1312,7 @@ } }, "prompt_variations": { - "description": "Prompt variations (M) — Handlebars templates, minimum 2", + "description": "Prompt variations (M) - Handlebars templates, minimum 2", "type": "array", "items": { "type": "string" @@ -1353,7 +1353,7 @@ ] }, "PipelineConfig": { - "description": "Configuration for pipeline steps: iterate a list of items through ordered\nstages with no barrier (each item flows through all stages independently).\n\nThe step graph stays linear — a pipeline step still has exactly one\n`next_step`. The fan-out (N items x M stages) lives entirely inside this one\nstep; iteration is an intra-step concern, never a step-to-step edge.", + "description": "Configuration for pipeline steps: iterate a list of items through ordered\nstages with no barrier (each item flows through all stages independently).\n\nThe step graph stays linear - a pipeline step still has exactly one\n`next_step`. The fan-out (N items x M stages) lives entirely inside this one\nstep; iteration is an intra-step concern, never a step-to-step edge.", "type": "object", "properties": { "item_source": { @@ -1390,7 +1390,7 @@ ] }, { - "description": "An array produced by a prior step. Emits that step's result identifier\n(`r_`) — a runtime value, so the graph width is symbolic.", + "description": "An array produced by a prior step. Emits that step's result identifier\n(`r_`) - a runtime value, so the graph width is symbolic.", "type": "object", "properties": { "step": { @@ -1447,7 +1447,7 @@ ] }, { - "description": "A ticket field value split into a list. Resolution is deferred — there\nis no list `FieldType` and ticket field values are not captured at\nexport time yet — so this currently emits a symbolic placeholder.", + "description": "A ticket field value split into a list. Resolution is deferred - there\nis no list `FieldType` and ticket field values are not captured at\nexport time yet - so this currently emits a symbolic placeholder.", "type": "object", "properties": { "name": { @@ -1467,7 +1467,7 @@ ] }, "PipelineStage": { - "description": "A single stage in a pipeline — deliberately flat (not a recursive\n`StepSchema`): \"prompt + optional agent/model/schema\" only. It has no\n`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the\nstep-graph linearity question.", + "description": "A single stage in a pipeline - deliberately flat (not a recursive\n`StepSchema`): \"prompt + optional agent/model/schema\" only. It has no\n`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the\nstep-graph linearity question.", "type": "object", "properties": { "prompt": { diff --git a/src/schemas/ticket_metadata.schema.json b/src/schemas/ticket_metadata.schema.json index f88a7d5d..a7f43f73 100644 --- a/src/schemas/ticket_metadata.schema.json +++ b/src/schemas/ticket_metadata.schema.json @@ -21,7 +21,7 @@ }, "collection": { "type": "string", - "description": "Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search.", + "description": "Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets - resolution falls back to the active collection, then a deterministic search.", "pattern": "^[a-z0-9_]{3,64}$", "examples": ["dev_kanban", "ralph_loop", "custom"] }, diff --git a/src/services/git_onboarding.rs b/src/services/git_onboarding.rs new file mode 100644 index 00000000..357cd6d6 --- /dev/null +++ b/src/services/git_onboarding.rs @@ -0,0 +1,252 @@ +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; + +use crate::api::cli_detection::onboarding_spec_for_slug; +use crate::config::{Config, GitProviderConfig}; + +#[derive(Debug)] +pub enum OnboardingStep { + InstallCli { + install_url: String, + provider_display: String, + }, + CollectToken { + pat_url: String, + provider: String, + provider_display: String, + placeholder: String, + }, + AutoConfigured { + username: String, + token: String, + provider: String, + provider_display: String, + }, +} + +fn is_cli_installed(command: &str) -> bool { + Command::new(command) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn grab_cli_token(command: &str, args: &[&str]) -> Option { + let output = Command::new(command) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|token| !token.is_empty()) +} + +pub fn validate_github_token(token: &str) -> Result { + let response = reqwest::blocking::Client::new() + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {token}")) + .header("User-Agent", "operator") + .send() + .context("Failed to reach GitHub API")?; + anyhow::ensure!( + response.status().is_success(), + "GitHub token validation failed (HTTP {})", + response.status() + ); + let body: serde_json::Value = response.json().context("Failed to parse GitHub response")?; + body["login"] + .as_str() + .map(str::to_owned) + .context("GitHub response missing 'login' field") +} + +pub fn validate_gitlab_token(token: &str) -> Result { + let response = reqwest::blocking::Client::new() + .get("https://gitlab.com/api/v4/user") + .header("Private-Token", token) + .header("User-Agent", "operator") + .send() + .context("Failed to reach GitLab API")?; + anyhow::ensure!( + response.status().is_success(), + "GitLab token validation failed (HTTP {})", + response.status() + ); + let body: serde_json::Value = response.json().context("Failed to parse GitLab response")?; + body["username"] + .as_str() + .map(str::to_owned) + .context("GitLab response missing 'username' field") +} + +pub fn resolve_onboarding(provider: &str) -> Option { + let meta = onboarding_spec_for_slug(provider)?; + if !is_cli_installed(meta.command) { + return Some(OnboardingStep::InstallCli { + install_url: meta.install_url.to_string(), + provider_display: meta.display_name.to_string(), + }); + } + if let Some(token) = (!meta.auth_args.is_empty()) + .then(|| grab_cli_token(meta.command, meta.auth_args)) + .flatten() + { + let username = match provider { + "github" => validate_github_token(&token), + "gitlab" => validate_gitlab_token(&token), + _ => return None, + }; + if let Ok(username) = username { + return Some(OnboardingStep::AutoConfigured { + username, + token, + provider: provider.to_string(), + provider_display: meta.display_name.to_string(), + }); + } + } + Some(OnboardingStep::CollectToken { + pat_url: meta.pat_url.to_string(), + provider: provider.to_string(), + provider_display: meta.display_name.to_string(), + placeholder: meta.placeholder.to_string(), + }) +} + +pub fn configure_git_provider(config: &mut Config, provider: &str, token_env: &str) -> Result<()> { + match provider { + "github" => { + config.git.provider = Some(GitProviderConfig::GitHub); + config.git.github.enabled = true; + config.git.github.token_env = token_env.to_string(); + } + "gitlab" => { + config.git.provider = Some(GitProviderConfig::GitLab); + config.git.gitlab.enabled = true; + config.git.gitlab.token_env = token_env.to_string(); + } + "gitea" => { + config.git.provider = Some(GitProviderConfig::Gitea); + config.git.gitea.enabled = true; + config.git.gitea.token_env = token_env.to_string(); + } + _ => anyhow::bail!("Unsupported provider: {provider}"), + } + Ok(()) +} + +pub fn apply_git_provider(config: &mut Config, provider: &str, token: &str) -> Result<()> { + let token_env = token_env_for(config, provider) + .ok_or_else(|| anyhow::anyhow!("Unsupported provider: {provider}"))?; + configure_git_provider(config, provider, &token_env)?; + std::env::set_var(token_env, token); + Ok(()) +} + +pub fn complete_git_onboarding(config: &mut Config, provider: &str, token: &str) -> Result<()> { + apply_git_provider(config, provider, token)?; + config.save() +} + +pub fn token_env_for(config: &Config, provider: &str) -> Option { + match provider { + "github" => Some(config.git.github.token_env.clone()), + "gitlab" => Some(config.git.gitlab.token_env.clone()), + "gitea" => Some(config.git.gitea.token_env.clone()), + _ => None, + } +} + +pub fn shell_export_block(token_env: &str) -> String { + format!("export {token_env}=") +} + +pub fn validate_token(provider: &str, token: &str) -> Result { + match provider { + "github" => validate_github_token(token), + "gitlab" => validate_gitlab_token(token), + _ => anyhow::bail!("Unsupported provider: {provider}"), + } +} + +pub fn resolve_onboarding_with_config(config: &Config, provider: &str) -> Option { + let mut step = resolve_onboarding(provider)?; + if provider == "gitea" { + let base = + crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com") + .ok()?; + if let OnboardingStep::CollectToken { pat_url, .. } = &mut step { + *pat_url = base.join("user/settings/applications").ok()?.to_string(); + } + } + Some(step) +} + +pub fn validate_token_with_config(config: &Config, provider: &str, token: &str) -> Result { + if provider != "gitea" { + return validate_token(provider, token); + } + let base = crate::types::pr::provider_base_url(config.git.gitea.host.as_deref(), "gitea.com")?; + let git = crate::config::GitExecutionConfig { + credentials: Some(crate::config::GitCredentialConfig { + repository_url: base.join("operator/authentication")?.to_string(), + username: "operator".into(), + token_env: config.git.gitea.token_env.clone(), + }), + ..Default::default() + }; + let runtime = crate::git::runtime::GitRuntime::create_with_token(&git, Some(token))?; + let output = Command::new(crate::api::cli_detection::binary_for( + crate::types::pr::GitProvider::Gitea, + )) + .args(["api", "--login", "operator", "user"]) + .env("XDG_CONFIG_HOME", &runtime.path) + .output() + .context("Gitea requires tea with the api command")?; + anyhow::ensure!(output.status.success(), "Gitea token validation failed"); + let body: serde_json::Value = serde_json::from_slice(&output.stdout)?; + body["login"] + .as_str() + .map(str::to_owned) + .context("Gitea response missing login") +} + +pub fn provider_is_configured(config: &Config, provider: &str) -> bool { + match provider { + "github" => config.git.provider == Some(GitProviderConfig::GitHub), + "gitlab" => config.git.provider == Some(GitProviderConfig::GitLab), + "gitea" => config.git.provider == Some(GitProviderConfig::Gitea), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_onboarding_spec_for_supported_providers() { + for provider in ["github", "gitlab", "gitea"] { + assert!(onboarding_spec_for_slug(provider).is_some()); + } + assert!(onboarding_spec_for_slug("bitbucket").is_none()); + } + + #[test] + fn test_cli_helpers_reject_missing_binary() { + assert!(!is_cli_installed("nonexistent-cli-tool-xyz-12345")); + assert!(grab_cli_token("nonexistent-cli-tool-xyz-12345", &["auth", "token"]).is_none()); + } + + #[test] + fn test_resolve_onboarding_rejects_unknown_provider() { + assert!(resolve_onboarding("bitbucket").is_none()); + } +} diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index 1f2e8d79..95678b97 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -317,8 +317,8 @@ pub async fn list_statuses( /// Write or upsert a kanban config section to `config.toml`. /// -/// `config_override_path` is optional - when `None`, falls back to -/// `Config::operator_config_path()` (which is what production uses). +/// `config_override_path` is optional - when `None`, the config's own +/// `operator_config_path_for()` is used (which is what production uses). /// When `Some`, the config is loaded from and saved to that path instead /// (used by unit tests). #[allow(dead_code)] @@ -344,7 +344,7 @@ pub fn write_config( config .save() .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; - Config::operator_config_path().display().to_string() + config.operator_config_path_for().display().to_string() }; info!(section = %section_header, "Wrote kanban config section"); diff --git a/src/services/mod.rs b/src/services/mod.rs index baa8b1f2..dcd9f1f1 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -5,6 +5,7 @@ #![allow(unused_imports)] // Re-exports for future integration +pub mod git_onboarding; pub mod kanban_issuetype_service; pub mod kanban_onboarding; pub mod kanban_sync; diff --git a/src/setup.rs b/src/setup.rs index b31a1430..3bdd3ba4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::PathBuf; use crate::agents::{generate_status_script, generate_tmux_conf}; -use crate::config::{CollectionPreset, Config}; +use crate::config::{CollectionPreset, Config, SessionWrapperType}; use crate::templates::TemplateType; /// Common optional fields that can be configured for TASK and propagated to other types @@ -32,8 +32,29 @@ pub struct SetupOptions { pub llm_tool: Option, /// Whether to use git worktrees for per-ticket isolation (default: false) pub use_worktrees: bool, + /// Session wrapper chosen during setup; `None` leaves the configured value + pub wrapper: Option, + /// Acceptance criteria body; `None` writes the shipped template + pub acceptance_criteria: Option, + /// Issue types for a `Custom` preset (e.g. a merged hosted selection) + pub custom_collection: Vec, + /// Collection to activate after scaffolding + pub active_collection: Option, + /// Hosted collections to scaffold: (manifest, files, `icon_svg`) + pub hosted_collections: Vec, + /// Model providers declared during setup + pub model_servers: Vec, + /// Default execution target selected during setup. + pub execution_target: Option, } +/// A hosted collection resolved for scaffolding. +pub type FetchedCollection = ( + crate::collections::manifest::CollectionManifest, + Vec<(String, String, Option)>, + Option, +); + /// Result of setup operation #[derive(Debug)] pub struct SetupResult { @@ -41,6 +62,8 @@ pub struct SetupResult { pub files_created: Vec, pub files_skipped: Vec, pub config_path: PathBuf, + /// Projects discovered during initialization, with git info + pub discovered: Vec, } /// Parse collection preset from string @@ -62,7 +85,8 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu directories_created: Vec::new(), files_created: Vec::new(), files_skipped: Vec::new(), - config_path: tickets_path.join("operator").join("config.toml"), + config_path: config.operator_config_path_for(), + discovered: Vec::new(), }; // Create directories @@ -85,7 +109,11 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu // Get effective issue types from preset let issue_types = if options.preset == CollectionPreset::Custom { - config.templates.collection.clone() + if options.custom_collection.is_empty() { + config.templates.collection.clone() + } else { + options.custom_collection.clone() + } } else { options.preset.issue_types() }; @@ -118,7 +146,10 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu let operator_templates = tickets_path.join("operator").join("templates"); write_file_if_allowed( &operator_templates.join("ACCEPTANCE_CRITERIA.md"), - include_str!("templates/ACCEPTANCE_CRITERIA.md"), + options + .acceptance_criteria + .as_deref() + .unwrap_or(include_str!("templates/ACCEPTANCE_CRITERIA.md")), options.force, &mut result, )?; @@ -135,26 +166,53 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu &mut result, )?; - // Update config with preset + for (manifest, files, icon_svg) in &options.hosted_collections { + crate::startup::templates::write_fetched_collection( + &tickets_path.join("templates"), + manifest, + files, + icon_svg.as_deref(), + )?; + } + config.templates.preset = options.preset; if options.preset == CollectionPreset::Custom { - // Keep existing collection - } else { config.templates.collection = issue_types; + } else { + config.templates.collection.clear(); + } + if let Some(active) = &options.active_collection { + config.templates.active_collection = Some(active.clone()); } - // Configure git worktree preference config.git.use_worktrees = options.use_worktrees; - // Generate tmux config - generate_tmux_config(config)?; + if let Some(wrapper) = options.wrapper { + config.sessions.wrapper = wrapper; + } + if let Some(tool) = &options.llm_tool { + config.llm_tools.default_tool = Some(tool.clone()); + } + for server in &options.model_servers { + if !config.model_servers.iter().any(|s| s.name == server.name) { + config.model_servers.push(server.clone()); + } + } + if let Some(target) = &options.execution_target { + config.launch.target = Some(target.name.clone()); + if !matches!(target.kind, crate::config::TargetKind::Local) { + if let Some(existing) = config.targets.iter_mut().find(|t| t.name == target.name) { + existing.clone_from(target); + } else { + config.targets.push(target.clone()); + } + } + } - // Discover projects (git repos and/or LLM marker files) - let discovered = crate::projects::discover_projects_with_git(&config.projects_path()); - config.projects = discovered.iter().map(|p| p.name.clone()).collect(); + generate_tmux_config(config)?; - // Save config (must be after directories are created) - config.save()?; + result.discovered = crate::projects::discover_projects_with_git(&config.projects_path()); + config.projects = result.discovered.iter().map(|p| p.name.clone()).collect(); Ok(result) } @@ -501,4 +559,132 @@ mod tests { // No projects should be discovered assert!(config.projects.is_empty()); } + + // --- Wizard choices that were previously collected and dropped --- + + fn workspace_config(temp_dir: &TempDir) -> Config { + let tickets_path = temp_dir.path().join(".tickets"); + let mut config = Config::default(); + config.paths.tickets = tickets_path.to_string_lossy().to_string(); + config.paths.state = tickets_path.join("operator").to_string_lossy().to_string(); + config.paths.projects = temp_dir.path().to_string_lossy().to_string(); + config + } + + #[test] + fn test_initialize_workspace_persists_session_wrapper() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + wrapper: Some(SessionWrapperType::Zellij), + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + assert_eq!(config.sessions.wrapper, SessionWrapperType::Zellij); + } + + #[test] + fn test_initialize_workspace_leaves_wrapper_untouched_when_unset() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + config.sessions.wrapper = SessionWrapperType::Cmux; + + initialize_workspace(&mut config, &SetupOptions::default()).unwrap(); + + assert_eq!(config.sessions.wrapper, SessionWrapperType::Cmux); + } + + #[test] + fn test_initialize_workspace_persists_use_worktrees() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + use_worktrees: true, + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + assert!(config.git.use_worktrees); + } + + #[test] + fn test_initialize_workspace_writes_custom_acceptance_criteria() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + acceptance_criteria: Some("- ships on a tuesday".to_string()), + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + let written = fs::read_to_string( + temp_dir + .path() + .join(".tickets/operator/templates/ACCEPTANCE_CRITERIA.md"), + ) + .unwrap(); + assert_eq!(written, "- ships on a tuesday"); + } + + #[test] + fn test_initialize_workspace_applies_llm_tool() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + let options = SetupOptions { + llm_tool: Some("claude".to_string()), + ..Default::default() + }; + initialize_workspace(&mut config, &options).unwrap(); + + assert_eq!(config.llm_tools.default_tool.as_deref(), Some("claude")); + } + + #[test] + fn test_initialize_workspace_upserts_default_coder_target() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + let target = crate::config::TargetDef { + name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), + display_name: Some("Coder".to_string()), + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: "operator-agent".to_string(), + ..Default::default() + }), + }; + + for _ in 0..2 { + initialize_workspace( + &mut config, + &SetupOptions { + execution_target: Some(target.clone()), + ..Default::default() + }, + ) + .unwrap(); + } + + assert_eq!( + config.launch.target.as_deref(), + Some(crate::config::DEFAULT_CODER_TARGET_NAME) + ); + assert_eq!(config.targets, [target]); + } + + #[test] + fn test_initialize_workspace_does_not_save() { + let temp_dir = TempDir::new().unwrap(); + let mut config = workspace_config(&temp_dir); + + initialize_workspace(&mut config, &SetupOptions::default()).unwrap(); + + assert!( + !config.operator_config_path_for().exists(), + "initialize_workspace must leave persistence to the caller" + ); + } } diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 68dde61a..b07874e4 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -10,9 +10,9 @@ //! ## Usage //! //! ```rust,ignore -//! use crate::startup::{SETUP_STEPS, SetupStepInfo}; +//! use crate::startup::setup_steps; //! -//! for step in SETUP_STEPS { +//! for step in setup_steps() { //! println!("{}: {}", step.name, step.description); //! } //! @@ -21,246 +21,14 @@ //! init_default_templates(&templates_path)?; //! ``` +pub mod steps; pub mod templates; -/// Information about a setup wizard step for documentation purposes. -#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs -#[derive(Debug, Clone)] -pub struct SetupStepInfo { - /// Display name of the step (e.g., "Welcome") - pub name: &'static str, - /// Brief description of what happens in this step - pub description: &'static str, - /// Detailed help text explaining the step - pub help_text: &'static str, - /// Navigation instructions (keys to use) - pub navigation: &'static str, -} - -/// All setup wizard steps in order. +/// Whether a workspace has been initialized at `config`'s tickets path. /// -/// 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 via binary and docs_gen, not reachable from lib.rs -pub static SETUP_STEPS: &[SetupStepInfo] = &[ - // ── Tier 0: Config / Welcome ───────────────────────────────────────────── - SetupStepInfo { - name: "Welcome", - description: "Splash screen showing detected LLM tools and discovered projects", - help_text: "The welcome screen displays:\n\ - - Detected LLM tools (Claude, Gemini, Codex, etc.) with version and model count\n\ - - Discovered projects organized by which LLM tool marker files they contain\n\ - - The path where the tickets directory will be created\n\n\ - 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**: Native macOS terminal for AI agents, organized into windows and workspaces\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: "Web UI Password", - description: "Optionally set the admin password for the web dashboard", - help_text: "Operator has a single human account, `admin`.\n\n\ - This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists.\n\n\ - Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page.\n\n\ - The password must be at least 12 characters. This step is hidden when an admin account already exists.", - navigation: "Tab to switch fields, Enter to continue (blank to skip), 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 native macOS terminal that organizes AI agent sessions into \ - windows and workspaces.\n\n\ - This step verifies the cmux app's CLI binary exists at the configured \ - binary_path (by default inside /Applications/cmux.app) and meets the \ - minimum supported version.", - 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 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\ - - **Custom Selection**: Choose individual issue types", - navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", - }, - SetupStepInfo { - name: "Hosted Collections", - description: "Browse and select hosted collections (only shown if Browse chosen)", - help_text: "Pick one or more curated collections published at operator.untra.io.\n\n\ - The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps.\n\n\ - Selections are additive - choose as many as apply.", - navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", - }, - SetupStepInfo { - name: "Task Field Config", - description: "Configure optional fields for TASK issue type", - help_text: - "TASK is the foundational issue type. Configure which optional fields to include:\n\ - - **priority**: Priority level (P0-critical to P3-low)\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: "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 { - name: "Startup Tickets", - description: "Optionally create tickets to bootstrap your projects", - help_text: "Create startup tickets to help initialize your projects:\n\ - - **ASSESS tickets**: Scan projects for catalog-info.yaml, create if missing\n\ - - **AGENT_SETUP tickets**: Configure Claude agents for each project\n\ - - **PROJECT_INIT tickets**: Run both ASSESS and AGENT_SETUP for each project\n\n\ - 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", - }, - SetupStepInfo { - name: "Confirm", - description: "Review settings and confirm initialization", - help_text: "Review your configuration before initialization:\n\ - - Path where `.tickets/` will be created\n\ - - Selected issue types and preset name\n\ - - Directories that will be created: queue/, in-progress/, completed/, templates/\n\n\ - Choose Initialize to create the ticket queue, or Cancel to exit without changes.", - navigation: "Tab or Space to toggle selection, Enter to confirm, Esc to go back", - }, -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_setup_steps_not_empty() { - assert!(!SETUP_STEPS.is_empty()); - } - - #[test] - fn test_setup_steps_have_required_fields() { - for step in SETUP_STEPS { - assert!(!step.name.is_empty(), "Step name should not be empty"); - assert!( - !step.description.is_empty(), - "Step description should not be empty" - ); - assert!( - !step.help_text.is_empty(), - "Step help_text should not be empty" - ); - assert!( - !step.navigation.is_empty(), - "Step navigation should not be empty" - ); - } - } - - #[test] - fn test_setup_steps_count_matches_enum() { - // 16 steps: Welcome, SessionWrapperChoice, WorktreePreference, - // AdminPassword, TmuxOnboarding, VSCodeSetup, CmuxSetup, ZellijSetup, - // KanbanInfo, KanbanProviderSetup, CollectionSource, HostedCollectionFetch, TaskFieldConfig, - // AcceptanceCriteria, StartupTickets, Confirm - assert_eq!(SETUP_STEPS.len(), 16); - } - - #[test] - fn test_step_names_are_unique() { - let names: Vec<&str> = SETUP_STEPS.iter().map(|s| s.name).collect(); - let mut unique_names = names.clone(); - unique_names.sort_unstable(); - unique_names.dedup(); - assert_eq!( - names.len(), - unique_names.len(), - "Step names should be unique" - ); - } +/// One predicate for every surface: the TUI decides whether to show the setup +/// wizard from it, and it keeps "is this set up?" from drifting between them. +#[allow(dead_code)] // Used via binary today; the REST setup surface will share it +pub fn workspace_initialized(config: &crate::config::Config) -> bool { + config.tickets_path().join("queue").exists() } diff --git a/src/startup/steps.rs b/src/startup/steps.rs new file mode 100644 index 00000000..6b9c9183 --- /dev/null +++ b/src/startup/steps.rs @@ -0,0 +1,427 @@ +//! The setup wizard's step catalog: identity, order and copy. +//! +//! This is the single source of truth for the wizard. The ratatui renderer +//! matches on [`SetupStep`], `docs_gen` renders [`setup_steps`] into +//! `docs/startup/index.md`, and `bindings/SetupStep.ts` is generated from the +//! same enum. Adding a variant is a compile error until [`SetupStep::info`] +//! and [`SetupStep::slug`] account for it, and until [`SetupStep::ALL`] is +//! resized to place it in the walk order. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +/// Documentation copy for one wizard step. +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +#[derive(Debug, Clone)] +pub struct SetupStepInfo { + /// Display name of the step (e.g., "Welcome") + pub name: &'static str, + /// Brief description of what happens in this step + pub description: &'static str, + /// Detailed help text explaining the step + pub help_text: &'static str, + /// Navigation instructions (keys to use) + pub navigation: &'static str, +} + +/// A step in the setup wizard. +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, ToSchema, TS, +)] +#[ts(export)] +pub enum SetupStep { + /// Splash screen with discovered projects and detected LLM tools + #[serde(rename = "welcome")] + Welcome, + /// Kanban integration overview and provider credential detection + #[serde(rename = "kanban-info")] + KanbanInfo, + /// Declare which model providers this workspace uses + #[serde(rename = "model-server")] + ModelServer, + /// Connect a git provider so agents can branch, push and open PRs + #[serde(rename = "git-provider")] + GitProvider, + /// Choose which issue type collection to use + #[serde(rename = "collection-source")] + CollectionSource, + /// Browse and multi-select hosted collections + #[serde(rename = "hosted-collections")] + HostedCollectionFetch, + /// Configure optional TASK fields + #[serde(rename = "task-field-config")] + TaskFieldConfig, + /// Select the session wrapper agents launch into + #[serde(rename = "session-wrapper-choice")] + SessionWrapperChoice, + /// Choose where agent commands execute + #[serde(rename = "execution-target")] + ExecutionTarget, + /// Choose in-place branches or per-ticket worktrees + #[serde(rename = "worktree-preference")] + WorktreePreference, + /// Optional admin password for the web dashboard + #[serde(rename = "admin-password")] + AdminPassword, + /// tmux help, shown only when tmux is selected + #[serde(rename = "tmux-onboarding")] + TmuxOnboarding, + /// VS Code extension setup, shown only when VS Code is selected + #[serde(rename = "vscode-setup")] + VSCodeSetup, + /// cmux setup, shown only when cmux is selected + #[serde(rename = "cmux-setup")] + CmuxSetup, + /// Zellij setup, shown only when Zellij is selected + #[serde(rename = "zellij-setup")] + ZellijSetup, + /// Review the acceptance criteria template + #[serde(rename = "acceptance-criteria")] + AcceptanceCriteria, + /// Optionally create bootstrap tickets + #[serde(rename = "startup-tickets")] + StartupTickets, + /// Review and confirm initialization + #[serde(rename = "confirm")] + Confirm, +} + +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +impl SetupStep { + /// Every step, in the order the wizard walks them. Conditional steps + /// (the per-wrapper ones) appear here even though a given run skips most. + pub const ALL: [SetupStep; 18] = [ + SetupStep::Welcome, + SetupStep::KanbanInfo, + SetupStep::ModelServer, + SetupStep::GitProvider, + SetupStep::CollectionSource, + SetupStep::HostedCollectionFetch, + SetupStep::TaskFieldConfig, + SetupStep::SessionWrapperChoice, + SetupStep::ExecutionTarget, + SetupStep::WorktreePreference, + SetupStep::AdminPassword, + SetupStep::TmuxOnboarding, + SetupStep::VSCodeSetup, + SetupStep::CmuxSetup, + SetupStep::ZellijSetup, + SetupStep::AcceptanceCriteria, + SetupStep::StartupTickets, + SetupStep::Confirm, + ]; + + /// Stable identifier used by docs URLs and the web renderer. + pub fn slug(self) -> &'static str { + match self { + SetupStep::Welcome => "welcome", + SetupStep::KanbanInfo => "kanban-info", + SetupStep::ModelServer => "model-server", + SetupStep::GitProvider => "git-provider", + SetupStep::CollectionSource => "collection-source", + SetupStep::HostedCollectionFetch => "hosted-collections", + SetupStep::TaskFieldConfig => "task-field-config", + SetupStep::SessionWrapperChoice => "session-wrapper-choice", + SetupStep::ExecutionTarget => "execution-target", + SetupStep::WorktreePreference => "worktree-preference", + SetupStep::AdminPassword => "admin-password", + SetupStep::TmuxOnboarding => "tmux-onboarding", + SetupStep::VSCodeSetup => "vscode-setup", + SetupStep::CmuxSetup => "cmux-setup", + SetupStep::ZellijSetup => "zellij-setup", + SetupStep::AcceptanceCriteria => "acceptance-criteria", + SetupStep::StartupTickets => "startup-tickets", + SetupStep::Confirm => "confirm", + } + } + + /// Documentation copy for this step. + pub fn info(self) -> SetupStepInfo { + match self { + SetupStep::Welcome => SetupStepInfo { + name: "Welcome", + description: "Splash screen showing detected LLM tools and discovered projects", + help_text: "The welcome screen displays:\n\ + - Detected LLM tools (Claude, Gemini, Codex, etc.) with version and model count\n\ + - Discovered projects organized by which LLM tool marker files they contain\n\ + - The path where the tickets directory will be created\n\n\ + This gives you an overview of your development environment before proceeding.", + navigation: "Enter to continue, Esc to cancel", + }, + SetupStep::KanbanInfo => SetupStepInfo { + name: "Kanban Info", + description: "Connect a kanban provider, or skip and connect one later", + 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 already exported (e.g. OPERATOR_JIRA_API_KEY) are listed as \ + detected providers.\n\n\ + **Connect a kanban provider** opens the same onboarding dialog the dashboard \ + uses: pick a provider, enter its credentials, validate them against the live \ + API, and choose a project. The provider section is written to config.toml and \ + the token is exported into this session, with a shell snippet to make it \ + permanent.\n\n\ + **Skip for now** moves on; press `K` from the dashboard at any time.", + navigation: "↑/↓ to select, Enter to confirm, Esc to go back", + }, + SetupStep::ModelServer => SetupStepInfo { + name: "Model Server", + description: "Declare which model providers this workspace uses", + help_text: + "Model providers are where inference happens - distinct from the agent CLI \ + that calls them.\n\n\ + Each provider is probed live: a row reads `N models` when Operator can reach \ + it, `key missing` when its API key env var is unset, or `unreachable` with \ + the reason.\n\n\ + Space declares a provider, writing a `[[model_servers]]` entry. Operator \ + stores only the *name* of the environment variable holding the key, never \ + the key itself - export it in your shell to make it permanent.\n\n\ + Providers needing a custom base URL (OpenAI-compatible, LM Studio) are \ + listed but not selectable here; add them to config.toml directly.\n\n\ + This step is optional - Operator ships working defaults for the first-party \ + vendors.", + navigation: + "↑/↓ or j/k to navigate, Space to declare, Enter to continue, Esc to go back", + }, + SetupStep::GitProvider => SetupStepInfo { + name: "Git Provider", + description: "Connect a git provider so agents can branch, push and open PRs", + help_text: + "Operator branches per ticket and opens pull requests on your behalf, which \ + needs a provider and a token.\n\n\ + Each row reports what was found: the provider CLI (`gh`, `glab`, `tea`) not \ + installed, an existing CLI login Operator can adopt with no typing, or a \ + prompt for a personal access token.\n\n\ + Only the *name* of the environment variable holding the token is written to \ + config.toml. The token itself is exported into this session, and the step \ + prints the shell line to make that permanent - without it the token is gone \ + when Operator exits.\n\n\ + This step is optional; Operator works against a local repository with no \ + provider connected.", + navigation: + "↑/↓ or j/k to navigate, Enter to connect, Esc to go back", + }, + SetupStep::CollectionSource => SetupStepInfo { + name: "Collection Source", + 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\ + - **Custom Selection**: Choose individual issue types", + navigation: "↑/↓ or j/k to navigate, Enter to select, Esc to go back", + }, + SetupStep::HostedCollectionFetch => SetupStepInfo { + name: "Hosted Collections", + description: "Browse and select hosted collections (only shown if Browse chosen)", + help_text: "Pick one or more curated collections published at operator.untra.io.\n\n\ + The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps.\n\n\ + Selections are additive - choose as many as apply.", + navigation: "↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back", + }, + SetupStep::TaskFieldConfig => SetupStepInfo { + name: "Task Field Config", + description: "Configure optional fields for TASK issue type", + help_text: + "TASK is the foundational issue type. Configure which optional fields to include:\n\ + - **priority**: Priority level (P0-critical to P3-low)\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", + }, + SetupStep::SessionWrapperChoice => 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**: Native macOS terminal for AI agents, organized into windows and workspaces\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", + }, + SetupStep::ExecutionTarget => SetupStepInfo { + name: "Execution Target", + description: "Choose whether agents run locally or in Coder workspaces", + help_text: "Local runs agent commands on the same machine as Operator. Coder creates or starts a per-ticket workspace and launches there over SSH.\n\nCoder configuration stores only environment variable names for the deployment URL and session token. Secret values remain in the process environment.\n\nCoder targets disable git worktrees and relay injection, and cannot be combined with Zellij.", + navigation: "↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back", + }, + SetupStep::WorktreePreference => 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", + }, + SetupStep::AdminPassword => SetupStepInfo { + name: "Web UI Password", + description: "Optionally set the admin password for the web dashboard", + help_text: "Operator has a single human account, `admin`.\n\n\ + This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists.\n\n\ + Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page.\n\n\ + The password must be at least 12 characters. This step is hidden when an admin account already exists.", + navigation: "Tab to switch fields, Enter to continue (blank to skip), Esc to go back", + }, + SetupStep::TmuxOnboarding => 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", + }, + SetupStep::VSCodeSetup => 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", + }, + SetupStep::CmuxSetup => SetupStepInfo { + name: "Cmux Setup", + description: "cmux session wrapper setup (shown if cmux selected)", + help_text: "cmux is a native macOS terminal that organizes AI agent sessions into \ + windows and workspaces.\n\n\ + This step verifies the cmux app's CLI binary exists at the configured \ + binary_path (by default inside /Applications/cmux.app) and meets the \ + minimum supported version.", + navigation: "Enter to continue, Esc to go back", + }, + SetupStep::ZellijSetup => 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", + }, + SetupStep::AcceptanceCriteria => SetupStepInfo { + 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", + }, + SetupStep::StartupTickets => SetupStepInfo { + name: "Startup Tickets", + description: "Optionally create tickets to bootstrap your projects", + help_text: "Create startup tickets to help initialize your projects:\n\ + - **ASSESS tickets**: Scan projects for catalog-info.yaml, create if missing\n\ + - **AGENT_SETUP tickets**: Configure Claude agents for each project\n\ + - **PROJECT_INIT tickets**: Run both ASSESS and AGENT_SETUP for each project\n\n\ + 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", + }, + SetupStep::Confirm => SetupStepInfo { + name: "Confirm", + description: "Review settings and confirm initialization", + help_text: "Review your configuration before initialization:\n\ + - Path where `.tickets/` will be created\n\ + - Selected issue types and preset name\n\ + - Directories that will be created: queue/, in-progress/, completed/, templates/\n\n\ + Choose Initialize to create the ticket queue, or Cancel to exit without changes.", + navigation: "Tab or Space to toggle selection, Enter to confirm, Esc to go back", + }, + } + } +} + +/// The full catalog in wizard order, for documentation generation. +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs +pub fn setup_steps() -> Vec { + SetupStep::ALL.iter().map(|s| s.info()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_every_step_info_field_is_non_empty() { + for step in SetupStep::ALL { + let info = step.info(); + assert!(!info.name.is_empty(), "{step:?}: empty name"); + assert!(!info.description.is_empty(), "{step:?}: empty description"); + assert!(!info.help_text.is_empty(), "{step:?}: empty help_text"); + assert!(!info.navigation.is_empty(), "{step:?}: empty navigation"); + } + } + + #[test] + fn test_all_contains_no_duplicates() { + let unique: HashSet<_> = SetupStep::ALL.iter().collect(); + assert_eq!(unique.len(), SetupStep::ALL.len()); + } + + #[test] + fn test_slugs_are_unique() { + let unique: HashSet<_> = SetupStep::ALL.iter().map(|s| s.slug()).collect(); + assert_eq!(unique.len(), SetupStep::ALL.len()); + } + + #[test] + fn test_step_names_are_unique() { + let unique: HashSet<_> = SetupStep::ALL.iter().map(|s| s.info().name).collect(); + assert_eq!(unique.len(), SetupStep::ALL.len()); + } + + /// Slugs key docs URLs and the web renderer's component map, so a rename is + /// a breaking change and must be deliberate. + #[test] + fn test_slugs_match_frozen_snapshot() { + let slugs: Vec<&str> = SetupStep::ALL.iter().map(|s| s.slug()).collect(); + assert_eq!( + slugs, + vec![ + "welcome", + "kanban-info", + "model-server", + "git-provider", + "collection-source", + "hosted-collections", + "task-field-config", + "session-wrapper-choice", + "execution-target", + "worktree-preference", + "admin-password", + "tmux-onboarding", + "vscode-setup", + "cmux-setup", + "zellij-setup", + "acceptance-criteria", + "startup-tickets", + "confirm", + ] + ); + } + + #[test] + fn test_serde_representation_matches_slug() { + for step in SetupStep::ALL { + let json = serde_json::to_string(&step).unwrap(); + assert_eq!(json, format!("\"{}\"", step.slug())); + } + } + + #[test] + fn test_setup_steps_covers_every_variant() { + assert_eq!(setup_steps().len(), SetupStep::ALL.len()); + } +} diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index 6bb6b7a5..7e87e118 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use crate::agents::{SystemTmuxClient, TmuxClient, TmuxError}; +use crate::api::providers::model_server::ModelServerKind; use crate::config::{CollectionPreset, SessionWrapperType}; +use crate::integrations::catalog::{onboardable, CatalogEntry, Vertical}; use crate::ui::masked_input::MaskedInput; use ratatui::{widgets::ListState, Frame}; @@ -15,6 +17,10 @@ pub use types::*; #[cfg(test)] mod tests; +pub(crate) const LOCAL_TARGET_OPTION_INDEX: usize = 0; +pub(crate) const CODER_TARGET_OPTION_INDEX: usize = 1; +const EXECUTION_TARGET_OPTION_COUNT: usize = 2; + /// Setup screen shown when .tickets/ directory doesn't exist pub struct SetupScreen { /// Whether the screen is visible @@ -67,23 +73,41 @@ pub struct SetupScreen { /// Detected kanban providers from environment variables pub detected_kanban_providers: Vec, /// Indices of providers with valid credentials - pub valid_kanban_providers: Vec, - /// Projects fetched from current provider being configured - pub kanban_projects: - super::paginated_list::PaginatedList, - /// Issue types for the currently selected project - pub kanban_issue_types: Vec, - /// Member count for the currently selected project - pub kanban_member_count: usize, + /// Highlighted row on the kanban info step (0 = connect, 1 = skip) + pub(crate) kanban_choice_state: ListState, + /// Set when the user chose "connect"; the key handler opens the dialog + pub(crate) kanban_dialog_requested: bool, + // ─── Model Server State ───────────────────────────────────────────────── + /// Cursor over `model_providers()` + pub(crate) model_server_state: ListState, + /// Live probe result per kind slug, filled on entering the step + pub(crate) model_server_probes: std::collections::HashMap, + /// Kind slugs the user declared, written as `[[model_servers]]` entries + pub model_servers_declared: Vec, + /// Whether the probe pass has run + pub model_servers_probed: bool, + // ─── Git Provider State ───────────────────────────────────────────────── + /// Cursor over `git_providers()` + pub(crate) git_provider_state: ListState, + /// Slug the user asked to connect; the key handler resolves onboarding + pub(crate) git_connect_requested: Option, + /// Per-slug outcome line rendered next to the provider + pub(crate) git_provider_status: std::collections::HashMap, + /// Shell line that persists the connected provider's token + pub(crate) git_export_hint: Option, /// Whether kanban detection/testing has run pub kanban_detection_complete: bool, - /// Whether the user chose to skip kanban setup - pub kanban_skipped: bool, // ─── Session Wrapper Setup State ──────────────────────────────────────────── /// Selected session wrapper type pub selected_wrapper: SessionWrapperType, /// List state for wrapper selection pub(crate) wrapper_state: ListState, + // ─── Execution Target State ───────────────────────────────────────────── + pub(crate) execution_target_state: ListState, + pub(crate) coder_target_name: String, + pub(crate) coder_template: String, + pub(crate) coder_field: CoderSetupField, + pub(crate) execution_target_error: Option, /// Tmux availability status (checked during `TmuxOnboarding` step) pub tmux_status: TmuxDetectionStatus, /// VS Code extension status (checked during `VSCodeSetup` step) @@ -130,6 +154,9 @@ impl SetupScreen { let mut worktree_state = ListState::default(); worktree_state.select(Some(0)); + let mut execution_target_state = ListState::default(); + execution_target_state.select(Some(LOCAL_TARGET_OPTION_INDEX)); + let mut hosted_state = ListState::default(); hosted_state.select(Some(0)); @@ -162,15 +189,37 @@ impl SetupScreen { .to_string(), // Kanban setup state detected_kanban_providers: Vec::new(), - valid_kanban_providers: Vec::new(), - kanban_projects: super::paginated_list::PaginatedList::new(8), - kanban_issue_types: Vec::new(), - kanban_member_count: 0, + kanban_choice_state: { + let mut st = ListState::default(); + st.select(Some(0)); + st + }, + kanban_dialog_requested: false, + model_server_state: { + let mut st = ListState::default(); + st.select(Some(0)); + st + }, + model_server_probes: std::collections::HashMap::new(), + model_servers_declared: Vec::new(), + model_servers_probed: false, + git_provider_state: { + let mut st = ListState::default(); + st.select(Some(0)); + st + }, + git_connect_requested: None, + git_provider_status: std::collections::HashMap::new(), + git_export_hint: None, kanban_detection_complete: false, - kanban_skipped: false, // Session wrapper state selected_wrapper: SessionWrapperType::Tmux, wrapper_state, + execution_target_state, + coder_target_name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), + coder_template: String::new(), + coder_field: CoderSetupField::TargetName, + execution_target_error: None, tmux_status: TmuxDetectionStatus::NotChecked, vscode_status: VSCodeDetectionStatus::NotChecked, // Git worktree state @@ -311,6 +360,7 @@ impl SetupScreen { /// Toggle selection (Space key) pub fn toggle_selection(&mut self) { match self.step { + SetupStep::ModelServer => self.toggle_model_server(), SetupStep::HostedCollectionFetch => { // Toggle the highlighted collection in the multi-select picker. if let Some(r) = self.highlighted_hosted() { @@ -344,6 +394,9 @@ impl SetupScreen { } } } + SetupStep::ExecutionTarget => { + self.coder_field = self.coder_field.toggled(); + } SetupStep::WorktreePreference => { // Select the currently highlighted worktree option if let Some(i) = self.worktree_state.selected() { @@ -375,6 +428,29 @@ impl SetupScreen { /// Move to next item in list pub fn select_next(&mut self) { match self.step { + SetupStep::KanbanInfo => { + let i = self + .kanban_choice_state + .selected() + .map_or(0, |i| (i + 1) % 2); + self.kanban_choice_state.select(Some(i)); + } + SetupStep::ModelServer => { + let len = Self::model_providers().len(); + let i = self + .model_server_state + .selected() + .map_or(0, |i| (i + 1) % len); + self.model_server_state.select(Some(i)); + } + SetupStep::GitProvider => { + let len = Self::git_providers().len() + 1; + let i = self + .git_provider_state + .selected() + .map_or(0, |i| (i + 1) % len); + self.git_provider_state.select(Some(i)); + } SetupStep::CollectionSource => { let len = self.source_options.len(); if len > 0 { @@ -399,6 +475,16 @@ impl SetupScreen { let i = self.wrapper_state.selected().map_or(0, |i| (i + 1) % len); self.wrapper_state.select(Some(i)); } + SetupStep::ExecutionTarget => { + let i = self + .execution_target_state + .selected() + .map_or(LOCAL_TARGET_OPTION_INDEX, |i| { + (i + 1) % EXECUTION_TARGET_OPTION_COUNT + }); + self.execution_target_state.select(Some(i)); + self.execution_target_error = None; + } SetupStep::WorktreePreference => { let len = WorktreeOption::all().len(); let i = self.worktree_state.selected().map_or(0, |i| (i + 1) % len); @@ -416,6 +502,35 @@ impl SetupScreen { /// Move to previous item in list pub fn select_prev(&mut self) { match self.step { + SetupStep::KanbanInfo => { + let i = self + .kanban_choice_state + .selected() + .map_or(0, |i| (i + 1) % 2); + self.kanban_choice_state.select(Some(i)); + } + SetupStep::ModelServer => { + let len = Self::model_providers().len(); + let i = self.model_server_state.selected().map_or(0, |i| { + if i == 0 { + len - 1 + } else { + i - 1 + } + }); + self.model_server_state.select(Some(i)); + } + SetupStep::GitProvider => { + let len = Self::git_providers().len() + 1; + let i = self.git_provider_state.selected().map_or(0, |i| { + if i == 0 { + len - 1 + } else { + i - 1 + } + }); + self.git_provider_state.select(Some(i)); + } SetupStep::CollectionSource => { let len = self.source_options.len(); if len > 0 { @@ -458,6 +573,16 @@ impl SetupScreen { .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 }); self.wrapper_state.select(Some(i)); } + SetupStep::ExecutionTarget => { + let i = self + .execution_target_state + .selected() + .map_or(CODER_TARGET_OPTION_INDEX, |i| { + usize::from(i == LOCAL_TARGET_OPTION_INDEX) + }); + self.execution_target_state.select(Some(i)); + self.execution_target_error = None; + } SetupStep::WorktreePreference => { let len = WorktreeOption::all().len(); let i = @@ -547,6 +672,160 @@ impl SetupScreen { self.password_error = None; } + pub fn handle_execution_target_key(&mut self, code: ratatui::crossterm::event::KeyCode) { + use ratatui::crossterm::event::KeyCode; + + if self.execution_target_state.selected() != Some(CODER_TARGET_OPTION_INDEX) { + return; + } + let value = match self.coder_field { + CoderSetupField::TargetName => &mut self.coder_target_name, + CoderSetupField::Template => &mut self.coder_template, + }; + match code { + KeyCode::Char(c) => value.push(c), + KeyCode::Backspace | KeyCode::Delete => { + value.pop(); + } + _ => return, + } + self.execution_target_error = None; + } + + pub fn selected_execution_target(&self) -> crate::config::TargetDef { + if self.execution_target_state.selected() != Some(CODER_TARGET_OPTION_INDEX) { + return crate::config::TargetDef::local(); + } + crate::config::TargetDef { + name: self.coder_target_name.trim().to_string(), + display_name: Some("Coder".to_string()), + kind: crate::config::TargetKind::Coder(crate::config::CoderConfig { + template: self.coder_template.trim().to_string(), + ..Default::default() + }), + } + } + + fn coder_target_selected(&self) -> bool { + self.execution_target_state.selected() == Some(CODER_TARGET_OPTION_INDEX) + } + + /// Declare or undeclare the highlighted provider. Kinds without a default + /// base URL need one supplied by hand, so they are not selectable here. + fn toggle_model_server(&mut self) { + let Some(kind) = self + .model_server_state + .selected() + .and_then(|i| Self::model_providers().get(i).map(|(_, k)| *k)) + else { + return; + }; + if !kind.connectable_from_defaults() { + return; + } + let slug = kind.slug().to_string(); + if let Some(pos) = self.model_servers_declared.iter().position(|s| s == &slug) { + self.model_servers_declared.remove(pos); + } else { + self.model_servers_declared.push(slug); + } + } + + /// Probe every connectable kind against its defaults, mirroring what the + /// web UI's provider cards show. + pub async fn probe_model_servers(&mut self, config: &crate::config::Config) { + if self.model_servers_probed { + return; + } + self.model_servers_probed = true; + let policy = crate::auth::egress::EgressPolicy::from_config(config); + for (_, kind) in Self::model_providers() { + if !kind.connectable_from_defaults() { + continue; + } + let server = crate::config::ModelServer { + name: kind.slug().to_string(), + kind: kind.slug().to_string(), + base_url: kind.default_base_url().map(str::to_string), + api_key_env: kind.default_api_key_env().map(str::to_string), + extra_env: std::collections::HashMap::new(), + display_name: None, + }; + let outcome = crate::api::providers::model_server::probe_models(&server, &policy).await; + let status = if outcome.reachable { + format!("{} models", outcome.models.len()) + } else if kind + .default_api_key_env() + .is_some_and(|e| std::env::var(e).is_err()) + { + "key missing".to_string() + } else { + outcome + .error + .unwrap_or_else(|| "unreachable".to_string()) + .chars() + .take(40) + .collect() + }; + self.model_server_probes + .insert(kind.slug().to_string(), status); + } + } + + /// The declared providers, as `[[model_servers]]` entries. + pub fn declared_model_servers(&self) -> Vec { + self.model_servers_declared + .iter() + .filter_map(|slug| ModelServerKind::from_slug(slug)) + .map(|kind| crate::config::ModelServer { + name: kind.slug().to_string(), + kind: kind.slug().to_string(), + base_url: kind.default_base_url().map(str::to_string), + api_key_env: kind.default_api_key_env().map(str::to_string), + extra_env: std::collections::HashMap::new(), + display_name: Some(kind.display_name().to_string()), + }) + .collect() + } + + /// Git providers the wizard offers, from the integration catalog. + pub(crate) fn git_providers() -> Vec { + onboardable(Vertical::Git) + } + + /// Model providers the wizard offers, from the integration catalog. Each + /// resolves to a `ModelServerKind` (guaranteed by `tests/vertical_parity.rs`). + pub(crate) fn model_providers() -> Vec<(CatalogEntry, ModelServerKind)> { + onboardable(Vertical::Model) + .into_iter() + .filter_map(|e| ModelServerKind::from_slug(e.slug).map(|k| (e, k))) + .collect() + } + + /// The highlighted provider slug, or `None` for the trailing "skip" row. + pub(crate) fn selected_git_provider(&self) -> Option { + let providers = Self::git_providers(); + self.git_provider_state + .selected() + .and_then(|i| providers.get(i)) + .map(|e| e.slug.to_string()) + } + + /// Consume a pending request to connect a git provider. + pub fn take_git_connect_request(&mut self) -> Option { + self.git_connect_requested.take() + } + + /// Record the outcome of a connect attempt for display. + pub fn set_git_provider_status(&mut self, slug: &str, status: String) { + self.git_provider_status.insert(slug.to_string(), status); + } + + /// Consume a pending request to open the kanban onboarding dialog. + pub fn take_kanban_dialog_request(&mut self) -> bool { + std::mem::take(&mut self.kanban_dialog_requested) + } + pub fn confirm(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => { @@ -562,23 +841,24 @@ impl SetupScreen { SetupResult::Continue } SetupStep::KanbanInfo => { - // Configure valid providers, otherwise proceed to the collection step. - if self.valid_kanban_providers.is_empty() || self.kanban_skipped { - self.enter_collection_source(); + // Row 0 hands off to the shared onboarding dialog, which + // collects credentials and writes the provider section. + if self.kanban_choice_state.selected() == Some(0) { + self.kanban_dialog_requested = true; } else { - self.step = SetupStep::KanbanProviderSetup { provider_index: 0 }; + self.step = SetupStep::ModelServer; } SetupResult::Continue } - SetupStep::KanbanProviderSetup { provider_index } => { - // Move to the next provider or on to the collection step. - let next_index = provider_index + 1; - if next_index < self.valid_kanban_providers.len() { - self.step = SetupStep::KanbanProviderSetup { - provider_index: next_index, - }; - } else { - self.enter_collection_source(); + SetupStep::ModelServer => { + self.step = SetupStep::GitProvider; + SetupResult::Continue + } + SetupStep::GitProvider => { + // Row 0..n connect; the last row moves on without a provider. + match self.selected_git_provider() { + Some(slug) => self.git_connect_requested = Some(slug), + None => self.enter_collection_source(), } SetupResult::Continue } @@ -643,7 +923,38 @@ impl SetupScreen { self.selected_wrapper = options[i].to_wrapper_type(); } } - // Navigate to worktree preference step + self.step = SetupStep::ExecutionTarget; + SetupResult::Continue + } + SetupStep::ExecutionTarget => { + if self.coder_target_selected() { + if self.selected_wrapper == SessionWrapperType::Zellij { + self.execution_target_error = + Some("Coder targets cannot use the Zellij session wrapper".to_string()); + return SetupResult::Continue; + } + if self.coder_target_name.trim().is_empty() { + self.execution_target_error = + Some("Coder target name is required".to_string()); + return SetupResult::Continue; + } + if matches!( + self.coder_target_name.trim(), + crate::config::TARGET_LOCAL | crate::config::TARGET_DOCKER + ) { + self.execution_target_error = Some(format!( + "'{}' is reserved for a built-in target", + self.coder_target_name.trim() + )); + return SetupResult::Continue; + } + if self.coder_template.trim().is_empty() { + self.execution_target_error = + Some("Coder template is required".to_string()); + return SetupResult::Continue; + } + self.use_worktrees = false; + } self.step = SetupStep::WorktreePreference; SetupResult::Continue } @@ -652,7 +963,8 @@ impl SetupScreen { if let Some(i) = self.worktree_state.selected() { let options = WorktreeOption::all(); if i < options.len() { - self.use_worktrees = options[i].to_use_worktrees(); + self.use_worktrees = + !self.coder_target_selected() && options[i].to_use_worktrees(); } } // The wrapper fan-out now lives on the AdminPassword arm, so @@ -724,26 +1036,16 @@ impl SetupScreen { self.step = SetupStep::Welcome; SetupResult::Continue } - SetupStep::KanbanProviderSetup { provider_index } => { - if provider_index > 0 { - self.step = SetupStep::KanbanProviderSetup { - provider_index: provider_index - 1, - }; - } else { - self.step = SetupStep::KanbanInfo; - } + SetupStep::ModelServer => { + self.step = SetupStep::KanbanInfo; + SetupResult::Continue + } + SetupStep::GitProvider => { + self.step = SetupStep::ModelServer; SetupResult::Continue } SetupStep::CollectionSource => { - // Return to the kanban step that preceded the collection step. - if !self.valid_kanban_providers.is_empty() && !self.kanban_skipped { - let last_index = self.valid_kanban_providers.len() - 1; - self.step = SetupStep::KanbanProviderSetup { - provider_index: last_index, - }; - } else { - self.step = SetupStep::KanbanInfo; - } + self.step = SetupStep::GitProvider; SetupResult::Continue } SetupStep::HostedCollectionFetch => { @@ -764,6 +1066,10 @@ impl SetupScreen { SetupResult::Continue } SetupStep::WorktreePreference => { + self.step = SetupStep::ExecutionTarget; + SetupResult::Continue + } + SetupStep::ExecutionTarget => { self.step = SetupStep::SessionWrapperChoice; SetupResult::Continue } @@ -836,15 +1142,15 @@ impl SetupScreen { SetupStep::HostedCollectionFetch => self.render_hosted_collection_step(frame), SetupStep::TaskFieldConfig => self.render_task_field_config_step(frame), SetupStep::SessionWrapperChoice => self.render_session_wrapper_choice_step(frame), + SetupStep::ExecutionTarget => self.render_execution_target_step(frame), SetupStep::WorktreePreference => self.render_worktree_preference_step(frame), SetupStep::TmuxOnboarding => self.render_tmux_onboarding_step(frame), SetupStep::VSCodeSetup => self.render_vscode_setup_step(frame), SetupStep::CmuxSetup => self.render_cmux_setup_step(frame), SetupStep::ZellijSetup => self.render_zellij_setup_step(frame), SetupStep::KanbanInfo => self.render_kanban_info_step(frame), - SetupStep::KanbanProviderSetup { provider_index } => { - self.render_kanban_provider_setup_step(frame, provider_index); - } + SetupStep::ModelServer => self.render_model_server_step(frame), + SetupStep::GitProvider => self.render_git_provider_step(frame), SetupStep::AdminPassword => self.render_admin_password_step(frame), SetupStep::AcceptanceCriteria => self.render_acceptance_criteria_step(frame), SetupStep::StartupTickets => self.render_startup_tickets_step(frame), diff --git a/src/ui/setup/steps/git.rs b/src/ui/setup/steps/git.rs new file mode 100644 index 00000000..de27fbd1 --- /dev/null +++ b/src/ui/setup/steps/git.rs @@ -0,0 +1,135 @@ +//! Git provider step: connect a provider so agents can branch, push and PR. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::api::cli_detection::onboarding_spec_for_slug; +use crate::integrations::catalog::CatalogEntry; +use crate::ui::dialogs::centered_rect; + +use super::super::SetupScreen; + +impl SetupScreen { + pub(crate) fn render_git_provider_step(&mut self, frame: &mut Frame) { + let area = centered_rect(70, 80, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Git Provider ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Title + Constraint::Length(3), // Description + Constraint::Min(6), // Provider rows + Constraint::Length(3), // Token note + Constraint::Length(2), // Footer + ]) + .split(inner); + + let title = Paragraph::new(Line::from(vec![Span::styled( + "Branches, pushes and pull requests", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )])); + frame.render_widget(title, chunks[0]); + + let description = Paragraph::new(vec![ + Line::from("Operator adopts an existing CLI login where it finds one,"), + Line::from("and otherwise asks for a personal access token."), + ]) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(description, chunks[1]); + + let providers = SetupScreen::git_providers(); + let selected = self.git_provider_state.selected().unwrap_or(0); + let mut rows: Vec = providers + .iter() + .enumerate() + .map(|(i, entry)| self.git_provider_row(entry, i == selected)) + .collect(); + rows.push(Line::from("")); + rows.push(row_line( + "Continue without a git provider", + selected == providers.len(), + None, + )); + frame.render_widget(Paragraph::new(rows), chunks[2]); + + let note = match &self.git_export_hint { + Some(export) => Paragraph::new(vec![ + Line::from("Token exported for this session only. To keep it:"), + Line::from(Span::styled( + export.clone(), + Style::default().fg(Color::Cyan), + )), + ]), + None => Paragraph::new(vec![ + Line::from("config.toml records only the env var name holding the token."), + Line::from("Export it in your shell to keep it past this session."), + ]) + .style(Style::default().fg(Color::DarkGray)), + }; + frame.render_widget(note, chunks[3]); + + let footer = Line::from(vec![ + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Connect "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Back"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[4], + ); + } + + fn git_provider_row(&self, entry: &CatalogEntry, highlighted: bool) -> Line<'static> { + let status = self + .git_provider_status + .get(entry.slug) + .cloned() + .unwrap_or_else(|| match onboarding_spec_for_slug(entry.slug) { + Some(spec) => format!("via {}, or a token", spec.command), + None => "personal access token".to_string(), + }); + row_line(entry.label, highlighted, Some(status)) + } +} + +fn row_line(label: &str, highlighted: bool, status: Option) -> Line<'static> { + let (marker, color) = if highlighted { + ("> ", Color::Cyan) + } else { + (" ", Color::Gray) + }; + let mut spans = vec![ + Span::raw(" "), + Span::styled(marker, Style::default().fg(color)), + Span::styled(label.to_string(), Style::default().fg(color)), + ]; + if let Some(status) = status { + let status_color = if status.starts_with("connected") { + Color::Green + } else { + Color::DarkGray + }; + spans.push(Span::raw(" ")); + spans.push(Span::styled(status, Style::default().fg(status_color))); + } + Line::from(spans) +} diff --git a/src/ui/setup/steps/kanban.rs b/src/ui/setup/steps/kanban.rs index c68eddac..0ba6206e 100644 --- a/src/ui/setup/steps/kanban.rs +++ b/src/ui/setup/steps/kanban.rs @@ -35,7 +35,9 @@ impl SetupScreen { Constraint::Length(6), // Supported providers list (4 providers) Constraint::Length(1), // Spacer Constraint::Length(2), // Detected header - Constraint::Min(6), // Detected providers list + Constraint::Min(4), // Detected providers list + Constraint::Length(1), // Spacer + Constraint::Length(2), // Action rows Constraint::Length(2), // Footer/help ]) .split(inner); @@ -126,8 +128,7 @@ impl SetupScreen { Style::default().fg(Color::DarkGray), )])); } else { - for (i, provider) in self.detected_kanban_providers.iter().enumerate() { - let is_valid = self.valid_kanban_providers.contains(&i); + for provider in &self.detected_kanban_providers { let (icon, icon_color) = match &provider.status { ProviderStatus::Untested => ("?", Color::Yellow), ProviderStatus::Testing => ("~", Color::Yellow), @@ -155,14 +156,7 @@ impl SetupScreen { Span::raw(" ["), Span::styled(icon, Style::default().fg(icon_color)), Span::raw("] "), - Span::styled( - provider_name, - Style::default().fg(if is_valid { - Color::White - } else { - Color::DarkGray - }), - ), + Span::styled(provider_name, Style::default().fg(Color::White)), Span::raw(" - "), Span::styled(&provider.domain, Style::default().fg(Color::Cyan)), Span::raw(" ("), @@ -174,153 +168,38 @@ impl SetupScreen { let detected_list = Paragraph::new(detected_lines); frame.render_widget(detected_list, chunks[7]); - // Footer - let footer = if self.valid_kanban_providers.is_empty() { - Line::from(vec![ - Span::styled("[Enter]", Style::default().fg(Color::Yellow)), - Span::raw(" Continue "), - Span::styled("[Esc]", Style::default().fg(Color::Yellow)), - Span::raw(" Back"), - ]) - } else { - Line::from(vec![ - Span::styled("[Enter]", Style::default().fg(Color::Yellow)), - Span::raw(" Configure providers "), - Span::styled("[S]", Style::default().fg(Color::Yellow)), - Span::raw(" Skip "), - Span::styled("[Esc]", Style::default().fg(Color::Yellow)), - Span::raw(" Back"), - ]) - }; - let footer_para = Paragraph::new(footer).alignment(Alignment::Center); - frame.render_widget(footer_para, chunks[8]); - } - - pub(crate) fn render_kanban_provider_setup_step( - &mut self, - frame: &mut Frame, - provider_index: usize, - ) { - let area = centered_rect(70, 80, frame.area()); - frame.render_widget(Clear, area); - - // Get the provider being configured - let provider_idx = self - .valid_kanban_providers - .get(provider_index) - .copied() - .unwrap_or(0); - let provider = self.detected_kanban_providers.get(provider_idx); - - let title = if let Some(p) = provider { - let provider_name = match p.provider_type { - KanbanProviderType::Jira => "Jira", - KanbanProviderType::Linear => "Linear", - KanbanProviderType::Github => "GitHub", - KanbanProviderType::Openspec => "OpenSpec", - }; - format!(" Setup: {} - {} ", provider_name, p.domain) - } else { - " Kanban Provider Setup ".to_string() - }; - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(2), // Instructions - Constraint::Length(1), // Spacer - Constraint::Min(10), // Project list - Constraint::Length(1), // Spacer - Constraint::Length(3), // Preview info - Constraint::Length(2), // Footer - ]) - .split(inner); - - // Instructions - let instructions = - Paragraph::new("Select a project to sync:").style(Style::default().fg(Color::Gray)); - frame.render_widget(instructions, chunks[0]); - - // Project list - if self.kanban_projects.is_empty() { - let loading = Paragraph::new(vec![ - Line::from(""), - Line::from(vec![Span::styled( - "Loading projects...", - Style::default().fg(Color::Yellow), - )]), - Line::from(""), - Line::from(vec![Span::styled( - "(Projects will be fetched when you enter this step)", - Style::default().fg(Color::DarkGray), - )]), - ]) - .alignment(Alignment::Center); - frame.render_widget(loading, chunks[2]); - } else { - crate::ui::paginated_list::render_paginated_list( - frame, - chunks[2], - &mut self.kanban_projects, - "Projects", - |project, _selected| { - ratatui::widgets::ListItem::new(Line::from(vec![ - Span::styled( - format!("{:8}", project.key), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - "), - Span::styled(project.name.clone(), Style::default().fg(Color::White)), - ])) - }, - ); - } - - // Preview info - let preview = if self.kanban_issue_types.is_empty() { - Line::from(vec![Span::styled( - "Select a project to see details", - Style::default().fg(Color::DarkGray), - )]) - } else { - Line::from(vec![ - Span::styled("Issue Types: ", Style::default().fg(Color::Yellow)), - Span::styled( - self.kanban_issue_types.join(", "), - Style::default().fg(Color::White), - ), - Span::raw(" | "), - Span::styled("Members: ", Style::default().fg(Color::Yellow)), - Span::styled( - self.kanban_member_count.to_string(), - Style::default().fg(Color::White), - ), - ]) - }; - let preview_para = Paragraph::new(preview); - frame.render_widget(preview_para, chunks[4]); + // Actions. Connecting hands off to the shared onboarding dialog, so + // credentials are collected the same way here and from the dashboard. + let selected = self.kanban_choice_state.selected().unwrap_or(0); + let action_rows = Paragraph::new(vec![ + choice_line("Connect a kanban provider", selected == 0), + choice_line("Skip for now", selected == 1), + ]); + frame.render_widget(action_rows, chunks[9]); - // Footer let footer = Line::from(vec![ - Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), Span::raw(" Select "), - Span::styled("[n/p]", Style::default().fg(Color::Yellow)), - Span::raw(" Page "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Confirm "), Span::styled("[Esc]", Style::default().fg(Color::Yellow)), - Span::raw(" Skip provider"), + Span::raw(" Back"), ]); let footer_para = Paragraph::new(footer).alignment(Alignment::Center); - frame.render_widget(footer_para, chunks[5]); + frame.render_widget(footer_para, chunks[10]); } } + +/// A selectable action row on the kanban info step. +fn choice_line(label: &str, selected: bool) -> Line<'_> { + let (marker, color) = if selected { + ("> ", Color::Cyan) + } else { + (" ", Color::Gray) + }; + Line::from(vec![ + Span::raw(" "), + Span::styled(marker, Style::default().fg(color)), + Span::styled(label.to_string(), Style::default().fg(color)), + ]) +} diff --git a/src/ui/setup/steps/mod.rs b/src/ui/setup/steps/mod.rs index c5207255..125b7e2e 100644 --- a/src/ui/setup/steps/mod.rs +++ b/src/ui/setup/steps/mod.rs @@ -4,9 +4,12 @@ mod acceptance; mod admin_password; mod collection; mod confirm; +mod git; mod hosted; mod kanban; +mod model_server; mod startup; +mod target; mod task_fields; mod welcome; mod wrapper; @@ -14,9 +17,12 @@ mod wrapper; pub use acceptance::*; pub use collection::*; pub use confirm::*; +pub use git::*; pub use hosted::*; pub use kanban::*; +pub use model_server::*; pub use startup::*; +pub use target::*; pub use task_fields::*; pub use welcome::*; pub use wrapper::*; diff --git a/src/ui/setup/steps/model_server.rs b/src/ui/setup/steps/model_server.rs new file mode 100644 index 00000000..74573e18 --- /dev/null +++ b/src/ui/setup/steps/model_server.rs @@ -0,0 +1,150 @@ +//! Model server step: declare which providers this workspace uses. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::api::providers::model_server::ModelServerKind; +use crate::integrations::catalog::CatalogEntry; +use crate::ui::dialogs::centered_rect; + +use super::super::SetupScreen; + +impl SetupScreen { + pub(crate) fn render_model_server_step(&mut self, frame: &mut Frame) { + let area = centered_rect(70, 80, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title(" Model Providers ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), // Title + Constraint::Length(3), // Description + Constraint::Min(10), // Provider rows + Constraint::Length(2), // Key hint + Constraint::Length(2), // Footer + ]) + .split(inner); + + let title = Paragraph::new(Line::from(vec![Span::styled( + "Where inference happens", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )])); + frame.render_widget(title, chunks[0]); + + let description = Paragraph::new(vec![ + Line::from("Declaring a provider records it in config.toml. Operator stores"), + Line::from("only the name of the env var holding its key, never the key."), + ]) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(description, chunks[1]); + + let selected = self.model_server_state.selected().unwrap_or(0); + let mut rows = Vec::new(); + let mut last_class = None; + let providers = SetupScreen::model_providers(); + for (i, (entry, kind)) in providers.iter().enumerate() { + let class = kind.provider_class(); + if last_class != Some(class) { + rows.push(Line::from(vec![Span::styled( + format!(" {}", class.display_name()), + Style::default().fg(Color::Yellow), + )])); + last_class = Some(class); + } + rows.push(self.provider_row(entry, *kind, i == selected)); + } + frame.render_widget(Paragraph::new(rows), chunks[2]); + + let hint = match self + .model_server_state + .selected() + .and_then(|i| providers.get(i).map(|(_, k)| k)) + { + Some(kind) if !kind.connectable_from_defaults() => { + Paragraph::new(Line::from(vec![Span::styled( + "Needs a base URL - add a [[model_servers]] entry to config.toml.", + Style::default().fg(Color::DarkGray), + )])) + } + Some(kind) => match kind.default_api_key_env() { + Some(env) => Paragraph::new(Line::from(vec![ + Span::raw("Key env var: "), + Span::styled(env, Style::default().fg(Color::Cyan)), + ])), + None => Paragraph::new(Line::from(vec![Span::styled( + "No API key required.", + Style::default().fg(Color::DarkGray), + )])), + }, + None => Paragraph::new(""), + }; + frame.render_widget(hint, chunks[3]); + + let footer = Line::from(vec![ + Span::styled("[↑/↓]", Style::default().fg(Color::Yellow)), + Span::raw(" Navigate "), + Span::styled("[Space]", Style::default().fg(Color::Yellow)), + Span::raw(" Declare "), + Span::styled("[Enter]", Style::default().fg(Color::Yellow)), + Span::raw(" Continue "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow)), + Span::raw(" Back"), + ]); + frame.render_widget( + Paragraph::new(footer).alignment(Alignment::Center), + chunks[4], + ); + } + + fn provider_row( + &self, + entry: &CatalogEntry, + kind: ModelServerKind, + highlighted: bool, + ) -> Line<'static> { + let slug = kind.slug(); + let declared = self.model_servers_declared.iter().any(|s| s == slug); + let marker = if highlighted { "> " } else { " " }; + let checkbox = if declared { "[x]" } else { "[ ]" }; + let name_color = if highlighted { + Color::Cyan + } else { + Color::White + }; + + let (status, status_color) = if kind.connectable_from_defaults() { + match self.model_server_probes.get(slug) { + Some(s) if s.ends_with("models") => (s.clone(), Color::Green), + Some(s) => (s.clone(), Color::DarkGray), + None => ("checking...".to_string(), Color::DarkGray), + } + } else { + ("needs base URL".to_string(), Color::DarkGray) + }; + + Line::from(vec![ + Span::raw(" "), + Span::styled(marker, Style::default().fg(name_color)), + Span::styled(checkbox, Style::default().fg(name_color)), + Span::raw(" "), + Span::styled(entry.label, Style::default().fg(name_color)), + Span::raw(" "), + Span::styled(status, Style::default().fg(status_color)), + ]) + } +} diff --git a/src/ui/setup/steps/target.rs b/src/ui/setup/steps/target.rs new file mode 100644 index 00000000..958a68e7 --- /dev/null +++ b/src/ui/setup/steps/target.rs @@ -0,0 +1,149 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::config::{DEFAULT_CODER_TOKEN_ENV, DEFAULT_CODER_URL_ENV}; +use crate::ui::dialogs::centered_rect; +use crate::ui::setup::{ + CoderSetupField, SetupScreen, CODER_TARGET_OPTION_INDEX, LOCAL_TARGET_OPTION_INDEX, +}; + +impl SetupScreen { + pub(crate) fn render_execution_target_step(&self, frame: &mut Frame) { + let area = centered_rect(72, 76, frame.area()); + frame.render_widget(Clear, area); + let block = Block::default() + .title(" Execution Target ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(2), + Constraint::Length(4), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(2), + Constraint::Length(2), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new("Where agent commands run").style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + chunks[0], + ); + + let selected = self + .execution_target_state + .selected() + .unwrap_or(LOCAL_TARGET_OPTION_INDEX); + frame.render_widget( + Paragraph::new(vec![ + target_line( + "Local", + selected == LOCAL_TARGET_OPTION_INDEX, + "run beside Operator", + ), + target_line( + "Coder", + selected == CODER_TARGET_OPTION_INDEX, + "one workspace per ticket over SSH", + ), + ]), + chunks[1], + ); + + if selected == CODER_TARGET_OPTION_INDEX { + render_field( + frame, + chunks[2], + "Target name", + &self.coder_target_name, + self.coder_field == CoderSetupField::TargetName, + ); + render_field( + frame, + chunks[3], + "Coder template", + &self.coder_template, + self.coder_field == CoderSetupField::Template, + ); + let credentials = format!( + "{DEFAULT_CODER_URL_ENV}: {} {DEFAULT_CODER_TOKEN_ENV}: {}", + env_status(DEFAULT_CODER_URL_ENV), + env_status(DEFAULT_CODER_TOKEN_ENV) + ); + frame.render_widget( + Paragraph::new(credentials).style(Style::default().fg(Color::DarkGray)), + chunks[4], + ); + } + + if let Some(error) = &self.execution_target_error { + frame.render_widget( + Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), + chunks[5], + ); + } + + frame.render_widget( + Paragraph::new("↑/↓ Select Tab Switch field Enter Continue Esc Back") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)), + chunks[6], + ); + } +} + +fn target_line(name: &str, selected: bool, description: &str) -> Line<'static> { + let marker = if selected { "(o)" } else { "( )" }; + let color = if selected { Color::Cyan } else { Color::Gray }; + Line::from(vec![ + Span::styled(format!("{marker} {name}"), Style::default().fg(color)), + Span::raw(format!(" {description}")), + ]) +} + +fn render_field( + frame: &mut Frame, + area: ratatui::layout::Rect, + label: &str, + value: &str, + focused: bool, +) { + let border = if focused { + Color::Cyan + } else { + Color::DarkGray + }; + frame.render_widget( + Paragraph::new(value.to_string()).block( + Block::default() + .title(format!(" {label} ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(border)), + ), + area, + ); +} + +fn env_status(name: &str) -> &'static str { + if std::env::var_os(name).is_some() { + "set" + } else { + "missing" + } +} diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index d05aa981..3b59b325 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -2,6 +2,7 @@ use super::types::*; use super::SetupScreen; +use crate::api::providers::model_server::ModelServerKind; use crate::config::SessionWrapperType; use std::collections::HashMap; @@ -180,9 +181,9 @@ fn test_setup_navigation_to_worktree_preference() { screen.selected_wrapper = SessionWrapperType::Tmux; screen.wrapper_state.select(Some(0)); // Select tmux - // SessionWrapperChoice -> WorktreePreference + // SessionWrapperChoice -> ExecutionTarget screen.confirm(); - assert_eq!(screen.step, SetupStep::WorktreePreference); + assert_eq!(screen.step, SetupStep::ExecutionTarget); } #[test] @@ -218,7 +219,55 @@ fn test_setup_worktree_preference_go_back() { screen.step = SetupStep::WorktreePreference; screen.go_back(); - assert_eq!(screen.step, SetupStep::SessionWrapperChoice); + assert_eq!(screen.step, SetupStep::ExecutionTarget); +} + +#[test] +fn test_execution_target_local_is_the_default() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionTarget; + + assert_eq!( + screen.selected_execution_target().kind, + crate::config::TargetKind::Local + ); + screen.confirm(); + assert_eq!(screen.step, SetupStep::WorktreePreference); +} + +#[test] +fn test_execution_target_coder_requires_template() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionTarget; + screen.execution_target_state.select(Some(1)); + screen.coder_template.clear(); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::ExecutionTarget); + assert!(screen + .execution_target_error + .as_deref() + .unwrap_or_default() + .contains("template")); +} + +#[test] +fn test_execution_target_coder_builds_target_and_disables_worktrees() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionTarget; + screen.execution_target_state.select(Some(1)); + screen.coder_template = "operator-agent".to_string(); + screen.use_worktrees = true; + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::WorktreePreference); + assert!(!screen.use_worktrees); + assert!(matches!( + screen.selected_execution_target().kind, + crate::config::TargetKind::Coder(_) + )); } #[test] @@ -301,11 +350,15 @@ fn test_welcome_advances_to_kanban_info() { } #[test] -fn test_kanban_info_no_providers_advances_to_collection_source() { +fn test_kanban_skip_advances_to_curated_collection_source() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); screen.step = SetupStep::KanbanInfo; - // No valid providers -> straight to the collection source step. - screen.confirm(); + screen.select_next(); // "Skip for now" + + screen.confirm(); // -> ModelServer + screen.confirm(); // -> GitProvider + skip_git_provider(&mut screen); + assert_eq!(screen.step, SetupStep::CollectionSource); // Curated options only (no per-provider import options). assert_eq!(screen.source_options, CollectionSourceOption::curated()); @@ -662,3 +715,397 @@ fn test_wizard_command_characters_are_typable_in_a_password() { assert_eq!(screen.password.value(), "ick j"); assert_eq!(screen.step, SetupStep::AdminPassword, "still on the step"); } + +// ─── Kanban step (defect A: the step used to be unreachable dead code) ────── + +/// Move the git step's cursor to its trailing "continue without" row and +/// confirm, so a walk does not stall on a live connect attempt. +fn skip_git_provider(screen: &mut SetupScreen) { + for _ in 0..SetupScreen::git_providers().len() { + screen.select_next(); + } + screen.confirm(); +} + +fn at_kanban_info() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::KanbanInfo; + screen +} + +#[test] +fn test_kanban_info_follows_welcome() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::Welcome; + + screen.confirm(); + assert_eq!(screen.step, SetupStep::KanbanInfo); +} + +#[test] +fn test_kanban_info_defaults_to_the_connect_row() { + let screen = at_kanban_info(); + assert_eq!(screen.kanban_choice_state.selected(), Some(0)); +} + +#[test] +fn test_kanban_connect_requests_the_dialog_without_advancing() { + let mut screen = at_kanban_info(); + + screen.confirm(); + + assert_eq!( + screen.step, + SetupStep::KanbanInfo, + "the wizard waits on the dialog rather than moving on" + ); + assert!(screen.take_kanban_dialog_request()); + assert!( + !screen.take_kanban_dialog_request(), + "the request is consumed once, so the dialog opens once" + ); +} + +#[test] +fn test_kanban_skip_advances_without_requesting_the_dialog() { + let mut screen = at_kanban_info(); + screen.select_next(); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::ModelServer); + assert!(!screen.take_kanban_dialog_request()); +} + +#[test] +fn test_kanban_choice_selection_wraps() { + let mut screen = at_kanban_info(); + + screen.select_next(); + assert_eq!(screen.kanban_choice_state.selected(), Some(1)); + screen.select_next(); + assert_eq!(screen.kanban_choice_state.selected(), Some(0)); + screen.select_prev(); + assert_eq!(screen.kanban_choice_state.selected(), Some(1)); +} + +#[test] +fn test_collection_source_goes_back_to_git_provider() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::CollectionSource; + + screen.go_back(); + assert_eq!(screen.step, SetupStep::GitProvider); +} + +/// Defect A regression: `KanbanProviderSetup` sat in the step enum, rendered a +/// screen and was never reachable, because the collection it keyed off was +/// never populated. Walking every wrapper branch proves each catalogued step +/// is actually selected by the state machine. +#[test] +fn test_wizard_walk_visits_every_catalog_step() { + // Steps only reachable from a branch the walk below does not take. + let conditional = [ + SetupStep::HostedCollectionFetch, // needs the hosted picker chosen + ]; + + let mut visited = std::collections::HashSet::new(); + for wrapper in [ + SessionWrapperType::Tmux, + SessionWrapperType::Vscode, + SessionWrapperType::Cmux, + SessionWrapperType::Zellij, + ] { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.tmux_status = TmuxDetectionStatus::Available { + version: "3.4".to_string(), + }; + + visited.insert(screen.step); + for _ in 0..SetupStep::ALL.len() * 2 { + if screen.step == SetupStep::Confirm { + break; + } + // Skip past the kanban hand-off; the dialog is driven by the app. + if screen.step == SetupStep::KanbanInfo { + screen.select_next(); + } + // Connecting shells out to provider CLIs; take the skip row. + if screen.step == SetupStep::GitProvider { + for _ in 0..SetupScreen::git_providers().len() { + screen.select_next(); + } + } + // `confirm` commits the highlighted wrapper, so steer the list. + if screen.step == SetupStep::SessionWrapperChoice { + let i = SessionWrapperOption::all() + .iter() + .position(|o| o.to_wrapper_type() == wrapper) + .expect("every wrapper is offered"); + screen.wrapper_state.select(Some(i)); + } + screen.confirm(); + screen.take_kanban_dialog_request(); + visited.insert(screen.step); + } + assert_eq!( + screen.step, + SetupStep::Confirm, + "{wrapper:?} branch never reached Confirm" + ); + } + + for step in SetupStep::ALL { + if conditional.contains(&step) { + continue; + } + assert!( + visited.contains(&step), + "{step:?} is in the catalog but no wizard path reaches it" + ); + } +} + +// ─── Model server step (D1a) ─────────────────────────────────────────────── + +fn at_model_server() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ModelServer; + screen +} + +fn select_kind(screen: &mut SetupScreen, kind: ModelServerKind) { + let i = SetupScreen::model_providers() + .iter() + .position(|(_, k)| *k == kind) + .expect("kind is offered by the wizard"); + screen.model_server_state.select(Some(i)); +} + +#[test] +fn test_model_server_step_follows_kanban_skip() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::KanbanInfo; + screen.select_next(); // "Skip for now" + + screen.confirm(); + assert_eq!(screen.step, SetupStep::ModelServer); +} + +#[test] +fn test_model_server_advances_to_git_provider() { + let mut screen = at_model_server(); + + screen.confirm(); + assert_eq!(screen.step, SetupStep::GitProvider); +} + +#[test] +fn test_model_server_go_back_returns_to_kanban_info() { + let mut screen = at_model_server(); + + screen.go_back(); + assert_eq!(screen.step, SetupStep::KanbanInfo); +} + +#[test] +fn test_model_server_declares_nothing_by_default() { + let screen = at_model_server(); + assert!(screen.declared_model_servers().is_empty()); +} + +#[test] +fn test_model_server_toggle_declares_the_highlighted_kind() { + let mut screen = at_model_server(); + select_kind(&mut screen, ModelServerKind::Ollama); + + screen.toggle_selection(); + + let declared = screen.declared_model_servers(); + assert_eq!(declared.len(), 1); + assert_eq!(declared[0].kind, ModelServerKind::Ollama.slug()); + assert_eq!( + declared[0].base_url.as_deref(), + ModelServerKind::Ollama.default_base_url() + ); +} + +#[test] +fn test_model_server_toggle_is_reversible() { + let mut screen = at_model_server(); + select_kind(&mut screen, ModelServerKind::Ollama); + + screen.toggle_selection(); + screen.toggle_selection(); + + assert!(screen.declared_model_servers().is_empty()); +} + +/// The key is referenced by env-var name; the secret never reaches config. +#[test] +fn test_declared_server_records_the_key_env_name_not_a_secret() { + let mut screen = at_model_server(); + select_kind(&mut screen, ModelServerKind::AnthropicApi); + + screen.toggle_selection(); + + let declared = screen.declared_model_servers(); + assert_eq!( + declared[0].api_key_env.as_deref(), + ModelServerKind::AnthropicApi.default_api_key_env() + ); +} + +/// Declaring writes a `[[model_servers]]` entry from kind defaults, so every +/// offered provider must actually have a default base URL to write. +#[test] +fn test_every_offered_model_provider_is_connectable_from_defaults() { + for (entry, kind) in SetupScreen::model_providers() { + assert!( + kind.connectable_from_defaults(), + "{} is offered but has no default base URL to declare", + entry.slug + ); + } +} + +#[test] +fn test_model_server_navigation_wraps_over_every_offered_provider() { + let mut screen = at_model_server(); + let len = SetupScreen::model_providers().len(); + assert_eq!(screen.model_server_state.selected(), Some(0)); + + for _ in 0..len { + screen.select_next(); + } + assert_eq!(screen.model_server_state.selected(), Some(0)); + + screen.select_prev(); + assert_eq!(screen.model_server_state.selected(), Some(len - 1)); +} + +// ─── Git provider step (D1b) ─────────────────────────────────────────────── + +fn at_git_provider() -> SetupScreen { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::GitProvider; + screen +} + +#[test] +fn test_git_provider_step_follows_model_server() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ModelServer; + + screen.confirm(); + assert_eq!(screen.step, SetupStep::GitProvider); +} + +#[test] +fn test_git_provider_go_back_returns_to_model_server() { + let mut screen = at_git_provider(); + + screen.go_back(); + assert_eq!(screen.step, SetupStep::ModelServer); +} + +/// The offered set comes from the integration catalog, so promoting a provider +/// into onboarding is a `SupportStatus` bump rather than an edit here. +#[test] +fn test_git_provider_offers_the_catalog_onboardable_set() { + let slugs: Vec<&str> = SetupScreen::git_providers() + .iter() + .map(|e| e.slug) + .collect(); + assert_eq!(slugs, vec!["github", "gitlab", "gitea"]); +} + +/// Proto entries are unadvertised and undocumented, so onboarding must not +/// surface them - in either provider vertical. +#[test] +fn test_wizard_offers_no_proto_providers() { + for slug in ["bitbucket", "azure", "forgejo"] { + assert!( + !SetupScreen::git_providers().iter().any(|e| e.slug == slug), + "{slug} is Proto and must not be offered" + ); + } + for slug in ["openai-compat", "lmstudio"] { + assert!( + !SetupScreen::model_providers() + .iter() + .any(|(e, _)| e.slug == slug), + "{slug} is Proto and must not be offered" + ); + } +} + +/// Every offered provider links out to its docs page from the wizard copy. +#[test] +fn test_offered_providers_are_documented() { + for entry in SetupScreen::git_providers() { + assert!(entry.docs_path.is_some(), "{}", entry.slug); + } + for (entry, _) in SetupScreen::model_providers() { + assert!(entry.docs_path.is_some(), "{}", entry.slug); + } +} + +#[test] +fn test_git_provider_enter_requests_the_highlighted_provider() { + let mut screen = at_git_provider(); + + screen.confirm(); + + assert_eq!( + screen.step, + SetupStep::GitProvider, + "the wizard waits on the connect attempt rather than moving on" + ); + assert_eq!(screen.take_git_connect_request().as_deref(), Some("github")); + assert!( + screen.take_git_connect_request().is_none(), + "the request is consumed once" + ); +} + +#[test] +fn test_git_provider_last_row_continues_without_a_provider() { + let mut screen = at_git_provider(); + for _ in 0..SetupScreen::git_providers().len() { + screen.select_next(); + } + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::CollectionSource); + assert!(screen.take_git_connect_request().is_none()); +} + +#[test] +fn test_git_provider_navigation_includes_the_skip_row() { + let mut screen = at_git_provider(); + let rows = SetupScreen::git_providers().len() + 1; + + for _ in 0..rows { + screen.select_next(); + } + assert_eq!(screen.git_provider_state.selected(), Some(0)); + + screen.select_prev(); + assert_eq!(screen.git_provider_state.selected(), Some(rows - 1)); + assert!(screen.selected_git_provider().is_none()); +} + +#[test] +fn test_git_provider_status_is_recorded_per_provider() { + let mut screen = at_git_provider(); + + screen.set_git_provider_status("gitlab", "connected as octocat".to_string()); + + assert_eq!( + screen.git_provider_status.get("gitlab").map(String::as_str), + Some("connected as octocat") + ); + assert!(!screen.git_provider_status.contains_key("github")); +} diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index b43cc1bd..8230c30e 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -156,6 +156,21 @@ pub enum SetupResult { Initialize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoderSetupField { + TargetName, + Template, +} + +impl CoderSetupField { + pub fn toggled(self) -> Self { + match self { + Self::TargetName => Self::Template, + Self::Template => Self::TargetName, + } + } +} + /// Startup ticket options for project initialization #[derive(Debug, Clone)] pub struct StartupTicketOption { @@ -324,42 +339,7 @@ impl WorktreeOption { } } -/// Steps in the setup process -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SetupStep { - /// Welcome splash screen with discovered projects - Welcome, - /// Select template collection source - CollectionSource, - /// Browse and multi-select hosted collections (fetched from the manifest URL) - HostedCollectionFetch, - /// Configure TASK optional fields - TaskFieldConfig, - /// Select session wrapper (tmux or vscode) - SessionWrapperChoice, - /// Git worktree preference (use worktrees vs in-place branches) - WorktreePreference, - /// Optional admin password for the web dashboard. Skipped entirely when an admin account already exists. - AdminPassword, - /// Tmux onboarding/help (only shown if tmux selected) - TmuxOnboarding, - /// VS Code extension setup (only shown if vscode selected) - VSCodeSetup, - /// cmux setup (only shown if cmux selected) - CmuxSetup, - /// Zellij setup (only shown if zellij selected) - ZellijSetup, - /// Kanban integration info and provider detection - KanbanInfo, - /// Per-provider setup with project selection (index into `valid_providers`) - KanbanProviderSetup { provider_index: usize }, - /// Review and configure acceptance criteria - AcceptanceCriteria, - /// Optional startup tickets creation - StartupTickets, - /// Confirm initialization - Confirm, -} +pub use crate::startup::steps::SetupStep; /// Which of the two password fields has focus. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 940514f2..1f62ead8 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -668,7 +668,8 @@ impl StatusSnapshot { let working_dir = std::env::current_dir() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); - let config_path = Config::operator_config_path() + let config_path = config + .operator_config_path_for() .to_string_lossy() .into_owned(); let tickets_dir = config.paths.tickets.clone(); diff --git a/superpowers/plans/2026-09-12-getting-started-parity-phase-a.md b/superpowers/plans/2026-09-12-getting-started-parity-phase-a.md new file mode 100644 index 00000000..7c17adfc --- /dev/null +++ b/superpowers/plans/2026-09-12-getting-started-parity-phase-a.md @@ -0,0 +1,549 @@ +# Getting-Started Parity - Phase A: audit + TUI alignment + +## Context + +Operator can be driven from two surfaces - the ratatui TUI (`operator`) and the +web SPA (`operator api`) - and both are supposed to land a new user at the same +place: a working `config.toml` with kanban, model and git providers connected. + +`docs/getting-started/index.md` is a five-line stub naming an order +(agent → kanban → git) that **neither surface implements**. The audit found the +mismatch is not editorial: the TUI wizard has an unreachable step, silently +discards three of the user's choices, and has two divergent workspace writers; +the web UI has no guided flow at all and locks its own configuration pages +behind a prerequisite only the TUI can satisfy. + +**Neither surface currently satisfies "by the end of this flow you have a usable +config.toml", and the web flow is not independently completable.** + +Sequencing is the user's: **audit and report first, then align UI and TUI, then +document as it exists.** + +### Scope of this plan + +The full arc runs ten stages. **This plan authorizes stages 0–5 only** - fix the +defects and land both new TUI steps (decision D1). The web wizard (D2), the REST +surface it needs, re-runnability and the documentation rewrite are deferred to a +follow-up plan, to be written once the TUI half is real and has been used. + +Docs are deliberately *not* in this phase: the user's rule is "document as it +exists", and after stage 5 only half the alignment exists. Writing the +getting-started flows now would document a state that stage 8 changes. + +Decisions already made by the user, carried forward: + +- **D1 (this plan)** - add a Model Server step and a Git Provider step to the TUI wizard. +- **D2 (deferred)** - a web wizard mirroring the TUI, writing the same `config.toml`. +- **D3 (deferred)** - either flow, run alone, ends with a usable `config.toml`. +- **D4 (deferred)** - docs last; per-surface flows link out to the existing + kanban/model/git pages rather than re-explaining them inline. + +> One open recommendation, easy to reverse: stage 2 promotes the step catalog to a +> single Rust source of truth. The user did not rule on this; it is on the critical +> path to D1 regardless, and the rationale is in Phase 2 below. + +> Per `CLAUDE.md`, once approved this plan should also be committed to +> `superpowers/plans/`. + +--- + +## Phase 1 - Audit report (complete) + +### Blocking defects + +| | Defect | Evidence | +|---|---|---| +| **A** | Kanban provider step is unreachable dead code | `valid_kanban_providers` (`src/ui/setup/mod.rs:70`) initialized empty at `:165`, **never pushed to anywhere**; `confirm()` at `:566` always takes the `is_empty()` branch | +| **B** | Three screens collect choices that are never persisted | `src/app/tickets.rs` has **zero** matches for `wrapper`/`worktree`/`acceptance`; `sessions.wrapper` and `git.use_worktrees` keep their defaults regardless of what the user picked | +| **C** | Two divergent workspace writers | `App::initialize_tickets` (`src/app/tickets.rs:19-207`) vs `setup::initialize_workspace` (`src/setup.rs:56-150`) - different dirs, different files, different config keys | +| **D** | Wizard can't be re-run | `setup_screen = Some(..)` only in `App::new`; `operator setup --interactive` is a stub (`src/main.rs:1146-1151`); trigger is the *queue dir*, not config (`src/app/mod.rs:157`) | +| **E** | Generated wizard doc is already wrong | `SETUP_STEPS` (`src/startup/mod.rs`) lists the pre-reorder order and mentions a git step that never existed; only guard is `assert_eq!(len(), 16)` at `:244` - it passed while the data was wrong | +| **F** | Web UI locks its own pages behind a TUI-only prerequisite | `refresh_tool_detection` runs only at `src/app/mod.rs:121`; `operator api` never detects ⇒ `llm` Yellow ⇒ **Model Providers + Delegators disabled in the sidebar** on real working routes | +| **G** | Git unreachable from the browser | Zero `/api/v1/git*` routes; `ConfigureGitProvider` has no `web_url()`, so `#/git` rows render as inert text | +| **H** | Kanban plumbing exists but has no SPA client | `PUT /api/v1/kanban/config` + validate/projects/statuses/session-env all persist correctly; only `vscode-extension/` calls them | + +Defects **F, G, H** are web-side and are addressed in the deferred phase. + +### Two further defects found during design (both verified) + +- **I** - `SetupOptions::kanban_provider` and `::llm_tool` (`src/setup.rs:30,32`) are + validated and printed by `cmd_setup` but **never read** by `initialize_workspace`. + `operator setup --kanban-provider jira --llm-tool claude` is a no-op. +- **J** - `Config::operator_config_path()` (`src/config.rs:691`) is a hardcoded + **relative** `.tickets/operator/config.toml`, and `Config::save()` uses it. The + tempdir tests at `src/setup.rs:351-499` therefore write into the **repo root** - + confirmed present on disk (2884 bytes, gitignored), which is why it went + unnoticed. This blocks writing parallel-safe tests for any new init path. + +### Corrections to assumptions + +- **Forgejo is not covered by git onboarding.** `onboarding_spec_for_slug` + (`src/api/cli_detection.rs:138`) returns `Some` only when `pat_url` is non-empty; + verified only `github`, `gitlab`, `gitea` have one. +- **Auth needs no work.** Browser session principals carry `Scope::ALL` + (`src/auth/store.rs:462`). + +### Lesser findings (for the deferred phase) + +- `POST /api/v1/collections/{name}/activate` is in-memory only - lost on restart. +- `PATCH /api/v1/configuration`, `PUT /api/v1/llm-tools/default` and the + model-server `PUT`/`DELETE` persist but no SPA page calls them. +- `#/settings/security` and `#/status` have no nav entry. +- Wizard footers advertise unbound `[S]`/`[R]`/`[T]`/`[n/p]`; `i` and `c` are live + on every screen (force-initialize / quit). +- Secrets are consistently stored **by reference** (env var *name*) across kanban, + git and model servers. Correct - preserve it. It is also what users trip on, + because a token exported after the process started is invisible to it. + +--- + +## Phase 2 - TUI alignment (stages 0–5) + +### Stage 0 - Defect J: make the config path derive from state + +Add `Config::operator_config_path_for(&self) -> PathBuf` deriving from +`paths.state`; keep the associated `operator_config_path()` for `Config::load()`'s +bootstrap (it must resolve a path before a `Config` exists). Point `Config::save()` +(`src/config.rs:769`) at the `&self` variant. + +For a normally-invoked process (cwd == workspace root) the resolved path is +identical, so behaviour is unchanged in practice - but it is a real semantic change +affecting every `config.save()` call site, so it gets **its own commit and its own +test**, ahead of everything else. It also stops `cargo test` scribbling in the repo +root, which is what makes stages 1–5 testable. + +### Stage 1 - Defects C, B, I: one workspace writer + +`initialize_workspace` is already the better implementation: it honours `force` via +`write_file_if_allowed`, writes `operator/templates/` plus the three interpolation +docs, and persists `git.use_worktrees`. `App::initialize_tickets` does none of that +and unconditionally `fs::write`s. + +Extend `SetupOptions` with the dropped choices, and make it **not save** so the +caller owns persistence: + +```rust +pub struct SetupOptions { + // existing: preset, force, task_fields, working_dir, + // kanban_provider, llm_tool, use_worktrees + pub wrapper: Option, + pub acceptance_criteria: Option, + pub custom_collection: Vec, + pub active_collection: Option, + pub hosted_collections: Vec, +} + +/// Creates directories, writes templates, mutates `config`. Does NOT persist - +/// the caller owns the save. +pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Result; +``` + +Dropping the internal save is what later lets a REST handler wrap the whole thing in +`ApiState::mutate_config` (which saves atomically **and** `replace_config`s, so a +running server isn't left on a stale snapshot - `src/rest/state.rs:156-167`). Doing +it now means the deferred phase needs no second refactor here. + +`App::initialize_tickets` shrinks to: build `SetupOptions` from `SetupScreen` → call +`initialize_workspace` → TUI-only tail (admin account, `generate_tmux_config`, +registry reload, startup tickets) → one `config.save()`. `cmd_setup` adds its own +`config.save()?`. + +**B and I fall out of this**: once `SetupOptions` carries them, `sessions.wrapper`, +`git.use_worktrees`, the acceptance text, `kanban_provider` and `llm_tool` all land. + +### Stage 2 - Defect E: promote the step catalog + +Defect E's guard already existed and still passed while the data was wrong - the +count never changed, only the order did. Sharing the **step catalog** (identity, +order, copy) replaces that with a compile-time guard. This is not the renderer +abstraction the user rejected; renderers stay hand-written. + +1. New `src/startup/steps.rs`; move `SetupStep` there from + `src/ui/setup/types.rs:329`. **Mandatory, not stylistic**: `src/rest` compiles in + the lib and must not reference bin-only `src/ui`, so the deferred REST surface + cannot see the enum where it lives today. Doing the move now avoids reopening + these files later. +2. Drop the `KanbanProviderSetup { provider_index }` payload - move the index onto + `SetupScreen` as a plain cursor field like every other step's. A data-free enum + makes `const ALL` and the derives trivial. +3. Replace the parallel `SETUP_STEPS` static with + `impl SetupStep { fn info(&self) -> SetupStepInfo }` - one exhaustive `match`, so + a new variant is a **compile error** until copy exists. `SETUP_STEPS` survives as + `ORDER.iter().map(SetupStep::info)`, leaving `src/docs_gen/startup.rs:39` untouched. +4. Add `ALL`, `ORDER`, `slug()`. One test: `ORDER` is a permutation of `ALL`. +5. `#[derive(TS, Serialize, Deserialize, ToSchema)] #[ts(export)]` → + `bindings/SetupStep.ts` via `make bindings`. Unused by the SPA until the deferred + phase, but generated from the start so the binding never has to be backfilled. + +**Defect E closes here**: regenerating `docs/startup/index.md` emits the real order. + +### Stage 3 - Defect A: bring the kanban step to life + +Deleting `KanbanProviderSetup` would leave the TUI unable to configure kanban while +a future web wizard trivially can - *creating* the asymmetry this work exists to +remove. So wire it up instead: + +- Populate `valid_kanban_providers` in the `Welcome` arm from + `p.has_required_env_vars()` - the same predicate `CollectionSourceOption::with_providers` + already uses (`src/ui/setup/types.rs:79`). +- Make the step persist via + `services::kanban_onboarding::{apply_config_request, set_session_env}` - **the same + two functions `PUT /api/v1/kanban/config` and `POST /api/v1/kanban/session-env` + call** (`src/rest/routes/kanban_onboarding.rs:104,136`). No new provider logic; + both surfaces converge on one service. +- Bind the advertised `[S]` skip key, or remove it from the footer. + +Also extract `startup::workspace_initialized(&config)` from `src/app/mod.rs:157` as +the single "is this set up?" predicate - cheap now, and the deferred phase depends +on both surfaces agreeing on it. + +~80 lines. + +### Target step order + +``` +Welcome → KanbanInfo → KanbanProviderSetup → ModelServer → GitProvider + → CollectionSource (→ HostedCollectionFetch) → TaskFieldConfig + → SessionWrapperChoice → WorktreePreference → AdminPassword + → {Tmux|VSCode|Cmux|Zellij} → AcceptanceCriteria → StartupTickets → Confirm +``` + +Kanban, model servers and git are the three external-service connections and are +siblings in the sidebar prereq chain, so they group consecutively. Kanban must stay +before `CollectionSource` (that step offers "import from a configured provider" - +`src/ui/setup/mod.rs:553`). The session wrapper is a local concern and reads better +next to its wrapper-specific follow-up. Changing this later is one edit to `ORDER`. + +### Stage 4 - TUI Model Server step (D1a) + +New `src/ui/setup/steps/model_server.rs`. Lists `ModelServerKind::ALL` grouped by +`provider_class()`, with a live status column from +`probe_models(&server, &EgressPolicy::from_config(&config))` against a transient +server built from kind defaults - byte-for-byte what +`routes::model_servers::kind_models` does (`src/rest/routes/model_servers.rs:353-390`). + +`probe_models` is async and `confirm()` is sync, so follow the existing precedent: +`src/app/keyboard.rs:57-72` awaits `load_hosted_collections` right after `confirm()` +returns `Continue` for the hosted step. Add `SetupScreen::probe_model_servers` and an +identical guard clause. + +Three sub-states, all preserving secret-by-reference: + +- env var present → Enter appends a `ModelServer` to `config.model_servers`; +- absent → an editable env-var-**name** field prefilled with `default_api_key_env()`, + plus a copy-paste shell export block (precedent: + `services::kanban_onboarding::build_shell_export_block_*`), and optionally a masked + paste that only `set_var`s for the running process; +- `openai-compat` / `lmstudio` (`connectable_from_defaults() == false`) require a + `base_url` before the row can be selected. + +No secret reaches disk. Fully skippable. + +### Stage 5 - TUI Git Provider step (D1b) - completes D1 + +New `src/ui/setup/steps/git.rs`, covering **github, gitlab, gitea** out of the box. +State per row comes from `git_onboarding::resolve_onboarding_with_config` +(`src/app/git_onboarding.rs:196`), reused verbatim: `InstallCli` / `AutoConfigured` +(Enter connects with zero typing) / `CollectToken` (opens the PAT page, shows the +existing `GitTokenDialog`). + +**Forgejo needs one extra line** - a `pat_url` on its `CliSpec` +(`src/api/cli_detection.rs:95-105`) plus `"forgejo"` arms in +`complete_git_onboarding` and `validate_token_with_config`. Forgejo speaks Gitea's +API and `tea` drives both, so the gitea path works verbatim with a different base +URL. Recommend including it (~15 lines); it closes the "config structs but no TUI +row" gap. Bitbucket and Azure DevOps have no PAT flow - leave them out. + +Two refactors this step requires: + +1. **`complete_git_onboarding` saves mid-flow** (`git_onboarding.rs:159`). Inside the + wizard that writes `config.toml` *before* Confirm, so a later Cancel leaves a + partial config. Split into `apply_git_provider` (mutate + `set_var`, no save) and + keep `complete_git_onboarding` as `apply + save` for the existing dashboard + callers (`src/app/status_actions.rs:166`, `src/app/keyboard.rs:333`). Same + one-writer discipline as stage 1. +2. **Key routing - verified hazard.** `src/app/keyboard.rs:21` has an unconditional + early `return Ok(())` for the setup screen at `:102`, and the git-token dialog + branch sits at `:326` - **below it**. The dialog would never receive keys while + the wizard is open, and typing a PAT would quit the app on the first `c`. Hoist + the dialog branch above the setup branch, or add a delegation guard at the top of + it. Precedent: the `AdminPassword` text-routing guard at `:26-39` exists for + exactly this reason. + +Add `git_onboarding::shell_export_block(provider, token_env)` and render it - kanban +has this block, git does not, and without it the token dies with the process. Make +`token_env` editable for users on `GH_TOKEN` or a per-host var. + +> `validate_*_token` uses `reqwest::blocking` inside an async fn. Fine in a TUI (it +> briefly stalls the event loop) - **but not fine in an axum handler**, which is a +> constraint the deferred REST work must respect via `spawn_blocking`. + +--- + +## Staging + +`make check` must pass at every boundary. All five stages are independently shippable. + +| # | Stage | Depends on | Regenerate | +|---|---|---|---| +| 0 | Defect J - `operator_config_path_for(&self)` | - | - | +| 1 | One writer - C + B + I | 0 | `docs/configuration/index.md` if doc-comments change | +| 2 | Step catalog - E | - | `make bindings`; `cargo run -- docs` → **defect E closed** | +| 3 | Kanban step alive - A; `workspace_initialized` | 2 | - | +| 4 | TUI Model Server step (D1a) | 2 | `cargo run -- docs` | +| 5 | TUI Git Provider step (D1b) | 2 | `cargo run -- docs`; watch `tests/vertical_parity.rs` for Forgejo | + +Critical path: 0 → 1 → 2 → 5. Stages 3 and 4 are off it and can land in parallel. + +--- + +## Testing + +Per repo TDD convention - write the failing test first. + +**In-file `#[cfg(test)] mod tests`:** + +- `src/config.rs` - `test_save_writes_under_paths_state_not_cwd` (stage 0). +- `src/setup.rs` (tempdir tests at `:351-499` are the model) - wrapper/worktree + persistence (B), acceptance criteria, kanban/llm options (I), + `test_initialize_workspace_does_not_save` (the stage-1 contract change), + `test_initialize_workspace_is_idempotent_without_force`. **Only safe after stage 0.** +- `src/startup/steps.rs` - replaces the hardcoded `len() == 16`: + `test_order_is_a_permutation_of_all`, `test_every_step_info_field_is_non_empty`, + `test_slugs_are_unique`, `test_slugs_match_frozen_snapshot` (slugs will key docs + URLs and, later, the SPA component map). +- `src/ui/setup/tests.rs` (664 lines; + `test_admin_password_step_follows_worktree_preference:465` is the model) - step-order + tests for the two new steps, `test_model_server_selection_appends_config_entry`, + `test_model_server_non_connectable_kind_requires_base_url`, + `test_git_selection_sets_provider_without_saving`, and the **defect A regression + test**: `test_wizard_walk_visits_every_catalog_step`, driving `confirm()` from + Welcome to Confirm across all four wrapper branches and asserting the union equals + `SetupStep::ALL` minus a documented allowlist. This is the test that would have + caught A on day one. +- `src/app/git_onboarding.rs` - Forgejo arm, `apply_git_provider` does not save. +- `src/app/keyboard.rs` - a dialog-over-wizard routing test, so the PAT field can + never again be eaten by the wizard's `c`/`i` bindings. + +**`tests/`:** + +- New `tests/setup_parity.rs`, using the `include_str!` source-scanning pattern from + `tests/surface_parity.rs`: every variant has non-empty info and appears in `ORDER`; + `bindings/SetupStep.ts` union equals the Rust slugs; **`docs/startup/index.md` + lists headings in `ORDER`** - the direct defect-E regression test. +- `tests/vertical_parity.rs` is an existing *constraint*, not new work: if the git + step advertises Forgejo, check its `SupportStatus` first - `Alpha`+ entries owe a + resolving docs page and a README badge. + +## End-to-end verification + +1. `make check` at every stage boundary. +2. **Clean room**: `rm -rf /tmp/opr-tui && mkdir -p /tmp/opr-tui && cd /tmp/opr-tui && operator`. + Walk the wizard end to end, choosing a non-default on every screen. +3. Assert on `/tmp/opr-tui/.tickets/operator/config.toml` that `sessions.wrapper`, + `git.use_worktrees`, `git.provider`, `git.

.enabled`, `[kanban.*]` and + `[[model_servers]]` all reflect what was chosen - the direct B/A/D1 check. +4. Confirm no config.toml appeared in the repo root after `cargo test` (defect J). +5. Re-run the wizard over the existing workspace (delete `.tickets/queue` for now; + proper re-entry is deferred) and confirm templates are **not** clobbered. +6. `cargo run -- docs && make bindings`, then `git diff --exit-code` to prove + generated artifacts are committed fresh - and eyeball that + `docs/startup/index.md` now lists the real order. + +--- + +## Deferred to the follow-up plan (stages 6–10) + +Recorded so the design is not lost. Do not build these now. + +- **6 - PREREQUISITE FOUND DURING STAGE 1.** `src/setup.rs` is **bin-only** + (`mod setup;` in `src/main.rs:39`; it is absent from `src/lib.rs`). `src/rest/` + compiles in the lib, so `POST /setup/initialize` **cannot** call + `setup::initialize_workspace` where it lives today. Stage 6c must first move + `src/setup.rs` into the lib (`pub mod setup;`), which also pulls in its deps - + `projects` and `startup` are already lib-private modules, so they only need + visibility widening, not relocation. Same class of constraint as the + `SetupStep` move in stage 2. Budget this before estimating 6c. +- **6 - Server enablers.** `startup::refresh_and_persist_detection` called from + `cmd_api` (fixes defect F: `llm` goes Green for web users, unlocking Model + Providers + Delegators); persist `collections/{name}/activate` through + `mutate_config`; `GET /setup/{status,steps}` + `POST /setup/initialize`; + `/api/v1/git/*` mirroring the kanban quartet (fixes defect G) - **every shelling + or `reqwest::blocking` call wrapped in `spawn_blocking`**; `ROUTE_RULES` entries + (`tests/route_scope_parity.rs` fails closed on any route missing from the table). +- **7 - `ui/src/api-client.ts`** kanban/git/setup methods; port from + `vscode-extension/src/kanban-onboarding.ts`, the working reference (fixes defect H). +- **8 - Web wizard (D2/D3).** `/#/onboarding` mounted as a **sibling of `setup`, not + inside `Layout`** (`ui/src/main.tsx:35-36`) - `Layout.tsx:22` disables nav items + whose `section.met` is false, so a wizard inside it would be surrounded by the + gating it exists to resolve. Component map `satisfies Record` so + `tsc` errors on a missing step. Plain React + CSS modules - + `tests/ui_packaging.rs` enforces a nine-entry dep allowlist and bans CSS-in-JS. + Render Startup Tickets read-only in v1 rather than shipping half-working ticket + creation and calling it parity. +- **9 - Re-runnability (defect D).** `SetupScreen::from_config` prefilling from live + config (`WorktreeOption::from_use_worktrees` already exists marked + `#[allow(dead_code)] // Useful for future config-to-UI state conversion`, + `src/ui/setup/types.rs:316` - this is that future); a TUI key; a real + `operator setup --interactive`; a web re-run entry. Re-run must be additive and + idempotent: `force = false`, kanban upserts, model servers PUT-or-POST (`POST` + 409s on duplicates, `src/rest/routes/model_servers.rs:143`), startup tickets + default to none. +- **10 - Docs (D4).** `docs/getting-started/index.md` stays slim: an **"expected + setup"** section naming the connections a working install needs, linking out to + `/getting-started/{kanban,model-servers,git,sessions}/` with **no inline provider + instructions**, then a chooser into two new **level-2** pages (one per surface), + both linking to `/startup/` rather than restating generated steps. + Constraints: nav entry in `docs/_data/navigation.yml` with a byte-matching title; + front matter exactly `title`/`description`/`layout: doc`; body starts at `##`; + absolute trailing-slash links; no callout/tab includes exist. + Verify with `cargo test --test docs_structure`. + +## Open items to confirm during implementation + +1. Forgejo's current `SupportStatus` in `src/integrations/catalog.rs` - determines + whether stage 5 also owes a docs page and README badge to satisfy + `tests/vertical_parity.rs`. +2. Stage 0's blast radius across all `config.save()` call sites. The resolved path + should be identical for normally-invoked processes, but this deserves its own + commit and review rather than being bundled. +3. Whether dropping the `KanbanProviderSetup` payload (stage 2) disturbs any + `go_back` logic in `src/ui/setup/mod.rs:720-825` beyond the mechanical change. + +--- + +## Implementation log (Phase A) + +Deviations from the plan as written, with reasons. + +- **Stage 3 reshaped.** The planned in-wizard per-provider step was gated on + `has_required_env_vars()`, i.e. it renders nothing unless the user has already + exported an API key - useless for the first-run audience it exists for, which + is very likely why `valid_kanban_providers` was never populated. On the user's + call the step now **delegates to the existing `K` onboarding dialog** (cold + onboarding: provider → credentials → live validation → project → config + + shell export). `SetupStep::KanbanProviderSetup` was removed from the catalog + (15 steps now), and `KanbanInfo` gained a connect/skip choice. +- **Keyboard routing fixed early.** The modal git-token and kanban dialogs were + below the setup screen's unconditional `return Ok(())`, so they could never + receive keys over the wizard. Hoisted above it in stage 3 rather than stage 5, + since stage 3 needed it first; stage 5 now inherits the fix. +- **Latent clobber fixed.** `services::kanban_onboarding::write_config` persists + straight to disk but `self.config` was never refreshed, so any later + `config.save()` wrote the kanban section away again. Pre-existing on the + dashboard path; certain once the wizard delegates (Confirm always saves). + `reload_config_after_kanban_write` now picks the result back up. +- **`SetupStep::ORDER` dropped.** `ALL` is declared in wizard order, so a + separate `ORDER` would have been the same array and its permutation test + vacuous. One ordered const instead. +- **Dead code removed along the way:** `App::generate_tmux_config`, + `Config::discover_projects_full`, `SetupScreen::{kanban_projects, + kanban_issue_types, kanban_member_count, kanban_skipped, + valid_kanban_providers}`, and `render_kanban_provider_setup_step`. +- **Defect J had a wider blast radius than expected** - three call sites + *reported* or *opened* the config path (`status_panel.rs`, + `services/kanban_onboarding.rs`, `rest/routes/kanban_onboarding.rs`) and would + have named a file `save()` no longer writes. All repointed. + +### Stages 4-5 + +- **Forgejo excluded, narrower than the plan recommended.** The plan proposed + adding a `pat_url` to bring Forgejo into the git step. Checking + `src/integrations/catalog.rs` settled the open item: Forgejo is **Proto**, + undocumented, with no `docs_path` - as are Bitbucket and Azure DevOps. Putting + a Proto integration in the primary onboarding flow would promote it ahead of + its support status and owe it docs under `tests/vertical_parity.rs`. The step + offers `GIT_PROVIDER_SLUGS = ["github", "gitlab", "gitea"]`, which is exactly + the Alpha-or-better set and exactly the set with a PAT flow. +- **Non-connectable model kinds are shown but not declarable.** Rather than an + inline `base_url` text field, `openai-compat` and `lmstudio` render with a + "needs base URL" hint and refuse toggling - the same restriction the web UI + applies (no Connect button), which keeps the two surfaces honest for D3. +- **The git token dialog defers its save inside the wizard.** `apply_git_provider` + when `setup_screen.is_some()`, `complete_git_onboarding` otherwise, so Confirm + stays the single persistence point and cancelling strands nothing. +- **Test churn is inherent to inserting steps.** Each new step invalidated the + ordering assertions of the step before it; those were updated, not deleted. + The wizard-walk test takes each new step's skip row so it does not shell out + to provider CLIs. + +### Verification + +`make check` passes (fmt + clippy `--locked --all-targets --all-features -D +warnings` + full test suite). Both parity tests were confirmed non-vacuous by +deliberately breaking them and watching them fail. + +One unrelated flake observed: `auth::schema::tests::test_concurrent_migration_of_one_database_is_safe` +failed once under full-suite load and passed 3/3 in isolation and on every +re-run. It is an 8-thread SQLite contention test in its own tempdir, touching +nothing in this change. + +### Pre-existing generated-artifact drift (not from this work) + +`cargo run -- docs` + `make bindings` do **not** reproduce the committed +artifacts on this branch, in both directions: + +- `docs/schemas/{config,metadata,state}.md` and `docs/schemas/openapi.json` + regenerate with em-dashes where the committed copies have hyphens; +- `src/schemas/issuetype_schema.json` regenerates with hyphens where the + committed copy has em-dashes. + +None of these files' sources were touched here. This matters for the deferred +stage 10: a docs task that runs the generators will surface this drift, and any +CI check doing `cargo run -- docs && git diff --exit-code` is not currently +clean. Worth a separate cleanup commit. + +### Catalog-derived provider lists (added after stage 5, before stage 8) + +The stage 4/5 steps keyed off per-vertical enums, and the git step's list was a +hand-written `GIT_PROVIDER_SLUGS` const - the catalog was consulted to *decide* +it, then the answer was hardcoded. Same drift class as defect E. + +Both steps now derive from `integrations::catalog::onboardable(vertical)` +(`Alpha`+ and documented). `GIT_PROVIDER_SLUGS` is deleted; rows, labels and +docs links come from `CatalogEntry`, and the slug→CLI mapping comes from +`cli_detection::onboarding_spec_for_slug`, which already owned it. + +Why the catalog rather than the enums: `integrations` is `pub mod` in `lib.rs`, +unlike `startup` and `setup`, so the stage-8 web wizard can read the same list +over the existing `/api/v1/integrations` without a module move. One list, three +surfaces; promoting a provider into onboarding is a `SupportStatus` bump. + +Consequences: +- Model Server drops from 7 rows to 5 (`openai-compat`, `lmstudio` are Proto). +- `toggle_model_server`'s `connectable_from_defaults()` guard is now unreachable + via the offered list. Kept (still correct if a future Alpha provider ships + without a default base URL), but the test that exercised it was replaced with + one asserting the real invariant rather than left to pass vacuously. +- Two hand-rules had coincidentally agreed: git used support status, model used + base-URL availability. Same answer today, divergent the moment a Beta provider + ships without a default base URL. + +Three guards, the third verified by reintroducing a hardcoded `"github"`: +`onboardable` never yields Proto/undocumented; the step files contain no +provider slug literals; `setup/mod.rs` must reference `onboardable(Vertical::*)`. + +### Open: pre-existing auth test flake (NOT from this work) + +`auth::schema::tests::test_concurrent_migration_of_one_database_is_safe` fails +~8% of the time **in isolation** (measured 2/25), with +`enabling WAL: database is locked`. It intermittently fails `make check`. + +`src/auth/schema.rs` is not in this change set. A hypothesis that +`apply_pragmas` sets `busy_timeout` *after* the `journal_mode = WAL` switch that +needs it was tested and **disproven** - reordering measured 2/25 before and +2/25 after, so the change was reverted. There is currently no working +explanation; SQLite's busy handler appears not to engage for this particular +conflict, but that was not established. Needs its own investigation. + +Treat a red `make check` on this branch as "check which test failed" until this +is resolved. + +### Final verification (stages 0-5 + catalog rewiring) + +lib 2206 passed / 0 failed; bin 2806 passed / 0 failed; 29 integration targets +all ok; `cargo fmt --check` clean; `clippy --locked --all-targets +--all-features -D warnings` clean. + +Not done: nobody has walked the two new screens in a terminal. They are covered +by unit tests and the reachability walk, which is not the same thing. diff --git a/superpowers/specs/2026-09-13-web-setup-wizard-design.md b/superpowers/specs/2026-09-13-web-setup-wizard-design.md new file mode 100644 index 00000000..c7bcc744 --- /dev/null +++ b/superpowers/specs/2026-09-13-web-setup-wizard-design.md @@ -0,0 +1,319 @@ +# Web Setup Wizard - design spec (D2 / D3) + +**Status:** ready to plan. Phase A (TUI alignment) is landed and verified. +**Audience:** the agent that will plan and implement this. Read this whole file +before writing a plan; it encodes decisions already made and traps already hit. + +**Related:** `superpowers/plans/2026-09-12-getting-started-parity-phase-a.md` - +the completed phase, including its audit and implementation log. This spec +supersedes that plan's "Deferred (stages 6–10)" section, which was written +before Phase A landed and is stale in two places called out below. + +--- + +## 1. Goal + +- **D2** - a multi-step first-run wizard in the web SPA that mirrors the TUI + wizard and writes the same `config.toml`. +- **D3** - **either** flow, run alone, ends with a `config.toml` the other + surface can use. This is the acceptance bar, not a nice-to-have. + +Out of scope here: documentation (D4). Docs come after this lands, per the +user's sequencing - "document as it exists". Do not write getting-started docs +as part of this work. + +## 2. Why this exists + +An audit found the two surfaces were not merely inconsistent: **the web flow +was not independently completable.** Its only guided screen is the admin-password +bootstrap; kanban and git are unreachable from the browser entirely, and the +pages that do work were gated behind a prerequisite only the TUI could satisfy. + +Phase A fixed the TUI half. The wizard now has the three external-service +connections it was missing, all derived from one catalog. This phase brings the +browser to parity. + +## 3. What Phase A already built (do not re-derive) + +### 3.1 The step catalog is the source of truth + +`src/startup/steps.rs` - `SetupStep`, 17 variants, declared **in wizard order**: + +``` +welcome, kanban-info, model-server, git-provider, collection-source, +hosted-collections, task-field-config, session-wrapper-choice, +worktree-preference, admin-password, tmux-onboarding, vscode-setup, +cmux-setup, zellij-setup, acceptance-criteria, startup-tickets, confirm +``` + +- `SetupStep::ALL` is a fixed-size array in walk order. `info()` and `slug()` + are **exhaustive matches**, so adding a variant is a compile error until copy + exists and `ALL` is resized. +- `#[derive(TS)] #[ts(export)]` generates `bindings/SetupStep.ts` (a string + union of the slugs above) via `make bindings`. +- `setup_steps()` feeds `docs/startup/index.md` through `src/docs_gen/startup.rs`. + +The slugs are a **frozen public identifier** - they key docs URLs and will key +your component map. `test_slugs_match_frozen_snapshot` guards renames. + +### 3.2 One workspace writer + +`setup::initialize_workspace(&mut Config, &SetupOptions) -> Result` +creates directories, writes templates, and **mutates config without saving**. +The caller owns persistence. This was done specifically so a REST handler can +wrap it in `ApiState::mutate_config`. `SetupOptions` today: + +```rust +preset, force, task_fields, working_dir, kanban_provider, llm_tool, +use_worktrees, wrapper, acceptance_criteria, custom_collection, +active_collection, hosted_collections, model_servers +``` + +`App::initialize_tickets` builds one of these from the wizard screen and calls +it. Your REST endpoint must do the same, from a DTO. + +### 3.3 Provider lists come from the integration catalog + +`integrations::catalog::onboardable(vertical) -> Vec` returns +`Alpha`+ **and** documented entries. Both TUI provider steps derive rows, +labels and docs links from it. + +| Vertical | Offered today | +|---|---| +| Git | github, gitlab, gitea | +| Model | anthropic-api, openai-api, google-api, ollama, openrouter | + +Proto entries (bitbucket, azure, forgejo, openai-compat, lmstudio) are +deliberately **not** offered - they are unadvertised and have no docs page to +link to. Promoting one into onboarding is a `SupportStatus` bump in +`src/integrations/catalog.rs`, nothing else. + +**Your web wizard must use this same list.** Three guards in +`tests/setup_parity.rs` already enforce it for the TUI; extend them, do not +work around them: + +- `test_wizard_steps_do_not_hardcode_provider_slugs` +- `test_wizard_derives_provider_lists_from_the_catalog` +- `test_binding_union_matches_catalog_slugs` + +### 3.4 Secrets are stored by reference, everywhere + +Kanban, git and model servers all store the **name of an env var**, never the +secret. The secret is `set_var`'d into the running process and the user is shown +a shell-export line to make it permanent. Preserve this. It is also the single +thing users trip over, so the web wizard should surface the export line at least +as prominently as the TUI does. + +## 4. Load-bearing constraints + +Each of these cost real time to discover. Respect them or re-pay that cost. + +### 4.1 `setup` and `startup` are bin-only - this blocks the obvious approach + +``` +src/lib.rs : mod startup; (private) pub mod integrations; +src/main.rs: mod setup; mod startup; mod integrations; +``` + +`src/rest/` compiles **in the lib**, so it cannot see `crate::setup` or +`crate::startup` as they are declared today. A `POST /setup/initialize` that +calls `initialize_workspace` **will not compile** without first moving +`src/setup.rs` into the lib (`pub mod setup;`) and widening `startup`. + +The Phase A plan's deferred section assumed this was free. It is not. Budget it +as the first task of the REST work, and check what else `setup.rs` pulls in +(`projects`, `startup::templates`, `collections::fetch` are all lib-private +today - visibility widening, not relocation). + +`integrations` is already `pub mod`, which is why §3.3 routes cleanly to the +browser with no move at all. + +### 4.2 Mount the wizard OUTSIDE `Layout` + +`ui/src/Layout.tsx:22` disables any nav item whose `section.met` is false. A +wizard rendered inside `Layout` would be surrounded by the very gating it exists +to resolve. `ui/src/main.tsx` already has the precedent and states the reason: + +```tsx +{/* Unauthenticated screens render outside Layout: the shell's own + API calls would 401 for a visitor who cannot yet authenticate. */} + +``` + +Add `onboarding` as a sibling of `setup`, not inside the `}>` block. + +### 4.3 LLM detection runs only in the TUI + +`crate::llm::refresh_tool_detection` is called exactly once, at +`src/app/mod.rs:121`. `operator api` never detects tools, so `llm_tools.detected` +stays empty, the `llm` section stays Yellow, and **Model Providers + Delegators +are disabled in the web sidebar** - on real, working routes. + +Fix this by hoisting detection into a shared entry point called from `cmd_api` +as well. It is a **user-visible fix worth shipping on its own**, independent of +the wizard, and the wizard's Welcome step needs it to show anything useful. + +Cost is bounded: `refresh_cached_tool` reuses cached path/version, and the +config write is gated on `detection_changed`. + +### 4.4 Blocking calls in async handlers + +`git_onboarding::resolve_onboarding*` shells out via `std::process::Command`, +and `validate_*_token` uses `reqwest::blocking`. Both are fine in the TUI (they +briefly stall the event loop). In an axum handler they block a runtime worker, +and `reqwest::blocking` can panic when constructed inside a tokio context. + +**Wrap every such call in `tokio::task::spawn_blocking`.** This is the largest +correctness risk in the REST work. Prefer wrapping over writing async twins, so +one implementation stays shared with the TUI. + +### 4.5 The SPA dependency allowlist + +`tests/ui_packaging.rs` enforces a **7-entry** allowlist for `ui/package.json` +(react, react-dom, react-router-dom, three @dnd-kit packages, @vscode/codicons) +and bans CSS-in-JS. Build the wizard with plain React and CSS modules, matching +`SetupPage.tsx` / `AuthPage.module.css`. Reaching for a form or wizard library +fails the build. + +### 4.6 Config is written relative to `paths.state` + +`Config::save()` writes `config.operator_config_path_for()` = `paths.state` + +`config.toml` (default `.tickets/operator/config.toml`, resolved against the +process cwd). `operator api` started from a different directory silently uses a +different config. Mention this in whatever the wizard shows as its destination +path - `GET /setup/status` should return the resolved absolute path. + +### 4.7 `ApiState::mutate_config` is the only REST write path + +```rust +pub async fn mutate_config(&self, mutate: impl FnOnce(&mut Config) -> Result) + -> Result +``` + +It serialises on a lock, saves atomically, **and** `replace_config`s so later +handlers do not see a stale snapshot. Never call `config.save()` directly from a +handler. + +### 4.8 Route scopes fail closed + +`tests/route_scope_parity.rs` walks the generated OpenAPI spec and fails on any +route missing from `ROUTE_RULES` in `src/auth/scope.rs` (`read` / `execute` / +`admin` helpers). Add an entry per new route or the test fails - which is the +desired behaviour, since an unknown route denies. + +**Auth needs no other work.** Browser session principals carry `Scope::ALL` +(`src/auth/store.rs:462`), so a post-bootstrap admin satisfies every rule the +wizard touches. + +## 5. What already exists vs. what is new + +### Already built - zero or near-zero Rust work + +| Concern | Endpoints | Note | +|---|---|---| +| Admin bootstrap | `GET`/`POST /api/v1/auth/bootstrap`, `POST /auth/login` | already drives `SetupPage.tsx` | +| **Kanban** | `POST /kanban/validate`, `/kanban/projects`, `/kanban/statuses`, `PUT /kanban/config`, `POST /kanban/session-env` | **fully built and proven - persists correctly.** Only `vscode-extension/src/kanban-onboarding.ts` calls it; `ui/src/api-client.ts` has no methods. Port that client. | +| Model servers | `GET /model-servers/kinds`, `GET /kinds/{slug}/models` (live probe), `POST /model-servers` | `ModelProvidersPage.tsx` already drives connect; extract its card grid | +| Provider catalog | `GET /api/v1/integrations` | serves §3.3 to the browser with no new route | +| Collections | `GET /collections`, `POST /collections/{name}/activate` | **activate is in-memory only** - see below | + +The kanban quartet being done is the single biggest head start here. Read +`vscode-extension/src/kanban-onboarding.ts` as the working reference. + +### New work + +1. **Move `setup.rs` into the lib** (§4.1). Prerequisite for everything else. +2. **Detection in `cmd_api`** (§4.3). Independently shippable. +3. **Persist collection activation** - `src/rest/routes/collections.rs:102` + mutates the in-memory registry only, so the wizard's collection choice + evaporates on restart. A direct D3 violation. Wrap in `mutate_config`. +4. **Git REST surface** - nothing exists; zero `/api/v1/git*` routes. Mirror + the kanban quartet exactly, including its convention that `validate` returns + `{valid, error}` rather than a 4xx on a bad token: + + ``` + GET /api/v1/git/providers read -> catalog entries + CLI/auth state + POST /api/v1/git/validate execute -> {valid, username?, error?} + PUT /api/v1/git/config admin -> {provider, token_env} NO secret + POST /api/v1/git/session-env admin -> {provider, token} -> {shell_export_block} + ``` + +5. **Setup surface**: + + ``` + GET /api/v1/setup/status read -> {initialized, admin_configured, config_path, tickets_path} + GET /api/v1/setup/steps read -> the catalog: slug, name, description, help_text, order + POST /api/v1/setup/initialize admin -> SetupOptions DTO, wrapped in mutate_config + ``` + + `initialized` **must** use the existing shared predicate + `startup::workspace_initialized(&config)` - already extracted in Phase A and + already used by `App::new`. If the two surfaces compute "is this set up?" + differently, D3 is unenforceable. + +6. **`ui/src/api-client.ts`** - kanban, git, setup, integrations methods. +7. **The wizard itself** - `ui/src/routes/onboarding/`, one component per slug, + map declared `satisfies Record` so `tsc` errors + when a Rust variant lands without a web component. That is the TypeScript-side + compile-time guard, replacing a drift test. + +## 6. Deliberate scope cuts + +- **Startup Tickets renders read-only in v1** ("you can create these later from + the dashboard"), and the parity test allowlists it explicitly. Shipping + half-working ticket creation and calling it parity is worse than an honest gap. +- **Per-wrapper steps** (tmux/vscode/cmux/zellij onboarding) are terminal-centric + help screens. Decide deliberately whether the browser shows them, shows one + combined "session target" screen, or skips them - and record the decision in + the parity test's allowlist either way. + +## 7. Acceptance - how to prove D3 + +The bar is not "tests pass". It is two clean rooms producing interchangeable +config. + +1. `rm -rf /tmp/opr-tui && mkdir -p /tmp/opr-tui && cd /tmp/opr-tui && operator` + - walk the wizard, choosing a non-default on every screen. +2. `rm -rf /tmp/opr-web && mkdir -p /tmp/opr-web && cd /tmp/opr-web && operator api --open` + - bootstrap at `/#/setup`, complete `/#/onboarding`, choosing the same options. +3. **Diff the two `config.toml` files.** Differences must be explainable by the + choices made, not by which surface wrote them. This is the D3 check. +4. Start `operator` in `/tmp/opr-web`: the TUI must read the web-produced config + without re-running setup. Then the reverse. +5. In the web run, confirm the sidebar has **no disabled entries** afterwards + (the §4.3 check). +6. `make check`, then `cargo run -- docs && make bindings && git diff --exit-code`. + +## 8. Known-bad state you will inherit + +Both pre-date this work. Neither is yours to fix, but both will confuse you. + +- **`make check` is not reliably green.** + `auth::schema::tests::test_concurrent_migration_of_one_database_is_safe` + fails ~8% of the time **in isolation** (measured 2/25) with + `enabling WAL: database is locked`. A hypothesis that `apply_pragmas` sets + `busy_timeout` after the `journal_mode=WAL` switch that needs it was **tested + and disproven** (2/25 before, 2/25 after) and reverted. There is no working + explanation yet. Treat a red `make check` as "check which test failed". +- **Generated artifacts do not round-trip.** `cargo run -- docs` regenerates + `docs/schemas/{config,metadata,state}.md` and `docs/schemas/openapi.json` with + em-dashes where the committed copies have hyphens, while + `src/schemas/issuetype_schema.json` regenerates with hyphens where the + committed copy has em-dashes. None of those sources were touched in Phase A. + Step 6 of §7 will surface this. It wants its own cleanup commit. + +## 9. Working agreements + +From `CLAUDE.md` and established over Phase A: + +- **TDD.** Write the failing test first; confirm it fails for the right reason. +- **Verify guards are not vacuous.** Every parity test added in Phase A was + confirmed by deliberately breaking it and watching it fail. Do the same. +- **`make check` before declaring done** - and read its *actual* exit code. A + grep over its output that matches nothing is not evidence of success; that + mistake hid a real failure during Phase A. +- **Do not commit.** The user handles all git operations manually. +- **Minimal comments**, terse and one line where they earn their place. +- **Ask rather than assume** on anything that changes the shape of the work. + Phase A's kanban step was redesigned mid-flight because the planned approach + served nobody on a first run; that was worth one question. diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index c2f8b0cd..1da750bc 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -221,9 +221,11 @@ fn concepts_status_keys() -> Vec { let end = start + src[start..].find(']').expect("STATUS_KEYS array end"); let mut ids = Vec::new(); let mut rest = &src[start..end]; - while let Some(i) = rest.find('\'') { + // Quote style is the formatter's choice; accept either. + while let Some(i) = rest.find(['\'', '"']) { + let quote = rest[i..].chars().next().expect("quote char"); let after = &rest[i + 1..]; - match after.find('\'') { + match after.find(quote) { Some(e) => { ids.push(after[..e].to_string()); rest = &after[e + 1..]; diff --git a/tests/setup_parity.rs b/tests/setup_parity.rs new file mode 100644 index 00000000..584508f1 --- /dev/null +++ b/tests/setup_parity.rs @@ -0,0 +1,210 @@ +//! Setup wizard parity tests. +//! +//! The wizard's step catalog (`src/startup/steps.rs`) is the single source of +//! truth for the ratatui renderer, the generated TypeScript binding and the +//! hosted docs page. These tests keep the three in step. +//! +//! Uses `include_str!` to scan source files (same pattern as +//! `surface_parity.rs`) because `startup` is a crate-private module and the +//! wizard itself is bin-only, so neither is reachable from an integration test. + +const STEPS_RS: &str = include_str!("../src/startup/steps.rs"); +const BINDING_TS: &str = include_str!("../bindings/SetupStep.ts"); +const STARTUP_DOC: &str = include_str!("../docs/startup/index.md"); +const SETUP_MOD_RS: &str = include_str!("../src/ui/setup/mod.rs"); +const GIT_STEP_RS: &str = include_str!("../src/ui/setup/steps/git.rs"); +const MODEL_STEP_RS: &str = include_str!("../src/ui/setup/steps/model_server.rs"); +const WEB_STEPS_TSX: &str = include_str!("../ui/src/routes/onboarding/steps.tsx"); + +/// The assertions below scrape TSX source, so collapse what the formatter is +/// free to rewrite: quote style and line wrapping. Prose keeps single spaces. +fn web_steps_tsx() -> String { + WEB_STEPS_TSX + .replace('\'', "\"") + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Same, for code shapes: the formatter may break a call chain across lines, so +/// compare with all whitespace removed. +fn tsx_contains_code(needle: &str) -> bool { + fn compact(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() + } + compact(&WEB_STEPS_TSX.replace('\'', "\"")).contains(&compact(needle)) +} + +/// Variant names in `SetupStep::ALL`, in declaration order. +fn catalog_order() -> Vec { + let all = STEPS_RS + .split_once("pub const ALL:") + .expect("steps.rs must declare SetupStep::ALL") + .1; + let body = all.split_once('[').unwrap().1.split_once("];").unwrap().0; + body.lines() + .filter_map(|l| l.trim().strip_prefix("SetupStep::")) + .map(|v| v.trim_end_matches(',').to_string()) + .collect() +} + +/// Slugs in the order `slug()` matches them. +fn catalog_slugs() -> Vec { + let arm = STEPS_RS + .split_once("pub fn slug(self)") + .expect("steps.rs must define slug()") + .1; + let body = arm.split_once('{').unwrap().1; + body.lines() + .filter_map(|l| l.trim().split_once("=> \"")) + .map(|(_, rest)| rest.split('"').next().unwrap_or("").to_string()) + .take_while(|s| !s.is_empty()) + .collect() +} + +/// Step display names from the generated docs page headings. +fn doc_step_names() -> Vec { + STARTUP_DOC + .lines() + .filter_map(|l| l.strip_prefix("### ")) + .filter_map(|h| h.split_once(". ")) + .map(|(_, name)| name.trim().to_string()) + .collect() +} + +/// `name:` literals from the `info()` match, in declaration order. +fn catalog_names() -> Vec { + let arm = STEPS_RS + .split_once("pub fn info(self)") + .expect("steps.rs must define info()") + .1; + arm.lines() + .filter_map(|l| l.trim().strip_prefix("name: \"")) + .map(|rest| rest.split('"').next().unwrap_or("").to_string()) + .collect() +} + +#[test] +fn test_catalog_is_non_empty() { + assert!( + !catalog_order().is_empty(), + "failed to parse SetupStep::ALL" + ); + assert_eq!(catalog_order().len(), catalog_slugs().len()); + assert_eq!(catalog_order().len(), catalog_names().len()); +} + +/// Defect E regression: the generated docs page drifted out of the wizard's +/// real order and nothing caught it, because the only guard was a step count. +#[test] +fn test_docs_page_lists_steps_in_catalog_order() { + assert_eq!( + doc_step_names(), + catalog_names(), + "docs/startup/index.md is stale - run `cargo run -- docs --only startup`" + ); +} + +#[test] +fn test_binding_union_matches_catalog_slugs() { + let union = BINDING_TS + .split_once("export type SetupStep =") + .expect("binding must declare SetupStep") + .1; + let members: Vec = union + .split('|') + .map(|m| m.trim().trim_end_matches(';').trim_matches('"').to_string()) + .collect(); + assert_eq!( + members, + catalog_slugs(), + "bindings/SetupStep.ts is stale - run `make bindings`" + ); +} + +/// Every variant must be reachable from the wizard state machine; a step the +/// renderer never selects is dead code (defect A's failure mode). +#[test] +fn test_every_catalog_step_is_referenced_by_the_wizard() { + for variant in catalog_order() { + assert!( + SETUP_MOD_RS.contains(&format!("SetupStep::{variant}")), + "SetupStep::{variant} is never referenced in src/ui/setup/mod.rs" + ); + } +} + +/// Provider slugs the wizard must never hardcode: the offered set comes from +/// `integrations::catalog::onboardable`, so promoting a provider into +/// onboarding is a `SupportStatus` bump rather than an edit in each surface. +/// The same list will back the web wizard over REST. +#[test] +fn test_wizard_steps_do_not_hardcode_provider_slugs() { + const SLUGS: &[&str] = &[ + "github", + "gitlab", + "gitea", + "bitbucket", + "forgejo", + "anthropic-api", + "openai-api", + "google-api", + "ollama", + "openrouter", + "openai-compat", + "lmstudio", + ]; + for (name, source) in [ + ("steps/git.rs", GIT_STEP_RS), + ("steps/model_server.rs", MODEL_STEP_RS), + ] { + for slug in SLUGS { + assert!( + !source.contains(&format!("\"{slug}\"")), + "{name} hardcodes the provider slug {slug:?}; derive the list from \ + integrations::catalog::onboardable instead" + ); + } + } +} + +/// The wizard must key its provider rows off the catalog, not a per-vertical +/// enum - the enums include Proto entries that onboarding does not advertise. +#[test] +fn test_wizard_derives_provider_lists_from_the_catalog() { + assert!( + SETUP_MOD_RS.contains("onboardable(Vertical::Git)"), + "git rows must come from the catalog" + ); + assert!( + SETUP_MOD_RS.contains("onboardable(Vertical::Model)"), + "model rows must come from the catalog" + ); +} + +#[test] +fn test_web_wizard_has_an_exhaustive_component_map() { + assert!( + tsx_contains_code("satisfies Record"), + "the web wizard must fail TypeScript compilation when the Rust step union grows" + ); + for slug in catalog_slugs() { + assert!( + tsx_contains_code(&format!("{slug}:")) || tsx_contains_code(&format!("\"{slug}\":")), + "web component map is missing {slug:?}" + ); + } +} + +#[test] +fn test_web_wizard_derives_provider_lists_from_rest_catalogs() { + assert!(tsx_contains_code("entry.vertical === \"model\"")); + assert!(tsx_contains_code("api.gitProviders()")); + assert!(tsx_contains_code("api.kanbanProviders()")); +} + +#[test] +fn test_web_parity_scope_cuts_are_explicit() { + assert!(web_steps_tsx().contains("Ticket creation is read-only")); + assert!(tsx_contains_code("wrapperSteps.has(step)")); +} diff --git a/ui/src/Layout.module.css b/ui/src/Layout.module.css index 3b0e22c9..2facb733 100644 --- a/ui/src/Layout.module.css +++ b/ui/src/Layout.module.css @@ -147,7 +147,7 @@ background: var(--color-green-l3); } -/* On the active pill (bg green-l3) a green-l3 dot would vanish — lighten it so +/* On the active pill (bg green-l3) a green-l3 dot would vanish - lighten it so * the healthy dot stays a filled mark, not just the cream ring. */ .active .navDot[data-health='green'] { background: var(--color-green-l1); @@ -182,7 +182,7 @@ /* Right-hand detail sidepanel (see right-panel.tsx). Fixed width, scrolls * independently of the main view, divided from it by a left border. Minimal by - * design — content components own their own styling. */ + * design - content components own their own styling. */ .rightPanel { width: 380px; flex-shrink: 0; diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index dc3a0de6..1cab3433 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,21 +1,21 @@ -import { useState } from 'react'; -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; -import styles from './Layout.module.css'; -import { useTheme } from './theme'; -import type { Concept } from './concepts'; -import { CONCEPTS, STATUS_KEYS, PAGE_KEYS } from './concepts'; -import { ConceptIcon } from './components/ConceptIcon'; -import { SectionsProvider, useSections } from './sections-context'; -import { RightPanelProvider, useRightPanel } from './right-panel'; -import type { SectionDto } from './api-client'; -import { OperatorApi, setCsrfToken } from './api-client'; -import { useHost } from './host'; +import { useState } from "react"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import styles from "./Layout.module.css"; +import { useTheme } from "./theme"; +import type { Concept } from "./concepts"; +import { CONCEPTS, STATUS_KEYS, PAGE_KEYS } from "./concepts"; +import { ConceptIcon } from "./components/ConceptIcon"; +import { SectionsProvider, useSections } from "./sections-context"; +import { RightPanelProvider, useRightPanel } from "./right-panel"; +import type { SectionDto } from "./api-client"; +import { OperatorApi, setCsrfToken } from "./api-client"; +import { useHost } from "./host"; // The "Status" group mirrors the canonical section order shared with the TUI and // VS Code extension (the SectionId enum in src/ui/status_panel.rs) and reflects // each section's live health from GET /api/v1/sections. A section whose // prerequisites aren't met yet is shown disabled with a tooltip naming what it -// needs — the user sees it exists and why it isn't reachable. "Pages" are +// needs - the user sees it exists and why it isn't reachable. "Pages" are // web-only views (Dashboard, Queue) with no section analog. function NavRow({ concept, section }: { concept: Concept; section?: SectionDto }) { @@ -30,14 +30,12 @@ function NavRow({ concept, section }: { concept: Concept; section?: SectionDto } ); if (!met) { - const needs = (section?.prerequisites ?? []) - .map((id) => CONCEPTS[id]?.label ?? id) - .join(', '); + const needs = (section?.prerequisites ?? []).map((id) => CONCEPTS[id]?.label ?? id).join(", "); return ( {inner} @@ -47,8 +45,10 @@ function NavRow({ concept, section }: { concept: Concept; section?: SectionDto } return ( (isActive ? `${styles.navLink} ${styles.active}` : styles.navLink)} + end={concept.route === "/"} + className={({ isActive }) => + isActive ? `${styles.navLink} ${styles.active}` : styles.navLink + } > {inner} @@ -80,9 +80,11 @@ function NavGroup({ label, keys }: { label: string; keys: readonly string[] }) { // with a header (title + close) above the caller-supplied node. function RightPanel() { const { content, title, close } = useRightPanel(); - if (!content) {return null;} + if (!content) { + return null; + } return ( -