diff --git a/.claude/settings.json b/.claude/settings.json index 7ee8e9fe..0c29e953 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,6 +7,7 @@ "Bash(git commit*)" ], "deny": [ + "Bash(git stash*)", "Bash(git revert*)", "Bash(git checkout*)", "Bash(git rebase*)", diff --git a/.githooks/pre-push b/.githooks/pre-push index aa04b9e8..0a69472f 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Committed pre-push hook. Runs the fast lint gate (`make fmt clippy`) before any push so formatting/clippy failures are caught locally +# Committed pre-push hook. Runs the fast gate (`make fmt-check clippy`) before any push so formatting/clippy failures are caught locally # Enable once per clone: make install-hooks (sets core.hooksPath=.githooks) # Bypass in an emergency: git push --no-verify set -euo pipefail @@ -8,8 +8,8 @@ set -euo pipefail repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" -echo "pre-push: running lint checks (fmt + clippy)…" -if ! make fmt clippy; then +echo "pre-push: running lint checks (fmt-check + clippy)…" +if ! make fmt-check clippy; then echo echo "pre-push: lint checks failed - push aborted." >&2 echo "Fix the issues above, or bypass with 'git push --no-verify' (not recommended)." >&2 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ae374bff..f95b23f6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -449,9 +449,20 @@ jobs: sed 's/^version = ".*"/version = "'"$V"'"/' Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml cargo update --workspace - # Build release with embedded UI + # Build release with embedded UI. + # + # Add `OPERATOR_RELEASE: "1"` below once OPERATOR_LICENSE_PUBLIC_KEYS and + # OPERATOR_LICENSE_ISSUER exist as repository secrets. build.rs then + # refuses to produce an artifact without them, because a build with no + # verification keys rejects every licence - a silent, total Premium + # outage. Until then the keys are passed through when set and the build + # succeeds either way. - name: Build release run: cargo build --locked --release --features embed-ui --target ${{ matrix.target }} + env: + OPERATOR_LICENSE_PUBLIC_KEYS: ${{ secrets.OPERATOR_LICENSE_PUBLIC_KEYS }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + OPERATOR_PURCHASE_URL: ${{ vars.OPERATOR_PURCHASE_URL }} - name: Rename binary shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index 8c9372d4..f6607762 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,20 +37,32 @@ Write superpowers plans to `superpowers/plans/` and design specs to `superpowers ### Mandatory Before Committing -All changes MUST pass these checks before committing. Run them with `make check`, which mirrors the CI `lint-test` job exactly: +All changes MUST pass these checks before committing. Run them with `make check`, +which is `fmt-check` + `lint` + `test` - each verb striped across every module: ```bash make check -# equivalent to the exact CI commands: -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 +# fmt-check: report-only formatting, per module +make fmt-check-rust # cargo fmt --check: root, crates/relay, opr8r, zed-extension +make fmt-check-ts # oxfmt --check, every JS/TS subproject +make fmt-check-tf # terraform fmt -check -diff (coder-module) +# lint: warnings are errors everywhere +make lint-rust # clippy --locked --all-targets --all-features, all 4 crates +make lint-ts # oxlint --type-aware, every JS/TS subproject +make lint-shell # shellcheck -S warning +make lint-helm # helm lint charts/operator +make lint-tf # terraform validate + rendered coder_script check +# test +make test-rust # cargo test --locked --all-features, all 3 crates +make test-ts # webcomponents + coder-module suites ``` +`make fmt` is the same striping in rewrite mode (`cargo fmt`, `bun run fmt`, +`terraform fmt`); only `fmt-check` gates. `make relay` / `make opr8r` / +`make vscode-extension` run every gate for one module when only it changed. +Display-bound suites stay off `make test`: `make vscode-extension` (Electron) +and `make storybook` (browser + axe) are run on their own. + 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`, @@ -65,8 +77,8 @@ excluded by `.oxfmtrc.json` / `.oxlintrc.jsonc` and must never be reformatted. > deprecation that only surfaces under `--all-targets`), which is how a clippy > failure can pass locally yet break CI. Always use the full command above. -Install the pre-push hook once per clone so the fast lint gate (fmt + clippy, -no tests) runs automatically before every push; the full `make check` remains +Install the pre-push hook once per clone so the fast gate (`fmt-check` + +root `clippy`, no tests) runs automatically before every push; the full `make check` remains the expectation before opening a PR: ```bash @@ -127,13 +139,12 @@ make check ## Quick Reference ```bash -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) -cargo test # Run all tests +make check # Full gate: fmt-check + lint + test, every module +make fmt # Rewrite formatting in every module +make lint # Every linter: clippy, oxlint, shellcheck, helm, terraform +make test # Rust + fast JS/TS suites +make vscode-extension # Compile + lint the extension (incl. the webview bundle) +make install-hooks # Install the fast pre-push hook (once per clone) cargo test # Run specific test cargo run # Run TUI cargo run -- queue # CLI: show queue diff --git a/Dockerfile.local b/Dockerfile.local index 88d907e3..a13b8173 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -12,19 +12,11 @@ RUN apt-get update \ WORKDIR /src -# bindings/ is committed (CI only verifies freshness), so `make bindings` -- -# i.e. a full cargo test run -- is not needed to type the frontend. COPY bindings/ ./bindings/ - -# webcomponents first: ui/ resolves @operator/webcomponents to -# ../webcomponents/dist/index.js by vite alias, a dist-artifact dependency -# rather than a package dependency, so the order is load-bearing. COPY webcomponents/ ./webcomponents/ RUN cd webcomponents && bun install --frozen-lockfile && bun run build -# ui/src/index.css @imports the brand tokens from the docs site, which is the -# single source of truth for them (docs/design-system/). The SPA build needs -# that one file even though nothing else of docs/ is involved. +# ui/src/index.css @imports the brand tokens from the docs site, which is the source of truth for them (docs/design-system/). COPY docs/assets/css/ ./docs/assets/css/ COPY ui/ ./ui/ RUN cd ui && bun install --frozen-lockfile && bun run build @@ -38,8 +30,7 @@ COPY --from=web /src/ui/dist ./ui/dist RUN cargo build --release --locked --bin operator -# opr8r is a separate cargo project with its own Cargo.lock, not a workspace -# member, so it needs its own invocation. +# opr8r is a separate cargo project with its own Cargo.lock, not a workspace member, so it needs its own invocation. RUN cd opr8r && cargo build --release --locked # Mirrors Dockerfile; only the operator/opr8r binary source differs. diff --git a/Makefile b/Makefile index d9abe6f3..3ff304e0 100644 --- a/Makefile +++ b/Makefile @@ -1,38 +1,72 @@ # Operator developer tasks. # -# `make check` mirrors the CI `lint-test` job exactly so a clean local run means -# 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. +# The three verbs stripe across every module: `make fmt` rewrites, `make +# fmt-check`, `make lint` and `make test` gate. `make check` runs all three +# gates and is the pre-PR bar. `make install-hooks` wires the committed pre-push +# hook, which runs the fast gate (root fmt-check + clippy, no tests). + +.PHONY: check fmt fmt-check lint test clippy build run install-hooks bindings \ + webcomponents storybook ui docs vscode-extension relay opr8r \ + fmt-rust fmt-ts fmt-tf fmt-check-rust fmt-check-ts fmt-check-tf \ + lint-rust lint-ts lint-shell lint-helm lint-tf test-rust test-ts + +# CI installs terraform; local dev machines may only have OpenTofu. +TF := $(shell command -v terraform >/dev/null 2>&1 && echo terraform || echo tofu) + +# Full gate. The commands below are the same ones CI runs, so a clean local run +# means a clean CI run. +check: fmt-check lint test + +# Rewrite formatting in every module. `fmt-check` is the same pass in report +# mode, and is what `check` and the pre-push hook run. +fmt: fmt-rust fmt-ts fmt-tf + +fmt-check: fmt-check-rust fmt-check-ts fmt-check-tf + +# crates/relay, opr8r and zed-extension have their own Cargo.lock and are not +# workspace members, so `--all` never reaches them. +fmt-rust: + cargo fmt --all + cd crates/relay && cargo fmt + cd opr8r && cargo fmt + cd zed-extension && cargo fmt + +fmt-check-rust: + cargo fmt --all -- --check + cd crates/relay && cargo fmt -- --check + cd opr8r && cargo fmt -- --check + cd zed-extension && cargo fmt -- --check + +# oxfmt is installed once at the repo root and covers every hand-written JS/TS +# subproject. +fmt-ts: + bun install --frozen-lockfile + bun run fmt -.PHONY: check fmt clippy test build run install-hooks bindings webcomponents storybook ui docs \ - fmt-ts lint-ts lint-shell relay +fmt-check-ts: + bun install --frozen-lockfile + bun run fmt:check -# Full CI-parity gate. Keep these commands byte-identical to -# .github/workflows/build.yaml so local and CI never disagree. -check: fmt clippy test relay fmt-ts lint-ts lint-shell +fmt-tf: + cd coder-module && $(TF) fmt -fmt: - cargo fmt --all -- --check +fmt-check-tf: + cd coder-module && $(TF) fmt -check -diff +lint: lint-rust lint-ts lint-shell lint-helm lint-tf + +# Root workspace only: the fast gate the pre-push hook pairs with fmt-check. clippy: cargo clippy --locked --all-targets --all-features -- -D warnings -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 +# zed-extension compiles to wasm, so its lints only resolve under that target. +lint-rust: clippy 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 + cd opr8r && cargo clippy --locked --all-targets --all-features -- -D warnings + cd zed-extension && cargo clippy --locked --target wasm32-wasip1 -- -D warnings +# The type-aware lints resolve each subproject's node_modules and copy-types +# output, so install those first (`make ui`, `make vscode-extension`). lint-ts: bun run lint:ui bun run lint:webcomponents @@ -43,6 +77,39 @@ lint-ts: lint-shell: shellcheck -S warning scripts/*.sh scripts/ci/*.sh .githooks/* +lint-helm: + helm lint charts/operator + +# The rendered coder_script is what actually runs in a workspace, so a bash +# syntax error there is a broken module. +lint-tf: + cd coder-module && $(TF) init -input=false && $(TF) validate + scripts/ci/check-coder-module.sh + +test: test-rust test-ts + +test-rust: + cargo test --locked --all-features + cd crates/relay && cargo test --locked --all-features + cd opr8r && cargo test --locked --all-features + +# Display-bound suites (vscode-extension, storybook) stay on their own targets. +test-ts: + bun install --frozen-lockfile + cd webcomponents && bun install --frozen-lockfile && bun run test + cd coder-module && bun test + +# Every gate for one module, for when only that module changed. +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 + +opr8r: + cd opr8r && cargo fmt -- --check + cd opr8r && cargo clippy --locked --all-targets --all-features -- -D warnings + cd opr8r && cargo test --locked --all-features + # Optimized release binary at target/release/operator. build: cargo build --release @@ -73,6 +140,17 @@ storybook: webcomponents ui: webcomponents cd ui && bun install --frozen-lockfile && bun run build +# The VS Code extension. Mirrors the compile steps of the CI +# `test-vscode-extension` job; `compile:webview` type-checks the webview bundle, +# which no other target reaches. Depends on `bindings` because copy-types copies +# them into vscode-extension/src/generated. +vscode-extension: bindings + cd vscode-extension && npm ci + cd vscode-extension && npm run compile + cd vscode-extension && npm run compile:webview + cd vscode-extension && npm run lint + cd vscode-extension && npm run fmt:check + # Full docs pipeline: bindings, generated reference docs and the hosted # collection bundle, the shared components bundle, then Jekyll. Mirrors the # ordering in .github/workflows/docs.yml. @@ -89,4 +167,4 @@ docs: webcomponents # One-time per clone: route git hooks at the committed .githooks/ directory. install-hooks: git config core.hooksPath .githooks - @echo "pre-push hook installed (runs 'make fmt clippy')" + @echo "pre-push hook installed (runs 'make fmt-check clippy')" diff --git a/README.md b/README.md index 80b521cb..6c562e04 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ **_This Project is currently in ALPHA, is free to use, and officially promises nothing yet!_** -* **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) +* **Session Management** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) [![VS Code Terminals](https://img.shields.io/badge/VS_Code_Terminals-007ACC)](https://operator.untra.io/getting-started/sessions/vscode-terminals/) -* **Editor** [![VS Code](https://img.shields.io/badge/VS_Code-007ACC)](https://operator.untra.io/getting-started/sessions/vscode/) [![Zed](https://img.shields.io/badge/Zed-084CCF?logo=zedindustries&logoColor=white)](https://operator.untra.io/getting-started/sessions/zed/) +* **IDE** [![VS Code](https://img.shields.io/badge/VS_Code-007ACC)](https://operator.untra.io/getting-started/ides/vscode/) [![Zed](https://img.shields.io/badge/Zed-084CCF?logo=zedindustries&logoColor=white)](https://operator.untra.io/getting-started/ides/zed/) -* **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) +* **Kanban Provider** [![Operator](https://img.shields.io/badge/Operator-C8613F)](https://operator.untra.io/getting-started/kanban/operator/) [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) * **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) @@ -18,7 +18,9 @@ * **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) -* **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) [![Kubernetes](https://img.shields.io/badge/Kubernetes-326CE5?logo=kubernetes&logoColor=white)](https://operator.untra.io/getting-started/platforms/kubernetes/) +* **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Kubernetes](https://img.shields.io/badge/Kubernetes-326CE5?logo=kubernetes&logoColor=white)](https://operator.untra.io/getting-started/platforms/kubernetes/) + +* **Remote Targets** (Premium) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/remote-targets/coder/) * **Workflow Export Format** [![Claude Workflow](https://img.shields.io/badge/Claude_Workflow-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/workflows/claude/) [![AGNT Workflow](https://img.shields.io/badge/AGNT_Workflow-6E56CF)](https://operator.untra.io/getting-started/workflows/agnt/) @@ -30,7 +32,7 @@ An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-st **Operator** is for you if: -- you do work assigned from tickets on a kanban board, such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/), [_Linear_](https://operator.untra.io/getting-started/kanban/linear/), or [_GitHub Projects_](https://operator.untra.io/getting-started/kanban/github/) +- you do work assigned from tickets on a kanban board - [_Operator's own_](https://operator.untra.io/getting-started/kanban/operator/), or synced in from [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/), [_Linear_](https://operator.untra.io/getting-started/kanban/linear/), or [_GitHub Projects_](https://operator.untra.io/getting-started/kanban/github/) - you use LLM assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/), or [_Google Gemini CLI_](https://operator.untra.io/getting-started/agents/gemini-cli/) - you reach many models through a provider like [_OpenRouter_](https://operator.untra.io/getting-started/model-servers/openrouter/) or a local [_Ollama_](https://operator.untra.io/getting-started/model-servers/ollama/) server - your work is version controlled with a git repository provider like [_GitHub_](https://operator.untra.io/getting-started/git/github/) or [_GitLab_](https://operator.untra.io/getting-started/git/gitlab/) @@ -304,4 +306,3 @@ operator launch --llm-tool codex --model qwen2.5-coder --model-server ollama-loc **Protocol compatibility.** Codex speaks the OpenAI API - pairing with ollama requires no bridge. Claude and Gemini use their own vendor protocols and require a translating proxy (e.g. `claude-code-router`, `litellm-proxy`) between the CLI and ollama; declare the bridge URL as your `model_server.base_url`. Current release ships the infrastructure - ollama detection and automatic env-var injection on spawn land in the next release. See `docs/getting-started/model-servers/` for the full walkthrough. - diff --git a/bindings/Config.ts b/bindings/Config.ts index ed1c0ec9..118a5c30 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -12,6 +12,7 @@ import type { McpConfig } from "./McpConfig"; import type { ModelServer } from "./ModelServer"; import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; +import type { ProfileIdentity } from "./ProfileIdentity"; import type { QueueConfig } from "./QueueConfig"; import type { RelayConfig } from "./RelayConfig"; import type { RemoteHost } from "./RemoteHost"; @@ -23,7 +24,7 @@ import type { TmuxConfig } from "./TmuxConfig"; import type { UiConfig } from "./UiConfig"; import type { VersionCheckConfig } from "./VersionCheckConfig"; -export type Config = { +export type Config = { profile: ProfileIdentity, /** * List of projects operator can assign work to */ diff --git a/bindings/IntegrationCatalogEntryDto.ts b/bindings/IntegrationCatalogEntryDto.ts index daa34d50..7968b209 100644 --- a/bindings/IntegrationCatalogEntryDto.ts +++ b/bindings/IntegrationCatalogEntryDto.ts @@ -33,4 +33,8 @@ readme_badge: boolean, /** * Official support / maturity status. */ -status: SupportStatus, }; +status: SupportStatus, premium: boolean, +/** + * Implemented session controllers for an IDE; absent for other categories. + */ +session_wrappers: Array | null, }; diff --git a/bindings/KanbanProviderKind.ts b/bindings/KanbanProviderKind.ts index fe6d16f9..7cec838a 100644 --- a/bindings/KanbanProviderKind.ts +++ b/bindings/KanbanProviderKind.ts @@ -1,6 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Which kanban provider an onboarding request targets. + * Which external kanban provider an onboarding request targets. + * Deliberately one variant smaller than [`KanbanProviderType`] + * + * [`KanbanProviderType`]: crate::api::providers::kanban::KanbanProviderType */ export type KanbanProviderKind = "jira" | "linear" | "github" | "openspec"; diff --git a/bindings/KanbanTicketCard.ts b/bindings/KanbanTicketCard.ts index 32b95b90..dd8a263d 100644 --- a/bindings/KanbanTicketCard.ts +++ b/bindings/KanbanTicketCard.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TicketPriority } from "./TicketPriority"; +import type { TicketStatus } from "./TicketStatus"; /** * A ticket card for the kanban board @@ -21,9 +23,9 @@ ticket_type: string, */ project: string, /** - * Current status: queued, running, awaiting, completed + * Current status */ -status: string, +status: TicketStatus, /** * Current step name */ @@ -33,9 +35,9 @@ step: string, */ step_display_name: string | null, /** - * Priority: P0-critical, P1-high, P2-medium, P3-low + * Priority level */ -priority: string, +priority: TicketPriority, /** * Timestamp for sorting (YYYYMMDD-HHMM format) */ diff --git a/bindings/LicenseResponse.ts b/bindings/LicenseResponse.ts new file mode 100644 index 00000000..6551c910 --- /dev/null +++ b/bindings/LicenseResponse.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LicenseStatus } from "./LicenseStatus"; +import type { LicenseTerms } from "./LicenseTerms"; + +export type LicenseResponse = { status: LicenseStatus, profile_id: string, premium: boolean, terms: LicenseTerms | null, purchase_url: string | null, }; diff --git a/bindings/LicenseStatus.ts b/bindings/LicenseStatus.ts new file mode 100644 index 00000000..a493c5ea --- /dev/null +++ b/bindings/LicenseStatus.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 LicenseStatus = "missing" | "valid" | "expired" | "not_yet_valid" | "invalid"; diff --git a/bindings/LicenseTerms.ts b/bindings/LicenseTerms.ts new file mode 100644 index 00000000..65efc848 --- /dev/null +++ b/bindings/LicenseTerms.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 LicenseTerms = { version: number, iss: string, aud: string, sub: string, jti: string, profile_id: string, tier: string, iat: bigint, nbf: bigint, exp: bigint, }; diff --git a/bindings/PremiumFeature.ts b/bindings/PremiumFeature.ts new file mode 100644 index 00000000..67399fde --- /dev/null +++ b/bindings/PremiumFeature.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 PremiumFeature = "remote_targets"; diff --git a/bindings/ProfileIdentity.ts b/bindings/ProfileIdentity.ts new file mode 100644 index 00000000..67bfd70e --- /dev/null +++ b/bindings/ProfileIdentity.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 ProfileIdentity = { id: string, name: string, }; diff --git a/bindings/ProfileSummary.ts b/bindings/ProfileSummary.ts new file mode 100644 index 00000000..2e513e3e --- /dev/null +++ b/bindings/ProfileSummary.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 ProfileSummary = { id: string, name: string, initialized: boolean, is_default: boolean, }; diff --git a/bindings/QueueByType.ts b/bindings/QueueByType.ts index e64db13b..28b23f4e 100644 --- a/bindings/QueueByType.ts +++ b/bindings/QueueByType.ts @@ -1,6 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Ticket counts by type for queue status + * Ticket counts keyed by issuetype. + * + * Issuetypes are an open set defined by collections, so this is a map rather + * than fixed fields. `BTreeMap` keeps the JSON key order stable. */ -export type QueueByType = { inv: number, fix: number, feat: number, spike: number, }; +export type QueueByType = { [key in string]: number }; diff --git a/bindings/SectionId.ts b/bindings/SectionId.ts index 666e9f3e..bf2abf28 100644 --- a/bindings/SectionId.ts +++ b/bindings/SectionId.ts @@ -5,4 +5,4 @@ * * String values match the `sectionId` used in the `VSCode` extension tree routing. */ -export type SectionId = "config" | "connections" | "kanban" | "llm" | "model-servers" | "git" | "issuetypes" | "delegators" | "projects" | "workflows"; +export type SectionId = "config" | "connections" | "kanban" | "llm" | "model-servers" | "git" | "issuetypes" | "delegators" | "projects" | "workflows" | "remote-targets" | "license"; diff --git a/bindings/SetupStep.ts b/bindings/SetupStep.ts index e25b413b..be9b63f2 100644 --- a/bindings/SetupStep.ts +++ b/bindings/SetupStep.ts @@ -3,4 +3,4 @@ /** * 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"; +export type SetupStep = "welcome" | "license" | "execution-mode" | "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/TargetProbeResponse.ts b/bindings/TargetProbeResponse.ts new file mode 100644 index 00000000..952b4e14 --- /dev/null +++ b/bindings/TargetProbeResponse.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 TargetProbeResponse = { reachable: boolean, message: string, }; diff --git a/bindings/TargetResponse.ts b/bindings/TargetResponse.ts new file mode 100644 index 00000000..60f6a3b3 --- /dev/null +++ b/bindings/TargetResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CoderConfig } from "./CoderConfig"; +import type { DockerConfig } from "./DockerConfig"; +import type { SshTarget } from "./SshTarget"; + +export type TargetResponse = { premium: boolean, entitled: boolean, user_declared: boolean, +/** + * Unique name, referenced by `DelegatorLaunchConfig.target`. + * `local` and `docker` are reserved for synthesized targets. + */ +name: string, +/** + * Human-readable name for UI surfaces + */ +display_name?: string | null, } & ({ "kind": "local" } | { "kind": "docker" } & DockerConfig | { "kind": "coder" } & CoderConfig | { "kind": "ssh" } & SshTarget); diff --git a/bindings/TargetsResponse.ts b/bindings/TargetsResponse.ts new file mode 100644 index 00000000..bb89389f --- /dev/null +++ b/bindings/TargetsResponse.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 { TargetResponse } from "./TargetResponse"; + +export type TargetsResponse = { targets: Array, total: number, }; diff --git a/bindings/TicketPriority.ts b/bindings/TicketPriority.ts new file mode 100644 index 00000000..ac53b16b --- /dev/null +++ b/bindings/TicketPriority.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Ticket urgency, as constrained by `src/schemas/ticket_metadata.schema.json`. + * + * Variants are declared most-urgent first, so the derived `Ord` is the sort order. + */ +export type TicketPriority = "P0-critical" | "P1-high" | "P2-medium" | "P3-low"; diff --git a/bindings/TicketStatus.ts b/bindings/TicketStatus.ts new file mode 100644 index 00000000..e8204136 --- /dev/null +++ b/bindings/TicketStatus.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. + +/** + * Workflow status, as constrained by `src/schemas/ticket_metadata.schema.json`. + */ +export type TicketStatus = "queued" | "running" | "awaiting" | "completed"; diff --git a/bindings/UpdateTicketStatusResponse.ts b/bindings/UpdateTicketStatusResponse.ts index 368d1e48..b116a511 100644 --- a/bindings/UpdateTicketStatusResponse.ts +++ b/bindings/UpdateTicketStatusResponse.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TicketStatus } from "./TicketStatus"; /** * Response from updating a ticket's status @@ -11,11 +12,11 @@ id: string, /** * Previous status before the update */ -previous_status: string, +previous_status: TicketStatus, /** * New status after the update */ -status: string, +status: TicketStatus, /** * Human-readable message */ diff --git a/build.rs b/build.rs index d169a0eb..70b68fd3 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,8 @@ use std::path::Path; fn main() { + check_license_keys(); + if std::env::var("CARGO_FEATURE_EMBED_UI").is_err() { return; } @@ -48,3 +50,27 @@ fn walk_dir_size(dir: &Path) -> u64 { } total } + +/// A release build must carry the Premium verification keys. +/// +/// `src/licensing.rs` reads them with `option_env!`, so they are baked in at +/// compile time. Without them every licence is rejected as "unknown license +/// signing key" - the right default for a source build, and a silent, total +/// Premium outage if it ever reaches a release artifact. +fn check_license_keys() { + println!("cargo:rerun-if-env-changed=OPERATOR_RELEASE"); + println!("cargo:rerun-if-env-changed=OPERATOR_LICENSE_PUBLIC_KEYS"); + println!("cargo:rerun-if-env-changed=OPERATOR_LICENSE_ISSUER"); + println!("cargo:rerun-if-env-changed=OPERATOR_PURCHASE_URL"); + + if std::env::var("OPERATOR_RELEASE").as_deref() != Ok("1") { + return; + } + let keys = std::env::var("OPERATOR_LICENSE_PUBLIC_KEYS").unwrap_or_default(); + let keys = keys.trim(); + assert!( + !(keys.is_empty() || keys == "{}"), + "OPERATOR_RELEASE=1 but OPERATOR_LICENSE_PUBLIC_KEYS is unset or empty - \ + this build would reject every Premium licence" + ); +} diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index e2c38760..775e17e9 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -28,20 +28,47 @@ docs: - title: Zellij url: /getting-started/sessions/zellij/ icon: zellij + - title: VS Code Terminals + url: /getting-started/sessions/vscode-terminals/ + icon: vscode + - title: IDEs + url: /getting-started/ides/ + children: - title: VS Code Extension - url: /getting-started/sessions/vscode/ + url: /getting-started/ides/vscode/ icon: vscode - - title: Cursor - url: /getting-started/sessions/cursor/ - icon: cursor - title: Zed - url: /getting-started/sessions/zed/ + url: /getting-started/ides/zed/ icon: zed - - title: Remote Hosts (SSH) - url: /getting-started/sessions/remote-hosts/ + - title: Execution Transports + url: /getting-started/transports/ + children: + - title: Local + url: /getting-started/transports/local/ + - title: SSH + url: /getting-started/transports/ssh/ + - title: Agent Relays + url: /getting-started/agent-relays/ + children: + - title: Claude Relay + url: /getting-started/agent-relays/claude-relay/ + icon: claude + - title: Operator Premium + url: /getting-started/premium/ + - title: Remote Targets + url: /getting-started/remote-targets/ + children: + - title: SSH Hosts + url: /getting-started/remote-targets/ssh/ + - title: Coder + url: /getting-started/remote-targets/coder/ + icon: coder - title: Supported Kanban Providers url: /getting-started/kanban/ children: + - title: Operator + url: /getting-started/kanban/operator/ + icon: operator - title: Jira Cloud url: /getting-started/kanban/jira/ icon: jira @@ -110,9 +137,6 @@ docs: - title: Supported Workspace Platforms url: /getting-started/platforms/ children: - - title: Coder - url: /getting-started/platforms/coder/ - icon: coder - title: Docker url: /getting-started/platforms/docker/ icon: docker diff --git a/docs/_layouts/redirect.html b/docs/_layouts/redirect.html new file mode 100644 index 00000000..cbec452c --- /dev/null +++ b/docs/_layouts/redirect.html @@ -0,0 +1,6 @@ +--- +layout: default +--- + + +

This page has moved to {{ page.redirect_to }}.

diff --git a/docs/assets/icons/operator.svg b/docs/assets/icons/operator.svg new file mode 100644 index 00000000..5970712a --- /dev/null +++ b/docs/assets/icons/operator.svg @@ -0,0 +1 @@ +Operator \ No newline at end of file diff --git a/docs/cli/index.md b/docs/cli/index.md index 0b46a4af..44b7d735 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,6 +13,7 @@ Operator provides both a TUI dashboard and CLI commands for queue management. | Option | Description | | --- | --- | | `-c, --config` | Config file path | +| `--profile` | Named configuration hosted by this Operator server | | `-d, --debug` | Enable debug logging | | `-w, --web` | Start with web view enabled | | `--ui` | Open the embedded web UI in a browser on launch | @@ -193,6 +194,9 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_LLM_TOOLS__DENIED` | Comma-separated list of denied LLM tools | | | `OPERATOR_LOGGING__LEVEL` | Log level (trace, debug, info, warn, error) | info | | `OPERATOR_LOGGING__TO_FILE` | Write logs to file in addition to stderr | true | +| `OPERATOR_LICENSE_PUBLIC_KEYS` | JSON map of key id to base64 Ed25519 public key used to verify Premium licences. Compile-time only | {} | +| `OPERATOR_LICENSE_ISSUER` | Expected `iss` claim on a Premium licence. Compile-time only | operator-licensing | +| `OPERATOR_PURCHASE_URL` | External destination shown by the Premium paywall. Compile-time only | - | ### Authentication @@ -276,3 +280,11 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_LOGGING__LEVEL` | Log level (trace, debug, info, warn, error) | info | | `OPERATOR_LOGGING__TO_FILE` | Write logs to file in addition to stderr | true | +### Licensing (build-time) + +| Variable | Description | Default | +| --- | --- | --- | +| `OPERATOR_LICENSE_PUBLIC_KEYS` | JSON map of key id to base64 Ed25519 public key used to verify Premium licences. Compile-time only | {} | +| `OPERATOR_LICENSE_ISSUER` | Expected `iss` claim on a Premium licence. Compile-time only | operator-licensing | +| `OPERATOR_PURCHASE_URL` | External destination shown by the Premium paywall. Compile-time only | - | + diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 896f05b0..aee53590 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -157,6 +157,10 @@ model_servers = [] hosts = [] targets = [] +[profile] +id = "00000000-0000-0000-0000-000000000000" +name = "legacy" + [agents] max_parallel = 5 cores_reserved = 1 diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 3ec83a2a..73f80f41 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -78,9 +78,7 @@ prompt_suffix = "\n\nBe concise." ## Execution targets -*Where does the agent process run?* One `[[targets]]` registry answers it for -every launch path (TUI, web, VS Code, REST). A delegator references a target -by name, exactly like `model_server`: +*Where does the agent process run?* One `[[targets]]` registry answers it for every launch path (TUI, web, VS Code, REST). A delegator references a target by name, exactly like `model_server`: ```toml [[targets]] @@ -124,34 +122,23 @@ target = "cloud" # default when a launch/delegator does not over a real fallback, so REST/CLI/auto launches with it set run in docker. 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 -target of the same name. Setting both `docker` and `host` now resolves -deterministically to the host (with a deprecation warning) instead of -erroring. +Legacy inputs are synthesized rather than special-cased: `[launch.docker]` becomes a target named `docker`, and every `[[hosts]]` entry becomes an ssh target of the same name. +Setting both `docker` and `host` now resolves deterministically to the host. ### Coder targets -A coder target's execution shape is an SSH target with a dynamically -provisioned alias: Operator creates (or restarts) a per-ticket workspace from -`template`, writes an SSH config fragment (`ProxyCommand coder ssh --stdio `), prepares the git checkout, and launches over the shared SSH remote path. Workspaces are stopped on completion and **never deleted** - reclamation belongs to the Coder admin's -autostop policy. +A coder target's execution shape is an SSH target with a dynamically provisioned alias: Operator creates a per-ticket workspace from `template`, writes an SSH config fragment (`ProxyCommand coder ssh --stdio `), prepares the git checkout, and launches over the shared SSH remote path. Workspaces are stopped on completion. Workspace reclamation belongs to the Coder admin's autostop policy. -Credentials are held **by name**: `url_env` / `token_env` name environment -variables, and the token variable is stripped from every agent's spawn -environment on all target kinds. **Blast radius:** a Coder session token can -create, delete, and SSH into every workspace its user owns - scope accordingly. +Credentials are held **by name**: `url_env` / `token_env` name environment variables, and the token variable is stripped from every agent's spawn environment on all target kinds. + +**Blast radius:** a Coder session token can create, delete, and SSH into every workspace its user owns - scope accordingly. No shared filesystem is required: the prompt and command payload are written on the operator side and pushed over SSH into the workspace before the session starts, so Operator can drive Coder from anywhere it can reach the deployment - a [Kubernetes deployment](/getting-started/platforms/kubernetes/#coder-targets), a server, or a laptop. `callback_url` keeps multi-step chains reporting when the SSH tunnel drops. -The `coder` CLI is resolved from `PATH`, then a cache in the state directory, and is otherwise downloaded from the deployment itself - so nothing has to be baked into an image and the CLI cannot drift from the server. `ssh` does have to be present. Keep `url_env` and `token_env` at their default names unless you have a reason not to: the SSH `ProxyCommand` runs the CLI as a subprocess, and -it reads `CODER_URL` / `CODER_SESSION_TOKEN` from the environment it inherits. +The `coder` CLI is resolved from `PATH`, then a cache in the state directory, and is otherwise downloaded from the deployment itself - so nothing has to be baked into an image and the CLI cannot drift from the server. `ssh` does have to be present. Keep `url_env` and `token_env` at their default names unless you have a reason not to: the SSH `ProxyCommand` runs the CLI as a subprocess, and it reads `CODER_URL` / `CODER_SESSION_TOKEN` from the environment it inherits. -Remote constraints for ssh and coder targets: worktrees and relay MCP -injection are forced off, and the zellij session wrapper is unsupported. See -[Remote Hosts (SSH)](/getting-started/sessions/remote-hosts/) for the -underlying mechanics. +Remote constraints for ssh and coder targets: worktrees and relay MCP injection are forced off, and the zellij session wrapper is unsupported. See [Remote Hosts (SSH)](/getting-started/sessions/remote-hosts/) for the underlying mechanics. ### Relay MCP injection @@ -210,11 +197,9 @@ A delegator can be serialized to a portable **agent profile** (`agent-profile.js tool-agnostic interchange format with a shared core (`provider`, `model`, `system_prompt`, `skills`, `mcp_servers`, `tools`) plus namespaced extension bags: `x_operator` (Operator's launch config and model properties) and per-platform opaque bags (`x_agnt`, `x_openai`) that are -preserved verbatim. Profiles round-trip losslessly in both directions, so a profile authored on -another platform survives `import → export` byte-for-byte. +preserved verbatim. Profiles round-trip losslessly in both directions, so a profile authored on another platform survives `import → export` byte-for-byte. -A delegator may also carry a **`remote_agent`** reference - a `{ platform, id }` pointer to a -remote, named agent that lives on another service: +A delegator may also carry a **`remote_agent`** reference - a `{ platform, id }` pointer to a remote, named agent that lives on another service: ```toml [[delegators]] diff --git a/docs/getting-started/agent-relays/claude-relay.md b/docs/getting-started/agent-relays/claude-relay.md new file mode 100644 index 00000000..8d31407d --- /dev/null +++ b/docs/getting-started/agent-relays/claude-relay.md @@ -0,0 +1,9 @@ +--- +title: "Claude Relay" +description: "Operator's embedded Claude-compatible agent relay." +layout: doc +--- + +Operator includes a Claude-compatible embedded relay, catalogued as `claude-relay`. It carries structured agent events and control messages. Relay availability does not itself require Premium. + +See the [relay reference](/relay/) for the protocol and configuration. Remote execution retains the launcher's relay and worktree limitations; a Premium license does not remove those compatibility checks. diff --git a/docs/getting-started/agent-relays/index.md b/docs/getting-started/agent-relays/index.md new file mode 100644 index 00000000..53d1ae48 --- /dev/null +++ b/docs/getting-started/agent-relays/index.md @@ -0,0 +1,11 @@ +--- +title: "Agent Relays" +description: "Relay structured agent events into Operator." +layout: doc +--- + +An agent relay carries structured lifecycle events between an agent and Operator. It complements the session controller and execution transport. + +| Relay | Status | Purpose | +|---|---|---| +| [Claude Relay](/getting-started/agent-relays/claude-relay/) | Alpha | Claude-compatible lifecycle and control events | diff --git a/docs/getting-started/concepts/kanban.md b/docs/getting-started/concepts/kanban.md index b1f0ba77..9a65496e 100644 --- a/docs/getting-started/concepts/kanban.md +++ b/docs/getting-started/concepts/kanban.md @@ -23,4 +23,38 @@ That's it. The board *is* the status report. In Operator!, the cards are [tickets](/getting-started/tickets/) and the workers are AI agents. Operator holds three internal states - **todo**, **doing**, **done** - and enforces both rules: agents pull the next ticket when a slot frees up, and parallelism limits cap work in progress. -You can run entirely from local tickets, or sync the board with an external [kanban provider](/getting-started/kanban/) like Jira, Linear, or GitHub Projects - Operator maps its three states onto your board's columns and moves cards as agents work. +That board is a [kanban provider](/getting-started/kanban/) built-in, where each card is a markdown file under `.tickets/` and the column is the directory it sits in. + +You can run on it alone, or connect an external provider like Jira, Linear or +GitHub Projects. Those sync *into* the Operator board: their issues arrive as +tickets here, and Operator maps its three states back onto your board's columns +as agents work. + +## Card order + +Both the web board and the agent launcher order work the same way, so what you +see at the top of the TODO column is what runs next: + +1. **Issue type**, in the order set by `queue.priority_order` (default + `INV > FIX > TASK > FEAT > SPIKE`). A type the list does not mention sorts + after every type it does. +2. **The ticket's own `priority:`** field - `P0-critical`, `P1-high`, + `P2-medium` (the default) or `P3-low`. +3. **Oldest first**, by the timestamp in the filename. + +The DONE column ignores all of that and shows the most recently completed first. + +## Filtering the board + +The board at `/queue` shows every ticket in every project, which gets long fast. +The filter bar above it narrows the view without touching any ticket: + +- **Search** matches a ticket's ID or summary. Several words all have to match. +- **Project**, **Type** and **Priority** are drawn from the tickets actually on + the board, so a collection's custom issue types appear as soon as one exists. + Picking several options in one facet widens it; picking across facets narrows. + +Filters are yours alone - they live in your browser, not in the config or on the +server - and they survive a reload. The counter reads `N of M tickets` while any +filter is on, and **Clear filters** appears only when there is something to +clear. diff --git a/docs/getting-started/sessions/cursor.md b/docs/getting-started/ides/cursor.md similarity index 99% rename from docs/getting-started/sessions/cursor.md rename to docs/getting-started/ides/cursor.md index ed814fd8..26c92ab2 100644 --- a/docs/getting-started/sessions/cursor.md +++ b/docs/getting-started/ides/cursor.md @@ -2,6 +2,7 @@ title: "Cursor" description: "Cursor IDE integration via the operator-terminals VS Code extension and Cursor's native MCP support." layout: doc +published: false --- Supported diff --git a/docs/getting-started/ides/index.md b/docs/getting-started/ides/index.md new file mode 100644 index 00000000..0b938f4e --- /dev/null +++ b/docs/getting-started/ides/index.md @@ -0,0 +1,14 @@ +--- +title: "IDEs" +description: "Editor integrations and their compatible session controllers." +layout: doc +--- + +IDE integrations connect an editor to Operator. Session controllers manage agent terminals; select a controller implemented for the environment where those terminals run. + +| IDE | Status | Session control | +|---|---|---| +| [VS Code](/getting-started/ides/vscode/) | Beta | [VS Code Terminals](/getting-started/sessions/vscode-terminals/), serialized as `vscode` | +| [Zed](/getting-started/ides/zed/) | Alpha | MCP/ACP integration; no IDE terminal controller | + +cmux, tmux, and Zellij cannot control VS Code's integrated terminals. See [Session Management](/getting-started/sessions/) for their independent terminal environments. diff --git a/docs/getting-started/ides/vscode.md b/docs/getting-started/ides/vscode.md new file mode 100644 index 00000000..c917d804 --- /dev/null +++ b/docs/getting-started/ides/vscode.md @@ -0,0 +1,112 @@ +--- +title: "VS Code Extension" +description: "VS Code terminal integration for Operator multi-agent orchestration." +layout: doc +--- + +Recommended + +Install from VS Code Marketplace + +Operator Terminals brings the Operator multi-agent orchestration experience directly into VS Code with integrated terminal management and ticket tracking. Its [VS Code session controller](/getting-started/sessions/vscode-terminals/) supports **macOS**, **Linux**, and **Windows** (no WSL required). Select the `vscode` controller for IDE terminals; tmux, cmux, and Zellij manage their own terminal environments. + +## Features + +- **Sidebar Integration**: View Queue, In Progress, and Completed tickets directly in VS Code +- **Styled Terminals**: Color-coded terminals by ticket type + - FEAT (cyan, sparkle icon) + - FIX (red, wrench icon) + - TASK (green, tasklist icon) + - SPIKE (magenta, beaker icon) + - INV (yellow, search icon) +- **Activity Tracking**: Monitors shell execution to detect idle/running states +- **Webhook Server**: Local HTTP server for Operator communication + +## Installation + +### From Marketplace (Recommended) + +1. Open VS Code +2. Go to Extensions (`Ctrl+Shift+X` / `Cmd+Shift+X`) +3. Search for "Operator Terminals" +4. Click Install + +Or install directly: [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals){:target="_blank"} + +### Manual Installation + +1. Download the `.vsix` file from [GitHub releases](https://github.com/untra/operator/releases){:target="_blank"} +2. In VS Code, go to Extensions +3. Click the "..." menu +4. Select "Install from VSIX..." + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `operator.webhookPort` | `7009` | Port for webhook server | +| `operator.autoStart` | `true` | Start server on VS Code launch | +| `operator.terminalPrefix` | `op-` | Prefix for managed terminal names | +| `operator.ticketsDir` | `.tickets` | Path to tickets directory | +| `operator.apiUrl` | `http://localhost:7008` | Operator REST API URL | + +## Commands + +Access via Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): + +| Command | Description | +|---------|-------------| +| `Operator: Start Webhook Server` | Start the webhook server | +| `Operator: Stop Webhook Server` | Stop the webhook server | +| `Operator: Show Server Status` | Display server status | +| `Operator: Launch Ticket` | Launch a ticket in a new terminal | +| `Operator: Launch Ticket (with options)` | Launch with agent/mode selection | +| `Operator: Download Operator` | Download the Operator CLI | + +## Sidebar Views + +The extension adds an Operator sidebar with four views: + +1. **Status**: Server status and connection info +2. **In Progress**: Currently running agent sessions +3. **Queue**: Pending tickets waiting to be launched +4. **Completed**: Recently completed tickets (collapsed by default) + +## API Endpoints + +The extension exposes a local HTTP API for Operator communication: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `GET /health` | GET | Server health check | +| `POST /terminal/create` | POST | Create a new terminal | +| `POST /terminal/:name/send` | POST | Send command to terminal | +| `POST /terminal/:name/show` | POST | Reveal terminal (keep focus) | +| `POST /terminal/:name/focus` | POST | Focus terminal (take focus) | +| `DELETE /terminal/:name/kill` | DELETE | Dispose terminal | +| `GET /terminal/:name/exists` | GET | Check if terminal exists | +| `GET /terminal/:name/activity` | GET | Get idle/running state | +| `GET /terminal/list` | GET | List all managed terminals | + +## Requirements + +- VS Code 1.85.0 or later +- Operator CLI (for full functionality) + +## Troubleshooting + +### Server won't start + +Check if another process is using the configured port: + +```bash +lsof -i :7009 +``` + +Try a different port in settings: `operator.webhookPort`. + +### Terminals not appearing in sidebar + +1. Ensure the webhook server is running (check Status view) +2. Verify `operator.ticketsDir` points to your tickets directory +3. Refresh the views using the refresh button diff --git a/docs/getting-started/ides/zed.md b/docs/getting-started/ides/zed.md new file mode 100644 index 00000000..c2d2a214 --- /dev/null +++ b/docs/getting-started/ides/zed.md @@ -0,0 +1,96 @@ +--- +title: "Zed" +description: "Zed editor integration for Operator via MCP context server, ACP agent, and slash commands." +layout: doc +--- + +Zed provides MCP and ACP integration. It does not provide an Operator-managed terminal session controller; terminal sessions remain a separate configuration choice. + +Alpha + +
+This integration is in alpha and may have limited functionality or incomplete support. +
+ +The [Zed](https://zed.dev) extension for Operator provides three integration layers: an MCP context server for tools and resources, an ACP agent server for delegated prompts, and slash commands for quick operations. + +## Prerequisites + +- [Operator](https://operator.untra.io) installed and on PATH +- Zed editor + +## Installation + +1. Open Zed +2. Open the Extensions panel (**Zed > Extensions** or `Cmd+Shift+X`) +3. Search for **Operator** +4. Click **Install** + +## Setup + +### MCP Context Server (automatic) + +After installing the extension, Zed automatically registers `operator mcp` as a context server. All Operator tools appear in the Agent Panel: + +- `operator_health` / `operator_status` - system health +- `operator_list_tickets` - query queue, in-progress, completed tickets +- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` - ticket lifecycle +- `operator_create_ticket` - create tickets from templates +- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` - registry queries +- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` - queue operations +- `operator_approve_agent` / `operator_reject_agent` - review actions + +If the `operator` binary is not found, the extension shows installation instructions. + +### ACP Agent Server (one-time setup) + +Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel - you can send prompts that flow through ACP to a Claude Code delegator. + +## Slash Commands + +| Command | Description | +|---------|-------------| +| `/op-status` | Show Operator health and status | +| `/op-queue` | List tickets in queue | +| `/op-launch TICKET-ID` | Launch a ticket | +| `/op-active` | List active agents | +| `/op-completed` | List recently completed tickets | +| `/op-ticket TICKET-ID` | Show ticket details | +| `/op-pause` | Pause queue processing | +| `/op-resume` | Resume queue processing | +| `/op-sync` | Sync kanban collections | +| `/op-approve AGENT-ID` | Approve agent review | +| `/op-reject AGENT-ID REASON` | Reject agent review | +| `/op-setup-agent` | Generate ACP agent server config | + +Commands with arguments support tab-completion from live API data. + +## How It Works + +Operator integrates with Zed through three communication channels: + +- **MCP Context Server** - Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration. +- **ACP Agent Server** - Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed. +- **Slash Commands** - Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant. + +## Configuration + +The Operator binary must be on your PATH. The extension also checks common install locations (`/usr/local/bin`, `/opt/homebrew/bin`). The REST API URL for slash commands defaults to `http://localhost:7008`. + +## Troubleshooting + +### MCP tools not appearing + +1. Verify Operator is on PATH: `which operator` +2. Test MCP server: `operator mcp` (should wait for JSON-RPC input) +3. Check Zed's extension logs: **View > Output > Extensions** + +### Slash commands failing + +1. Check that Operator API is running: `operator api` +2. Verify connectivity: `curl http://localhost:7008/api/v1/health` + +### Extension not appearing + +1. Open the Extensions panel and verify Operator is listed as installed +2. Try **Zed > Extensions > Reload** or restart Zed diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index fe989cfd..23c1384d 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -18,7 +18,19 @@ Welcome to Operator! This guide will help you get up and running with AI-assiste Operator is a multi-agent orchestration dashboard that coordinates AI coding assistants with your existing project management and version control workflows. +## Where agents run + +Operator separates *how* a session is managed from *where* the agent process runs. + +- [Session Management](/getting-started/sessions/) - tmux, cmux, Zellij and IDE terminals +- [IDEs](/getting-started/ides/) - VS Code and Zed +- [Execution Transports](/getting-started/transports/) - local execution and SSH +- [Agent Relays](/getting-started/agent-relays/) - streaming agent output back to Operator +- [Remote Targets](/getting-started/remote-targets/) - SSH hosts and Coder workspaces (Premium) +- [Platforms](/getting-started/platforms/) - running Operator itself under Docker or Kubernetes + ## Next Steps - [Prerequisites](/getting-started/prerequisites/) - System requirements and dependencies - [Installation](/getting-started/installation/) - Download and install Operator +- [Operator Premium](/getting-started/premium/) - What Premium adds, and how licensing works diff --git a/docs/getting-started/kanban/index.md b/docs/getting-started/kanban/index.md index 4395b87e..28e9cf0c 100644 --- a/docs/getting-started/kanban/index.md +++ b/docs/getting-started/kanban/index.md @@ -4,7 +4,9 @@ description: "Kanban and issue tracking integrations for Operator." layout: doc --- -Operator integrates with popular issue tracking systems to manage work items for AI agents. +Operator! is itself a kanban board, available by default. + +Issues are defined as markdown tickets on the Operator board, launching tickets with delegated agents allows the work to be done according to the workflows and standards you define. ## Available Integrations @@ -12,6 +14,7 @@ Statuses follow the [feature maturity](/maturity/) scale. | Provider | Status | Notes | |----------|--------|-------| +| [Operator](/getting-started/kanban/operator/) | GA | Built in; the `.tickets/` markdown is the board | | [Jira Cloud](/getting-started/kanban/jira/) | Beta | Full API integration | | [Linear](/getting-started/kanban/linear/) | Beta | Full API integration | | [GitHub Projects](/getting-started/kanban/github/) | Beta | Projects v2 GraphQL integration | @@ -19,12 +22,15 @@ Statuses follow the [feature maturity](/maturity/) scale. ## How It Works -Operator syncs tickets from your kanban provider: +The Operator board always exists. Connecting an external provider adds a sync +loop on top of it: 1. **Pull**: Fetches issues from configured boards/projects -2. **Queue**: Orders tickets by priority and type +2. **Queue**: Writes them as tickets on the Operator board, ordered by type then FIFO 3. **Assign**: Dispatches tickets to available agents -4. **Update**: Pushes status changes back to your provider +4. **Update**: Pushes column changes back to the originating provider + +The Operator board is only ever the destination of a sync, never a source. ## The Ticket Lifecycle @@ -86,11 +92,17 @@ as dropdowns. See the per-provider guides for details. ## Choosing a Provider +- **Operator**: The default. Best when the work starts here - no setup, no + credentials, tickets versioned alongside your code. Start here and add an + external provider only when work has to be visible to people outside Operator. - **Jira Cloud**: Best for teams already using Atlassian products, with rich workflow customization - **Linear**: Best for teams wanting a modern, fast issue tracker with streamlined workflows - **GitHub Projects**: Best when your work already lives in GitHub issues and Projects v2 boards - **OpenSpec**: Best for local, spec-driven change tracking without an external tracker (pull-only) -## Local Tickets +## The Operator Board -Operator also supports local-only tickets in `.tickets/queue/` for projects without external issue tracking. See [Tickets](/getting-started/tickets/) for details. +Tickets in `.tickets/` are not a fallback for projects without an issue tracker - +they are the board every provider syncs into. See +[Operator](/getting-started/kanban/operator/) for how to work it, and +[Tickets](/getting-started/tickets/) for the ticket format. diff --git a/docs/getting-started/kanban/operator.md b/docs/getting-started/kanban/operator.md new file mode 100644 index 00000000..df4e5fa5 --- /dev/null +++ b/docs/getting-started/kanban/operator.md @@ -0,0 +1,64 @@ +--- +title: "Operator" +description: "The built-in kanban board: your markdown tickets, the columns agents pull from." +layout: doc +--- + +Operator is its own kanban provider, and the one it encourages you to start with. +There is nothing to connect and no credential to store: the board is the `.tickets/` directory on the server, and every other provider syncs *into* it. + +## The board is the directory + +A ticket is a markdown file. Which column it appears in is which directory it lives in: + +``` +.tickets/queue/ -> TODO QUEUE work waiting to be pulled +.tickets/in-progress/ -> IN PROGRESS an agent (or you) is on it +.tickets/completed/ -> DONE finished work +``` + +## Why it is the default + +Operator's job is to run structured, agent-dispatched work. That needs a queue it fully controls. + +The Operator workflow graph is attached to each issue type. The built-in board is that queue - external providers are a way to *feed* it, not a replacement for it. + +- **No setup.** It is active the moment Operator starts. +- **No credentials.** Nothing to rotate, nothing to leak. +- **Works offline.** No API to be rate-limited by. +- **Your format.** Ticket frontmatter is [documented](/schemas/metadata/) and yours to extend. + +## Working the board + +From the web UI's Queue page: + +- **Add a card.** *+ New ticket* opens a form - issue type, project, summary. The ticket is written to `.tickets/queue/` through the same endpoint the CLI and MCP use, so it is identical to one created anywhere else. +- **Launch a card.** Click a card in TODO QUEUE or IN PROGRESS to open its detail panel: the ticket, its issue type's workflow graph, and a launch form where you pick the delegator, session wrapper and execution target. +- **Inspect a finished card.** DONE cards open read-only - the detail and the workflow it ran, without launch controls. + +From the terminal: + +```bash +operator create # new ticket +operator queue # show the board +operator launch # launch the next ticket +``` + +## Relationship to external providers + +Connecting [Jira](/getting-started/kanban/jira/), [Linear](/getting-started/kanban/linear/), [GitHub Projects](/getting-started/kanban/github/) or +another Kanban provider does not replace this board. Their issues are pulled in as markdown tickets here, and with `bidirectional = true` +each column change is pushed back to the originating board. + +The Operator board is never a sync *source*. It is the destination, so it takes no `[kanban.operator]` config section and does not appear in `operator sync`. + +## Configuration + +None. The only related setting is where the tickets live: + +```toml +[paths] +tickets = ".tickets" +``` + +See [Tickets](/getting-started/tickets/) for the ticket format and [Kanban](/getting-started/concepts/kanban/) for the concepts behind the columns. diff --git a/docs/getting-started/platforms/coder.md b/docs/getting-started/platforms/coder.md index 0b539fdd..b63288bb 100644 --- a/docs/getting-started/platforms/coder.md +++ b/docs/getting-started/platforms/coder.md @@ -1,207 +1,5 @@ --- title: "Coder" -description: "Run Operator inside a Coder workspace, or point Operator at Coder to spawn per-ticket agent workspaces." -layout: doc +layout: redirect +redirect_to: /getting-started/remote-targets/coder/ --- - -Alpha - -[Operator](https://operator.untra.io) and [Coder](https://coder.com) fit together in two directions. They are independent - pick the one that matches where Operator runs. - -| | Operator runs | Agents run | Set up with | -|---|---|---|---| -| **[Inside a workspace](#operator-inside-a-coder-workspace)** | in a Coder workspace | in that same workspace | the Terraform module | -| **[Targeting Coder](#operator-targeting-coder)** | anywhere (Kubernetes, a server, your laptop) | in per-ticket Coder workspaces | a `[[targets]]` entry | - -The two can be combined: Operator inside a workspace can also spawn *sibling* workspaces. See [child agent workspaces](#child-agent-workspaces). - -## Operator inside a Coder workspace - -A Terraform module runs Operator as a background REST API server in the workspace, and exposes its dashboard as a Coder app with healthchecks. - -**Registry:** [`registry.coder.com/untra/operator/coder`](https://registry.coder.com/modules/operator) - -Templates and modules do different jobs here: a **template** is the whole workspace blueprint (cloud, compute, storage), while a **module** adds one feature inside it. Operator is a module - you drop it into a template you already have. - -### Usage - -```tf -module "operator" { - source = "registry.coder.com/untra/operator/coder" - agent_id = coder_agent.main.id -} -``` - -Pin `version` to a published module release for reproducible builds. `install_version` is a separate knob that selects which Operator release the module downloads. - -```tf -module "operator" { - source = "registry.coder.com/untra/operator/coder" - agent_id = coder_agent.main.id - port = 7008 - max_parallel_agents = 4 - session_wrapper = "tmux" -} -``` - -### Full TOML override - -`config_toml` is written verbatim and replaces the generated config entirely. - -```tf -module "operator" { - source = "registry.coder.com/untra/operator/coder" - agent_id = coder_agent.main.id - config_toml = <<-EOT - [rest_api] - enabled = true - port = 7008 - - [agents] - max_parallel = 4 - - [sessions] - wrapper = "tmux" - EOT -} -``` - -### Variables - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `agent_id` | `string` | (required) | The ID of a Coder agent | -| `port` | `number` | `7008` | The port for the operator REST API server | -| `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | -| `slug` | `string` | `"operator"` | Application slug | -| `install_version` | `string` | `"{{ site.version }}"` | Operator GitHub release tag to install | -| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binaries into | -| `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | -| `config_toml` | `string` | `""` | Raw TOML config (written verbatim instead of auto-generated config) | -| `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | -| `session_wrapper` | `string` | `"tmux"` | Session wrapper type (`tmux`, `cmux`, or `zellij`) | -| `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | -| `order` | `number` | `null` | Position of the app in the Coder dashboard (lower = first) | -| `group` | `string` | `null` | Group that this app belongs to | -| `offline` | `bool` | `false` | Skip downloading; requires a pre-installed binary at `install_prefix` | -| `use_cached` | `bool` | `false` | Use cached binary if present, otherwise download | - -### Child agent workspaces - -Set `agent_template` and the generated config gains a `[[targets]]` entry with `kind = "coder"`, so tickets launched from this workspace create sibling workspaces from that template instead of running agents locally. These variables are ignored unless `agent_template` is set, and any left unset are omitted from the config so Operator's own defaults apply. - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `agent_template` | `string` | `""` | Template child agent workspaces are created from. Empty disables the coder target. | -| `coder_token_env` | `string` | `"CODER_SESSION_TOKEN"` | Name of the env var holding the Coder **user session token** | -| `callback_url` | `string` | `""` | Control-plane-reachable `OPERATOR_API_URL` override; empty keeps the reverse-tunnel default | -| `name_prefix` | `string` | `""` | Workspace name prefix for deterministic per-ticket naming | -| `workdir` | `string` | `""` | Project root inside spawned workspaces | -| `stop_on_complete` | `bool` | `null` | Stop a spawned workspace when its ticket completes (never deletes) | -| `create_timeout_secs` | `number` | `null` | Bound on workspace create plus agent-ready wait, in seconds | - -This mode needs a **user session token**, which is not the ambient `CODER_AGENT_TOKEN` - that one is scoped to a single workspace and cannot create others. The module does not provision it; supply it through your template. A user session token can create, delete, and SSH into every workspace its user owns, so scope the account accordingly. - -### Prerequisites - -The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. - -For child agent workspaces, the image also needs `ssh` (`openssh-client`), since agents are launched over real SSH. - -### Coder workspace context - -Coder automatically injects environment variables into every workspace that Operator can reference in ticket templates and agent prompts: - -- `CODER_WORKSPACE_NAME` - workspace identifier -- `CODER_WORKSPACE_OWNER` - workspace owner username -- `CODER_URL` - deployment URL, which the coder target reads by default -- `CODER_AGENT_TOKEN` - agent authentication token, scoped to this workspace - -No Operator configuration is needed to access these - they are ambient in the workspace environment. - -### How it works - -1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) -2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary), then the `opr8r` client binary that agent sessions call to report step completion for multi-step workflows. -3. Generates a TOML configuration file (or uses the provided `config_toml`) -4. Starts `operator api` as a background process -5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds - -## Operator targeting Coder - -Here Operator runs outside Coder - most often as the [Kubernetes deployment](/getting-started/platforms/kubernetes/) - and provisions a Coder workspace per ticket. The Terraform module is not involved. - -Declare a target. `template` is an allowlist: agents can only ever land on the template you name here. - -```toml -[[targets]] -name = "cloud" -kind = "coder" -template = "operator-agent" -``` - -Then reference it from a delegator, or set it as the default target. Full field reference: [execution targets](/delegators/#execution-targets). - -### Credentials - -Operator reads two environment variables, resolved **by name** so the values never enter the config file or the state store: - -| Variable | Default name | Holds | -|----------|--------------|-------| -| `url_env` | `CODER_URL` | Your deployment URL, e.g. `https://coder.example.com` | -| `token_env` | `CODER_SESSION_TOKEN` | A Coder user session token | - -A session token can create, delete, and SSH into every workspace its user owns, so give Operator its own service account rather than a human's credentials. Operator strips the token variable from every agent's spawn environment, on every target kind - including `local` agents, which would otherwise read it straight out of `env`. - -Keep both variables at their default names unless you have a reason not to. The SSH `ProxyCommand` runs the `coder` CLI as a subprocess, and the CLI reads these canonical names from the inherited environment. - -### The `coder` CLI - -Operator does not bundle the CLI. It resolves one in this order: - -1. `coder` on `PATH` -2. A previously downloaded copy in the state directory, at `.tickets/operator/bin/coder` -3. Otherwise it downloads `{CODER_URL}/bin/coder-linux-{amd64,arm64}` - your deployment serves a CLI matching its own version - and caches it at (2) - -So a container needs no CLI baked in, and the CLI can never drift from the server it talks to. It does need `ssh` and outbound network access to the deployment. The official image ships `openssh-client`; if you supply your own, include it. - -### Workspace lifecycle - -- **Naming is deterministic:** `{name_prefix}-{project}-{ticket_id}`, sanitized and capped at Coder's 32-character limit. Relaunching a ticket reuses its workspace. -- **Create or start:** absent workspaces are created from `template`; existing ones on that template are started. -- **Collisions are refused:** a workspace of the same name on a *different* template stops the launch rather than being reused, so Operator can never adopt a workspace a human made. -- **Never deleted:** completed workspaces are stopped (when `stop_on_complete` is set). Reclamation stays with your Coder autostop and autodelete policy. - -Operator writes its own per-workspace SSH config fragment under `.tickets/operator/ssh/` rather than running `coder config-ssh`, which would rewrite `~/.ssh/config`. The fragment proxies through `coder ssh --stdio` and skips host-key checking, matching what `coder config-ssh` writes for its own hosts: the Coder tailnet is the authentication boundary, and per-ticket workspaces are too short-lived for trust-on-first-use to be meaningful. - -### Constraints - -For `coder` targets, as for `ssh` targets, git worktrees and relay MCP injection are forced off, and the `zellij` session wrapper is unsupported. - -## Troubleshooting - -### Binary download fails (module) - -1. Check that the `install_version` matches a valid [GitHub release tag](https://github.com/untra/operator/releases) -2. Verify the workspace has internet access (or use `offline = true` with a pre-installed binary) -3. Check logs at the configured `log_path` (default: `/tmp/operator.log`) - -### Healthcheck timeout (module) - -1. Verify the port is not already in use: `ss -tlnp | grep 7008` -2. Check operator logs: `cat /tmp/operator.log` -3. Ensure the session wrapper (tmux by default) is installed in the workspace image - -### Port conflicts (module) - -Change the `port` variable to an unused port. Remember to update any other services or extensions that connect to the Operator API. - -### A coder target fails to launch - -Operator fails fast and names what is missing. In order: - -1. **A missing environment variable** - the error names it. Confirm `CODER_URL` and the session token are present in Operator's own environment, not just the agent's. -2. **The CLI download fails** - the error names the URL it tried. Usually egress: from a container, check reachability directly, e.g. `curl -sSf $CODER_URL/api/v2/buildinfo`. In Kubernetes this is commonly Coder's *own* ingress NetworkPolicy declining to admit Operator's namespace, which is a fix on the Coder side. -3. **`coder create` fails** - the message is Coder's own, verbatim. Template permissions and workspace quotas surface here. -4. **The workspace never becomes reachable over SSH** within `create_timeout_secs` - the template's agent is not starting, or `ssh` is missing from Operator's environment. -5. **A refused name collision** - a workspace of that name already exists on another template. Rename or remove it. diff --git a/docs/getting-started/platforms/index.md b/docs/getting-started/platforms/index.md index ff5127cd..493965cb 100644 --- a/docs/getting-started/platforms/index.md +++ b/docs/getting-started/platforms/index.md @@ -10,6 +10,7 @@ Operator can run as a background service in remote workspace platforms, providin | Option | Status | Notes | |--------|--------|-------| -| [Coder](/getting-started/platforms/coder/) | Supported | Terraform module, runs Operator as background API server with dashboard | | [Docker](/getting-started/platforms/docker/) | Supported | Official multi-arch image (`untra/operator`); container is the workspace, mount your projects root at `/op` | | [Kubernetes](/getting-started/platforms/kubernetes/) | Alpha | OCI Helm chart; single-replica StatefulSet with persistent workspace, authenticated REST API and dashboard | + +[Coder workspaces](/getting-started/remote-targets/coder/) are documented under [Remote Targets](/getting-started/remote-targets/). Running agents together with Operator in the same workspace remains local execution. diff --git a/docs/getting-started/premium/index.md b/docs/getting-started/premium/index.md new file mode 100644 index 00000000..993217e3 --- /dev/null +++ b/docs/getting-started/premium/index.md @@ -0,0 +1,81 @@ +--- +title: "Operator Premium" +description: "What is included, what Premium adds, and how a license is installed and verified." +layout: doc +--- + +Operator is free for local work for developers to work on their laptops. +Operator Premium is a paid tier that includes features ideal for larger teams or work done on more machines. + +## What each tier covers + +| | Included | Premium | +|---|---|---| +| Local agents | Multiple, in parallel | Multiple, in parallel | +| Local containers ([Docker](/getting-started/platforms/docker/)) | Yes | Yes | +| Named configurations | Yes | Yes | +| Dashboard over the network | Yes | Yes | +| External model APIs | Yes | Yes | +| [SSH hosts](/getting-started/remote-targets/ssh/) | - | Yes | +| [Coder workspaces](/getting-started/remote-targets/coder/) | - | Yes | + +Reaching the dashboard over a network, and calling a model provider's API over the +internet, are not remote execution. Only running an **agent process** on another +machine requires Premium. + +## Installing a license + +A license is a single text key. Install it from either surface: + +- **Terminal** - the Operator Premium step of the setup wizard, or the License + section of the dashboard. +- **Browser** - *License* under Premium in the sidebar. + +Installing validates the key before storing it, so a rejected key leaves any +existing license in place. The key is stored in the configuration's state +directory with owner-only permissions and is never returned by the API or +written to logs. + +## How verification works + +Verification is **entirely offline**. Operator never contacts a licensing +service, at install time or afterwards, and there is no activation step. + +A license is a signed token carrying the customer it was issued to, its license +id, its tier, the configuration it belongs to, and its validity dates. Operator +checks the signature against verification keys compiled into the binary, then +checks those claims. A license is bound to one **configuration**, identified by +a stable id that survives renaming the configuration. + +### Status + +| Status | Meaning | +|---|---| +| Free | No license installed. Local execution is unaffected | +| Premium | Verified and currently valid | +| Expired | Verified, but past its end date | +| Not yet valid | Verified, but its start date is in the future | +| Invalid | Signature, issuer, tier, configuration or format rejected | + +Only **Premium** grants remote execution. An unrecognised tier grants nothing. + +## When a license expires + +Nothing is killed. Running agents keep running, and a completion report from an +agent already in flight is still accepted and recorded. What stops is *starting* +further remote work: the next launch is refused before anything is provisioned. + +Configured remote targets stay visible and readable without a license, and +removing one always works. Registering, editing or probing a target requires +Premium. + +## Building from source + +Verification keys are supplied at build time and are **not** in this repository, +so a build from source carries none and rejects every license - Premium is +unreachable in such a build, by design. This repository contains no signing key, +issuer service, checkout, or revocation service; it only *consumes* licenses +issued elsewhere. + +The build-time inputs are listed under Licensing in the +[CLI reference](/cli/). A release build refuses to compile without them. diff --git a/docs/getting-started/remote-targets/coder.md b/docs/getting-started/remote-targets/coder.md new file mode 100644 index 00000000..c5d8ebe4 --- /dev/null +++ b/docs/getting-started/remote-targets/coder.md @@ -0,0 +1,209 @@ +--- +title: "Coder" +description: "Run Operator inside a Coder workspace, or point Operator at Coder to spawn per-ticket agent workspaces." +layout: doc +--- + +Alpha + +**Premium:** launching agents into remote Coder workspaces requires a Premium license for the selected Operator configuration. Running Operator and its agents together inside one workspace remains local execution. + +[Operator](https://operator.untra.io) and [Coder](https://coder.com) fit together in two directions. They are independent - pick the one that matches where Operator runs. + +| | Operator runs | Agents run | Set up with | +|---|---|---|---| +| **[Inside a workspace](#operator-inside-a-coder-workspace)** | in a Coder workspace | in that same workspace | the Terraform module | +| **[Targeting Coder](#operator-targeting-coder)** | anywhere (Kubernetes, a server, your laptop) | in per-ticket Coder workspaces | a `[[targets]]` entry | + +The two can be combined: Operator inside a workspace can also spawn *sibling* workspaces. See [child agent workspaces](#child-agent-workspaces). + +## Operator inside a Coder workspace + +A Terraform module runs Operator as a background REST API server in the workspace, and exposes its dashboard as a Coder app with healthchecks. + +**Registry:** [`registry.coder.com/untra/operator/coder`](https://registry.coder.com/modules/operator) + +Templates and modules do different jobs here: a **template** is the whole workspace blueprint (cloud, compute, storage), while a **module** adds one feature inside it. Operator is a module - you drop it into a template you already have. + +### Usage + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + agent_id = coder_agent.main.id +} +``` + +Pin `version` to a published module release for reproducible builds. `install_version` is a separate knob that selects which Operator release the module downloads. + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + agent_id = coder_agent.main.id + port = 7008 + max_parallel_agents = 4 + session_wrapper = "tmux" +} +``` + +### Full TOML override + +`config_toml` is written verbatim and replaces the generated config entirely. + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + agent_id = coder_agent.main.id + config_toml = <<-EOT + [rest_api] + enabled = true + port = 7008 + + [agents] + max_parallel = 4 + + [sessions] + wrapper = "tmux" + EOT +} +``` + +### Variables + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_id` | `string` | (required) | The ID of a Coder agent | +| `port` | `number` | `7008` | The port for the operator REST API server | +| `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | +| `slug` | `string` | `"operator"` | Application slug | +| `install_version` | `string` | `"{{ site.version }}"` | Operator GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binaries into | +| `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | +| `config_toml` | `string` | `""` | Raw TOML config (written verbatim instead of auto-generated config) | +| `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | +| `session_wrapper` | `string` | `"tmux"` | Session wrapper type (`tmux`, `cmux`, or `zellij`) | +| `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | +| `order` | `number` | `null` | Position of the app in the Coder dashboard (lower = first) | +| `group` | `string` | `null` | Group that this app belongs to | +| `offline` | `bool` | `false` | Skip downloading; requires a pre-installed binary at `install_prefix` | +| `use_cached` | `bool` | `false` | Use cached binary if present, otherwise download | + +### Child agent workspaces + +Set `agent_template` and the generated config gains a `[[targets]]` entry with `kind = "coder"`, so tickets launched from this workspace create sibling workspaces from that template instead of running agents locally. These variables are ignored unless `agent_template` is set, and any left unset are omitted from the config so Operator's own defaults apply. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_template` | `string` | `""` | Template child agent workspaces are created from. Empty disables the coder target. | +| `coder_token_env` | `string` | `"CODER_SESSION_TOKEN"` | Name of the env var holding the Coder **user session token** | +| `callback_url` | `string` | `""` | Control-plane-reachable `OPERATOR_API_URL` override; empty keeps the reverse-tunnel default | +| `name_prefix` | `string` | `""` | Workspace name prefix for deterministic per-ticket naming | +| `workdir` | `string` | `""` | Project root inside spawned workspaces | +| `stop_on_complete` | `bool` | `null` | Stop a spawned workspace when its ticket completes (never deletes) | +| `create_timeout_secs` | `number` | `null` | Bound on workspace create plus agent-ready wait, in seconds | + +This mode needs a **user session token**, which is not the ambient `CODER_AGENT_TOKEN` - that one is scoped to a single workspace and cannot create others. The module does not provision it; supply it through your template. A user session token can create, delete, and SSH into every workspace its user owns, so scope the account accordingly. + +### Prerequisites + +The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +For child agent workspaces, the image also needs `ssh` (`openssh-client`), since agents are launched over real SSH. + +### Coder workspace context + +Coder automatically injects environment variables into every workspace that Operator can reference in ticket templates and agent prompts: + +- `CODER_WORKSPACE_NAME` - workspace identifier +- `CODER_WORKSPACE_OWNER` - workspace owner username +- `CODER_URL` - deployment URL, which the coder target reads by default +- `CODER_AGENT_TOKEN` - agent authentication token, scoped to this workspace + +No Operator configuration is needed to access these - they are ambient in the workspace environment. + +### How it works + +1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) +2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary), then the `opr8r` client binary that agent sessions call to report step completion for multi-step workflows. +3. Generates a TOML configuration file (or uses the provided `config_toml`) +4. Starts `operator api` as a background process +5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds + +## Operator targeting Coder + +Here Operator runs outside Coder - most often as the [Kubernetes deployment](/getting-started/platforms/kubernetes/) - and provisions a Coder workspace per ticket. The Terraform module is not involved. + +Declare a target. `template` is an allowlist: agents can only ever land on the template you name here. + +```toml +[[targets]] +name = "cloud" +kind = "coder" +template = "operator-agent" +``` + +Then reference it from a delegator, or set it as the default target. Full field reference: [execution targets](/delegators/#execution-targets). + +### Credentials + +Operator reads two environment variables, resolved **by name** so the values never enter the config file or the state store: + +| Variable | Default name | Holds | +|----------|--------------|-------| +| `url_env` | `CODER_URL` | Your deployment URL, e.g. `https://coder.example.com` | +| `token_env` | `CODER_SESSION_TOKEN` | A Coder user session token | + +A session token can create, delete, and SSH into every workspace its user owns, so give Operator its own service account rather than a human's credentials. Operator strips the token variable from every agent's spawn environment, on every target kind - including `local` agents, which would otherwise read it straight out of `env`. + +Keep both variables at their default names unless you have a reason not to. The SSH `ProxyCommand` runs the `coder` CLI as a subprocess, and the CLI reads these canonical names from the inherited environment. + +### The `coder` CLI + +Operator does not bundle the CLI. It resolves one in this order: + +1. `coder` on `PATH` +2. A previously downloaded copy in the state directory, at `.tickets/operator/bin/coder` +3. Otherwise it downloads `{CODER_URL}/bin/coder-linux-{amd64,arm64}` - your deployment serves a CLI matching its own version - and caches it at (2) + +So a container needs no CLI baked in, and the CLI can never drift from the server it talks to. It does need `ssh` and outbound network access to the deployment. The official image ships `openssh-client`; if you supply your own, include it. + +### Workspace lifecycle + +- **Naming is deterministic:** `{name_prefix}-{project}-{ticket_id}`, sanitized and capped at Coder's 32-character limit. Relaunching a ticket reuses its workspace. +- **Create or start:** absent workspaces are created from `template`; existing ones on that template are started. +- **Collisions are refused:** a workspace of the same name on a *different* template stops the launch rather than being reused, so Operator can never adopt a workspace a human made. +- **Never deleted:** completed workspaces are stopped (when `stop_on_complete` is set). Reclamation stays with your Coder autostop and autodelete policy. + +Operator writes its own per-workspace SSH config fragment under `.tickets/operator/ssh/` rather than running `coder config-ssh`, which would rewrite `~/.ssh/config`. The fragment proxies through `coder ssh --stdio` and skips host-key checking, matching what `coder config-ssh` writes for its own hosts: the Coder tailnet is the authentication boundary, and per-ticket workspaces are too short-lived for trust-on-first-use to be meaningful. + +### Constraints + +For `coder` targets, as for `ssh` targets, git worktrees and relay MCP injection are forced off, and the `zellij` session wrapper is unsupported. + +## Troubleshooting + +### Binary download fails (module) + +1. Check that the `install_version` matches a valid [GitHub release tag](https://github.com/untra/operator/releases) +2. Verify the workspace has internet access (or use `offline = true` with a pre-installed binary) +3. Check logs at the configured `log_path` (default: `/tmp/operator.log`) + +### Healthcheck timeout (module) + +1. Verify the port is not already in use: `ss -tlnp | grep 7008` +2. Check operator logs: `cat /tmp/operator.log` +3. Ensure the session wrapper (tmux by default) is installed in the workspace image + +### Port conflicts (module) + +Change the `port` variable to an unused port. Remember to update any other services or extensions that connect to the Operator API. + +### A coder target fails to launch + +Operator fails fast and names what is missing. In order: + +1. **A missing environment variable** - the error names it. Confirm `CODER_URL` and the session token are present in Operator's own environment, not just the agent's. +2. **The CLI download fails** - the error names the URL it tried. Usually egress: from a container, check reachability directly, e.g. `curl -sSf $CODER_URL/api/v2/buildinfo`. In Kubernetes this is commonly Coder's *own* ingress NetworkPolicy declining to admit Operator's namespace, which is a fix on the Coder side. +3. **`coder create` fails** - the message is Coder's own, verbatim. Template permissions and workspace quotas surface here. +4. **The workspace never becomes reachable over SSH** within `create_timeout_secs` - the template's agent is not starting, or `ssh` is missing from Operator's environment. +5. **A refused name collision** - a workspace of that name already exists on another template. Rename or remove it. diff --git a/docs/getting-started/remote-targets/index.md b/docs/getting-started/remote-targets/index.md new file mode 100644 index 00000000..bb65a942 --- /dev/null +++ b/docs/getting-started/remote-targets/index.md @@ -0,0 +1,33 @@ +--- +title: "Remote Targets" +description: "Premium execution targets for delegating work to another machine." +layout: doc +--- + +**Premium:** remote targets are available with a valid license for the selected configuration. + +A remote target names an execution environment for a delegator. Operator keeps the queue and orchestration; the target runs agent processes, which report workflow progress through `opr8r` callbacks. + +| Target | Status | Connection | +|---|---|---| +| [SSH Hosts](/getting-started/remote-targets/ssh/) | Alpha | An SSH alias and remote working directory | +| [Coder](/getting-started/remote-targets/coder/) | Alpha | Per-ticket Coder workspaces | + +Local multi-agent execution and local containers remain included. Remote model inference and remote access to the dashboard do not require a remote execution entitlement. + +## Limitations in this release + +Operator enforces these when a delegator resolves to an SSH or Coder target, +rather than failing later in the launch: + +| Constraint | Why | +|---|---| +| Git worktrees are disabled | The worktree is created where Operator runs, not where the agent does | +| Relay injection is disabled | The relay hub is a local socket and is not reachable from the target | +| The Zellij session wrapper is rejected | The launch fails rather than silently running somewhere unexpected | + +A remote launch therefore works in the checkout at the target's working +directory. Use one target per concurrent piece of work, or separate working +directories, rather than relying on worktree isolation. + +Targets remain visible when a license expires. Existing work can report completion; a valid license is required to start further remote work. diff --git a/docs/getting-started/remote-targets/ssh.md b/docs/getting-started/remote-targets/ssh.md new file mode 100644 index 00000000..0385ea26 --- /dev/null +++ b/docs/getting-started/remote-targets/ssh.md @@ -0,0 +1,67 @@ +--- +title: "SSH Hosts" +description: "Launch agent CLI processes on a remote machine over SSH while the Operator dashboard stays local." +layout: doc +--- + +**Premium:** registering and launching remote targets requires a valid Premium license for the selected configuration. [SSH transport](/getting-started/transports/ssh/) provides the connection; the target identifies where work runs. + +Operator can launch an agent's CLI process on a **remote machine** while the dashboard, queue, and tracking stay local. Declare a `[[targets]]` entry and reference it from a delegator's `launch_config`: + +```toml +[[targets]] +name = "gpu-vm" +ssh_alias = "gpu-vm" # resolved via your ~/.ssh/config +workdir = "/srv/agents/my-project" +display_name = "GPU VM" + +[[delegators]] +name = "claude-remote" +llm_tool = "claude" +model = "opus" +[delegators.launch_config] +host = "gpu-vm" +``` + +A host is deliberately distinct from a [model server](/configuration/): a `[[model_servers]]` entry says where model *inference* lives; a `[[hosts]]` entry says where the agent *CLI process* runs. A remote delegator can combine both. + +## How it works + +The local tmux (or cmux) pane Operator creates runs a generated wrapper script that: + +1. Ships the prompt file and run script to `{workdir}/.tickets/operator/` on the host over `ssh` +2. Execs `ssh -t` into a **remote tmux session** +3. Opens an SSH **reverse tunnel** for the REST port, so `opr8r` step-completion callbacks from the remote side reach your local Operator at `http://localhost:{port}` + +Because the tracked pane is local, screen scraping, attach, idle detection, and send-keys all behave exactly as for local agents. The agent row shows an `@{host}` annotation in the dashboard. + +## Remote host requirements + +- **SSH access** via an alias in `~/.ssh/config`, with key-based auth. + Connect once manually first (`ssh gpu-vm`) to accept host keys. Launches use `BatchMode`, which cannot answer interactive prompts. +- **tmux** installed on the remote PATH. +- **The agent CLI** (`claude`, `codex`, `gemini`) on the remote PATH, already + authenticated there (e.g. remote `~/.claude` credentials). +- **The project checked out** at `workdir`. +- **API keys in the remote environment**: model-server keys are passed by + reference (`export ANTHROPIC_API_KEY=${YOUR_VAR}`) and expand in the *remote* shell. Export them in a file sourced by non-interactive shells, or rely on the CLI's own auth. + +Operator preflights all of this (reachability, tmux, tool, workdir) before creating any session and fails the launch with a specific message if a check fails. + +## Disconnects and reconnecting + +If the SSH link drops (laptop sleep, network change), the local pane dies and +the agent shows as dead - but the **remote tmux session and agent survive**. +Relaunch the ticket from the TUI: the wrapper regenerates and `tmux new-session -A` reattaches the surviving remote session with scrollback intact. + +## limitations + +- **No git worktrees** for remote agents - the agent works directly in + `workdir`, regardless of `use_worktrees`. +- **No hook signals or artifact detection** (both read the local filesystem); + liveness relies on pane presence and screen content, the same posture cmux + agents have. +- **No relay MCP injection** (the relay hub is a local Unix socket). +- **No docker mode** and **no zellij wrapper** with a remote host - both are rejected at resolution time. +- **One remote agent per host at a time** is the safe posture: concurrent agents to the same host would collide on the reverse-tunnel port, and the second launch fails loudly. +- Ticket files live on the local machine; remote agents signal progress through `opr8r` callbacks rather than moving ticket files. diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md index abcfd201..ea822825 100644 --- a/docs/getting-started/sessions/index.md +++ b/docs/getting-started/sessions/index.md @@ -10,13 +10,12 @@ Operator supports multiple session management backends for running AI coding age | Option | Status | Notes | |--------|--------|-------| -| [VS Code Extension](/getting-started/sessions/vscode/) | Recommended (Preferred) | Integrated terminals in VS Code, works on all platforms | -| [Cursor](/getting-started/sessions/cursor/) | Supported | Cursor IDE (VS Code fork); same extension, native MCP via `~/.cursor/mcp.json` | +| [VS Code Terminals](/getting-started/sessions/vscode-terminals/) | Beta | Integrated terminals controlled by the VS Code extension | | [tmux](/getting-started/sessions/tmux/) | Supported | Terminal multiplexer, ideal for headless/server environments | | [cmux](/getting-started/sessions/cmux/) | Supported | macOS terminal multiplexer, manages workspaces within cmux | | [Zellij](/getting-started/sessions/zellij/) | Supported | Terminal workspace manager, tab-per-agent model (macOS/Linux) | -| [Zed](/getting-started/sessions/zed/) | Supported | Zed editor extension; MCP context server, ACP agent, slash commands | -| [Remote Hosts (SSH)](/getting-started/sessions/remote-hosts/) | Supported | Run agent CLIs on a remote machine over SSH; dashboard stays local | + +[IDE integrations](/getting-started/ides/) and [execution transports](/getting-started/transports/) are separate choices. A controller manages its own terminal environment: cmux cannot control terminals inside VS Code. ## How It Works @@ -31,8 +30,6 @@ Session managers provide: **VS Code Extension** is the recommended choice for most users. It provides an integrated experience with ticket management, color-coded terminals, and works seamlessly on macOS, Linux, and Windows without additional setup. -**Cursor** is the right choice if you already use Cursor as your daily editor. The same `operator-terminals` extension installs from OpenVSX, and `Operator: Connect MCP Server` writes to Cursor's native `~/.cursor/mcp.json` (stdio) so the operator tool surface shows up in Cursor's MCP UI and chat. - **tmux** remains an excellent choice for headless/server environments, SSH sessions, and users who prefer terminal-based workflows. It's particularly useful for remote servers where VS Code may not be available. **cmux** is a macOS-native option for users already working within cmux. It launches agents as cmux windows or workspaces. Requires macOS and that Operator is running inside a cmux session. diff --git a/docs/getting-started/sessions/remote-hosts/index.md b/docs/getting-started/sessions/remote-hosts/index.md index 6163049f..6793f6ed 100644 --- a/docs/getting-started/sessions/remote-hosts/index.md +++ b/docs/getting-started/sessions/remote-hosts/index.md @@ -1,65 +1,5 @@ --- title: "Remote Hosts (SSH)" -description: "Launch agent CLI processes on a remote machine over SSH while the Operator dashboard stays local." -layout: doc +layout: redirect +redirect_to: /getting-started/remote-targets/ssh/ --- - -Operator can launch an agent's CLI process on a **remote machine** while the dashboard, queue, and tracking stay local. Declare a `[[targets]]` entry and reference it from a delegator's `launch_config`: - -```toml -[[targets]] -name = "gpu-vm" -ssh_alias = "gpu-vm" # resolved via your ~/.ssh/config -workdir = "/srv/agents/my-project" -display_name = "GPU VM" - -[[delegators]] -name = "claude-remote" -llm_tool = "claude" -model = "opus" -[delegators.launch_config] -host = "gpu-vm" -``` - -A host is deliberately distinct from a [model server](/configuration/): a `[[model_servers]]` entry says where model *inference* lives; a `[[hosts]]` entry says where the agent *CLI process* runs. A remote delegator can combine both. - -## How it works - -The local tmux (or cmux) pane Operator creates runs a generated wrapper script that: - -1. Ships the prompt file and run script to `{workdir}/.tickets/operator/` on the host over `ssh` -2. Execs `ssh -t` into a **remote tmux session** -3. Opens an SSH **reverse tunnel** for the REST port, so `opr8r` step-completion callbacks from the remote side reach your local Operator at `http://localhost:{port}` - -Because the tracked pane is local, screen scraping, attach, idle detection, and send-keys all behave exactly as for local agents. The agent row shows an `@{host}` annotation in the dashboard. - -## Remote host requirements - -- **SSH access** via an alias in `~/.ssh/config`, with key-based auth. - Connect once manually first (`ssh gpu-vm`) to accept host keys. Launches use `BatchMode`, which cannot answer interactive prompts. -- **tmux** installed on the remote PATH. -- **The agent CLI** (`claude`, `codex`, `gemini`) on the remote PATH, already - authenticated there (e.g. remote `~/.claude` credentials). -- **The project checked out** at `workdir`. -- **API keys in the remote environment**: model-server keys are passed by - reference (`export ANTHROPIC_API_KEY=${YOUR_VAR}`) and expand in the *remote* shell. Export them in a file sourced by non-interactive shells, or rely on the CLI's own auth. - -Operator preflights all of this (reachability, tmux, tool, workdir) before creating any session and fails the launch with a specific message if a check fails. - -## Disconnects and reconnecting - -If the SSH link drops (laptop sleep, network change), the local pane dies and -the agent shows as dead - but the **remote tmux session and agent survive**. -Relaunch the ticket from the TUI: the wrapper regenerates and `tmux new-session -A` reattaches the surviving remote session with scrollback intact. - -## limitations - -- **No git worktrees** for remote agents - the agent works directly in - `workdir`, regardless of `use_worktrees`. -- **No hook signals or artifact detection** (both read the local filesystem); - liveness relies on pane presence and screen content, the same posture cmux - agents have. -- **No relay MCP injection** (the relay hub is a local Unix socket). -- **No docker mode** and **no zellij wrapper** with a remote host - both are rejected at resolution time. -- **One remote agent per host at a time** is the safe posture: concurrent agents to the same host would collide on the reverse-tunnel port, and the second launch fails loudly. -- Ticket files live on the local machine; remote agents signal progress through `opr8r` callbacks rather than moving ticket files. diff --git a/docs/getting-started/sessions/vscode-terminals.md b/docs/getting-started/sessions/vscode-terminals.md new file mode 100644 index 00000000..38398912 --- /dev/null +++ b/docs/getting-started/sessions/vscode-terminals.md @@ -0,0 +1,14 @@ +--- +title: "VS Code Terminals" +description: "Manage agent terminals through the Operator VS Code extension." +layout: doc +--- + +The `vscode` session controller creates and monitors integrated terminals through the [Operator VS Code extension](/getting-started/ides/vscode/). It is available on macOS, Linux, and Windows. + +```toml +[sessions] +wrapper = "vscode" +``` + +Use this controller for VS Code terminals. cmux, tmux, and Zellij control their own environments and cannot be substituted for this controller inside VS Code. diff --git a/docs/getting-started/sessions/vscode.md b/docs/getting-started/sessions/vscode.md index 20465fd3..d00ad3a2 100644 --- a/docs/getting-started/sessions/vscode.md +++ b/docs/getting-started/sessions/vscode.md @@ -1,112 +1,5 @@ --- title: "VS Code Extension" -description: "VS Code terminal integration for Operator multi-agent orchestration." -layout: doc +layout: redirect +redirect_to: /getting-started/ides/vscode/ --- - -Recommended - -Install from VS Code Marketplace - -Operator Terminals brings the Operator multi-agent orchestration experience directly into VS Code with integrated terminal management and ticket tracking. This is the **recommended session manager** for most users, with full support for **macOS**, **Linux**, and **Windows** (no WSL required). - -## Features - -- **Sidebar Integration**: View Queue, In Progress, and Completed tickets directly in VS Code -- **Styled Terminals**: Color-coded terminals by ticket type - - FEAT (cyan, sparkle icon) - - FIX (red, wrench icon) - - TASK (green, tasklist icon) - - SPIKE (magenta, beaker icon) - - INV (yellow, search icon) -- **Activity Tracking**: Monitors shell execution to detect idle/running states -- **Webhook Server**: Local HTTP server for Operator communication - -## Installation - -### From Marketplace (Recommended) - -1. Open VS Code -2. Go to Extensions (`Ctrl+Shift+X` / `Cmd+Shift+X`) -3. Search for "Operator Terminals" -4. Click Install - -Or install directly: [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals){:target="_blank"} - -### Manual Installation - -1. Download the `.vsix` file from [GitHub releases](https://github.com/untra/operator/releases){:target="_blank"} -2. In VS Code, go to Extensions -3. Click the "..." menu -4. Select "Install from VSIX..." - -## Configuration - -| Setting | Default | Description | -|---------|---------|-------------| -| `operator.webhookPort` | `7009` | Port for webhook server | -| `operator.autoStart` | `true` | Start server on VS Code launch | -| `operator.terminalPrefix` | `op-` | Prefix for managed terminal names | -| `operator.ticketsDir` | `.tickets` | Path to tickets directory | -| `operator.apiUrl` | `http://localhost:7008` | Operator REST API URL | - -## Commands - -Access via Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): - -| Command | Description | -|---------|-------------| -| `Operator: Start Webhook Server` | Start the webhook server | -| `Operator: Stop Webhook Server` | Stop the webhook server | -| `Operator: Show Server Status` | Display server status | -| `Operator: Launch Ticket` | Launch a ticket in a new terminal | -| `Operator: Launch Ticket (with options)` | Launch with agent/mode selection | -| `Operator: Download Operator` | Download the Operator CLI | - -## Sidebar Views - -The extension adds an Operator sidebar with four views: - -1. **Status**: Server status and connection info -2. **In Progress**: Currently running agent sessions -3. **Queue**: Pending tickets waiting to be launched -4. **Completed**: Recently completed tickets (collapsed by default) - -## API Endpoints - -The extension exposes a local HTTP API for Operator communication: - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `GET /health` | GET | Server health check | -| `POST /terminal/create` | POST | Create a new terminal | -| `POST /terminal/:name/send` | POST | Send command to terminal | -| `POST /terminal/:name/show` | POST | Reveal terminal (keep focus) | -| `POST /terminal/:name/focus` | POST | Focus terminal (take focus) | -| `DELETE /terminal/:name/kill` | DELETE | Dispose terminal | -| `GET /terminal/:name/exists` | GET | Check if terminal exists | -| `GET /terminal/:name/activity` | GET | Get idle/running state | -| `GET /terminal/list` | GET | List all managed terminals | - -## Requirements - -- VS Code 1.85.0 or later -- Operator CLI (for full functionality) - -## Troubleshooting - -### Server won't start - -Check if another process is using the configured port: - -```bash -lsof -i :7009 -``` - -Try a different port in settings: `operator.webhookPort`. - -### Terminals not appearing in sidebar - -1. Ensure the webhook server is running (check Status view) -2. Verify `operator.ticketsDir` points to your tickets directory -3. Refresh the views using the refresh button diff --git a/docs/getting-started/sessions/zed.md b/docs/getting-started/sessions/zed.md index 11b1119a..a92635e5 100644 --- a/docs/getting-started/sessions/zed.md +++ b/docs/getting-started/sessions/zed.md @@ -1,94 +1,5 @@ --- title: "Zed" -description: "Zed editor integration for Operator via MCP context server, ACP agent, and slash commands." -layout: doc +layout: redirect +redirect_to: /getting-started/ides/zed/ --- - -Alpha - -
-This integration is in alpha and may have limited functionality or incomplete support. -
- -The [Zed](https://zed.dev) extension for Operator provides three integration layers: an MCP context server for tools and resources, an ACP agent server for delegated prompts, and slash commands for quick operations. - -## Prerequisites - -- [Operator](https://operator.untra.io) installed and on PATH -- Zed editor - -## Installation - -1. Open Zed -2. Open the Extensions panel (**Zed > Extensions** or `Cmd+Shift+X`) -3. Search for **Operator** -4. Click **Install** - -## Setup - -### MCP Context Server (automatic) - -After installing the extension, Zed automatically registers `operator mcp` as a context server. All Operator tools appear in the Agent Panel: - -- `operator_health` / `operator_status` - system health -- `operator_list_tickets` - query queue, in-progress, completed tickets -- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` - ticket lifecycle -- `operator_create_ticket` - create tickets from templates -- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` - registry queries -- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` - queue operations -- `operator_approve_agent` / `operator_reject_agent` - review actions - -If the `operator` binary is not found, the extension shows installation instructions. - -### ACP Agent Server (one-time setup) - -Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel - you can send prompts that flow through ACP to a Claude Code delegator. - -## Slash Commands - -| Command | Description | -|---------|-------------| -| `/op-status` | Show Operator health and status | -| `/op-queue` | List tickets in queue | -| `/op-launch TICKET-ID` | Launch a ticket | -| `/op-active` | List active agents | -| `/op-completed` | List recently completed tickets | -| `/op-ticket TICKET-ID` | Show ticket details | -| `/op-pause` | Pause queue processing | -| `/op-resume` | Resume queue processing | -| `/op-sync` | Sync kanban collections | -| `/op-approve AGENT-ID` | Approve agent review | -| `/op-reject AGENT-ID REASON` | Reject agent review | -| `/op-setup-agent` | Generate ACP agent server config | - -Commands with arguments support tab-completion from live API data. - -## How It Works - -Operator integrates with Zed through three communication channels: - -- **MCP Context Server** - Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration. -- **ACP Agent Server** - Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed. -- **Slash Commands** - Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant. - -## Configuration - -The Operator binary must be on your PATH. The extension also checks common install locations (`/usr/local/bin`, `/opt/homebrew/bin`). The REST API URL for slash commands defaults to `http://localhost:7008`. - -## Troubleshooting - -### MCP tools not appearing - -1. Verify Operator is on PATH: `which operator` -2. Test MCP server: `operator mcp` (should wait for JSON-RPC input) -3. Check Zed's extension logs: **View > Output > Extensions** - -### Slash commands failing - -1. Check that Operator API is running: `operator api` -2. Verify connectivity: `curl http://localhost:7008/api/v1/health` - -### Extension not appearing - -1. Open the Extensions panel and verify Operator is listed as installed -2. Try **Zed > Extensions > Reload** or restart Zed diff --git a/docs/getting-started/transports/index.md b/docs/getting-started/transports/index.md new file mode 100644 index 00000000..cf257e38 --- /dev/null +++ b/docs/getting-started/transports/index.md @@ -0,0 +1,12 @@ +--- +title: "Execution Transports" +description: "How Operator reaches the environment running an agent." +layout: doc +--- + +An execution transport carries commands to the environment running an agent. It is independent of the IDE and session controller. + +| Transport | Availability | Purpose | +|---|---|---| +| [Local](/getting-started/transports/local/) | Included | Execute on Operator's machine or in a local container | +| [SSH](/getting-started/transports/ssh/) | Premium | Connect to a registered remote target | diff --git a/docs/getting-started/transports/local.md b/docs/getting-started/transports/local.md new file mode 100644 index 00000000..cd6d4b9a --- /dev/null +++ b/docs/getting-started/transports/local.md @@ -0,0 +1,7 @@ +--- +title: "Local" +description: "Run agents alongside Operator." +layout: doc +--- + +Local execution runs agents on the machine hosting Operator, including local containers. Multiple local agents are included without a Premium license. Using remote model APIs or viewing the dashboard over the network does not make an agent a remote execution target. diff --git a/docs/getting-started/transports/ssh.md b/docs/getting-started/transports/ssh.md new file mode 100644 index 00000000..e694c833 --- /dev/null +++ b/docs/getting-started/transports/ssh.md @@ -0,0 +1,11 @@ +--- +title: "SSH" +description: "SSH transport for remote execution targets." +layout: doc +--- + +**Premium:** remote execution requires a valid license for the selected configuration. + +SSH supplies the connection to [SSH hosts](/getting-started/remote-targets/ssh/) and Coder workspaces. Operator uses an SSH alias from your SSH configuration, while the target supplies the remote working directory. SSH is an execution transport, not a session manager or an IDE. + +See [remote targets](/getting-started/remote-targets/) for registration and delegation. diff --git a/docs/index.md b/docs/index.md index a1378e06..e6e1c3ac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,8 +38,9 @@ Welcome friend! Operator! is an application ## Similar -These are tools that comparable and aspirational for Operator +These are tools that are comparable and aspirational for Operator +- [12 factor agents](https://hlyr.dev/12fa) - [agnt.gg](https://agnt.gg) - [agtx](https://github.com/fynnfluegge/agtx) - [claude-relay](https://github.com/Innestic/claude-relay) diff --git a/docs/llms.txt b/docs/llms.txt index 40b63ca1..11c4456f 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -19,7 +19,7 @@ Operator runs from the root of your work directory, discovers projects by LLM ma - [Workflows](https://operator.untra.io/workflows/): Shareable collections of Operator workflows. - [Workflow Export Formats](https://operator.untra.io/getting-started/workflows/): Export an Operator workflow into a format another LLM tool or model can run. - [Delegators](https://operator.untra.io/delegators/): Named LLM tool + model pairings for autonomous ticket launching. -- [Remote Hosts (SSH)](https://operator.untra.io/getting-started/sessions/remote-hosts/): Launch agent CLI processes on a remote machine over SSH while the Operator dashboard stays local. +- [Remote Hosts (SSH)](https://operator.untra.io/getting-started/sessions/remote-hosts/): Run launched agents on remote machines over SSH (execution targets). ## Integrations - [LLM Tools](https://operator.untra.io/llm-tools/): Configure Claude Code and other LLM tools for AI-powered agent integration with Operator!. diff --git a/docs/maturity/index.md b/docs/maturity/index.md index 47c2b262..d1732ab5 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -17,84 +17,99 @@ Operator integrates with many providers and tools across several **verticals**. ## Kanban Provider -| Integration | Status | Docs | -|---|---|---| -| Jira | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Jira](https://operator.untra.io/getting-started/kanban/jira/) | -| Linear | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Linear](https://operator.untra.io/getting-started/kanban/linear/) | -| GitHub Projects | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [GitHub Projects](https://operator.untra.io/getting-started/kanban/github/) | -| OpenSpec | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [OpenSpec](https://operator.untra.io/getting-started/kanban/openspec/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Operator | ![GA](https://img.shields.io/badge/GA-1BB91F) | Included | [Operator](https://operator.untra.io/getting-started/kanban/operator/) | +| Jira | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Jira](https://operator.untra.io/getting-started/kanban/jira/) | +| Linear | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Linear](https://operator.untra.io/getting-started/kanban/linear/) | +| GitHub Projects | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [GitHub Projects](https://operator.untra.io/getting-started/kanban/github/) | +| OpenSpec | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [OpenSpec](https://operator.untra.io/getting-started/kanban/openspec/) | ## Model Provider -| Integration | Status | Docs | -|---|---|---| -| Anthropic | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Anthropic](https://operator.untra.io/getting-started/model-servers/anthropic/) | -| OpenAI | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [OpenAI](https://operator.untra.io/getting-started/model-servers/openai/) | -| Google | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Google](https://operator.untra.io/getting-started/model-servers/google/) | -| Ollama | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Ollama](https://operator.untra.io/getting-started/model-servers/ollama/) | -| OpenRouter | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [OpenRouter](https://operator.untra.io/getting-started/model-servers/openrouter/) | -| OpenAI-compatible | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | -| LM Studio | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Anthropic | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Anthropic](https://operator.untra.io/getting-started/model-servers/anthropic/) | +| OpenAI | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [OpenAI](https://operator.untra.io/getting-started/model-servers/openai/) | +| Google | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Google](https://operator.untra.io/getting-started/model-servers/google/) | +| Ollama | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Ollama](https://operator.untra.io/getting-started/model-servers/ollama/) | +| OpenRouter | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [OpenRouter](https://operator.untra.io/getting-started/model-servers/openrouter/) | ## Git Version Control -| Integration | Status | Docs | -|---|---|---| -| GitHub | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [GitHub](https://operator.untra.io/getting-started/git/github/) | -| GitLab | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [GitLab](https://operator.untra.io/getting-started/git/gitlab/) | -| Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | -| Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | -| Forgejo | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | -| Gitea | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Gitea](https://operator.untra.io/getting-started/git/gitea/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| GitHub | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [GitHub](https://operator.untra.io/getting-started/git/github/) | +| GitLab | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [GitLab](https://operator.untra.io/getting-started/git/gitlab/) | +| Gitea | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Gitea](https://operator.untra.io/getting-started/git/gitea/) | -## Session +## Session Management -| Integration | Status | Docs | -|---|---|---| -| tmux | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [tmux](https://operator.untra.io/getting-started/sessions/tmux/) | -| cmux | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [cmux](https://operator.untra.io/getting-started/sessions/cmux/) | -| Zellij | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Zellij](https://operator.untra.io/getting-started/sessions/zellij/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| tmux | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [tmux](https://operator.untra.io/getting-started/sessions/tmux/) | +| cmux | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [cmux](https://operator.untra.io/getting-started/sessions/cmux/) | +| Zellij | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Zellij](https://operator.untra.io/getting-started/sessions/zellij/) | +| VS Code Terminals | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [VS Code Terminals](https://operator.untra.io/getting-started/sessions/vscode-terminals/) | -## Editor +## IDE -| Integration | Status | Docs | -|---|---|---| -| VS Code | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [VS Code](https://operator.untra.io/getting-started/sessions/vscode/) | -| Zed | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Zed](https://operator.untra.io/getting-started/sessions/zed/) | -| Cursor | ![Proto](https://img.shields.io/badge/Proto-6B7280) | [Cursor](https://operator.untra.io/getting-started/sessions/cursor/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| VS Code | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [VS Code](https://operator.untra.io/getting-started/ides/vscode/) | +| Zed | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Zed](https://operator.untra.io/getting-started/ides/zed/) | ## LLM Tool -| Integration | Status | Docs | -|---|---|---| -| Claude | ![GA](https://img.shields.io/badge/GA-1BB91F) | [Claude](https://operator.untra.io/getting-started/agents/claude/) | -| Codex | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Codex](https://operator.untra.io/getting-started/agents/codex/) | -| Gemini CLI | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Gemini CLI](https://operator.untra.io/getting-started/agents/gemini-cli/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Claude | ![GA](https://img.shields.io/badge/GA-1BB91F) | Included | [Claude](https://operator.untra.io/getting-started/agents/claude/) | +| Codex | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Codex](https://operator.untra.io/getting-started/agents/codex/) | +| Gemini CLI | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Gemini CLI](https://operator.untra.io/getting-started/agents/gemini-cli/) | ## Platform -| Integration | Status | Docs | -|---|---|---| -| Docker | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Docker](https://operator.untra.io/getting-started/platforms/docker/) | -| Coder | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Coder](https://operator.untra.io/getting-started/platforms/coder/) | -| Kubernetes | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Kubernetes](https://operator.untra.io/getting-started/platforms/kubernetes/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Docker | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Docker](https://operator.untra.io/getting-started/platforms/docker/) | +| Kubernetes | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Kubernetes](https://operator.untra.io/getting-started/platforms/kubernetes/) | ## Integration -| Integration | Status | Docs | -|---|---|---| -| AGNT | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT](https://operator.untra.io/getting-started/integrations/agnt/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| AGNT | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [AGNT](https://operator.untra.io/getting-started/integrations/agnt/) | ## Workflow Export Format -| Integration | Status | Docs | -|---|---|---| -| Claude Workflow | ![GA](https://img.shields.io/badge/GA-1BB91F) | [Claude Workflow](https://operator.untra.io/getting-started/workflows/claude/) | -| AGNT Workflow | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [AGNT Workflow](https://operator.untra.io/getting-started/workflows/agnt/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Claude Workflow | ![GA](https://img.shields.io/badge/GA-1BB91F) | Included | [Claude Workflow](https://operator.untra.io/getting-started/workflows/claude/) | +| AGNT Workflow | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [AGNT Workflow](https://operator.untra.io/getting-started/workflows/agnt/) | ## Notification Channel -| Integration | Status | Docs | -|---|---|---| -| Operating System | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Operating System](https://operator.untra.io/getting-started/notifications/os/) | -| Webhooks | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | [Webhooks](https://operator.untra.io/getting-started/notifications/webhooks/) | +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Operating System | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Operating System](https://operator.untra.io/getting-started/notifications/os/) | +| Webhooks | ![Beta](https://img.shields.io/badge/Beta-E8A33D) | Included | [Webhooks](https://operator.untra.io/getting-started/notifications/webhooks/) | + +## Execution Transport + +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Local | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Local](https://operator.untra.io/getting-started/transports/local/) | +| SSH | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [SSH](https://operator.untra.io/getting-started/transports/ssh/) | + +## Agent Relay + +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Claude Relay | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Claude Relay](https://operator.untra.io/getting-started/agent-relays/claude-relay/) | + +## Remote Targets + +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Coder | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [Coder](https://operator.untra.io/getting-started/remote-targets/coder/) | +| SSH Hosts | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [SSH Hosts](https://operator.untra.io/getting-started/remote-targets/ssh/) | diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 94228608..8d470c22 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -7,6 +7,13 @@ "title": "Config", "type": "object", "properties": { + "profile": { + "$ref": "#/$defs/ProfileIdentity", + "default": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "legacy" + } + }, "projects": { "description": "List of projects operator can assign work to", "type": "array", @@ -98,7 +105,9 @@ "host": "127.0.0.1", "port": 7008, "cors_origins": [], - "public_url": null + "public_url": null, + "shutdown_drain_seconds": 60, + "shutdown_cleanup_seconds": 15 } }, "git": { @@ -218,6 +227,22 @@ "templates" ], "$defs": { + "ProfileIdentity": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, "AgentsConfig": { "type": "object", "properties": { @@ -529,7 +554,7 @@ "minimum": 0 }, "target": { - "description": "Default named execution target. Per-launch and per-delegator choices\ntake precedence.", + "description": "Default named execution target. Per-launch and per-delegator choices take precedence.", "type": [ "string", "null" @@ -1153,6 +1178,20 @@ "null" ], "default": null + }, + "shutdown_drain_seconds": { + "description": "Maximum time to wait for active agents before shutdown cleanup begins.", + "type": "integer", + "format": "uint32", + "minimum": 0, + "default": 60 + }, + "shutdown_cleanup_seconds": { + "description": "Maximum time reserved for final callbacks and persistent cleanup.", + "type": "integer", + "format": "uint32", + "minimum": 0, + "default": 15 } } }, diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 81043f55..cc639493 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -27,6 +27,7 @@ JSON Schema for the Operator configuration file (`config.toml`). | Property | Type | Required | Description | | --- | --- | --- | --- | +| `profile` | → `ProfileIdentity` | No | | | `projects` | `array` | No | List of projects operator can assign work to | | `agents` | → `AgentsConfig` | Yes | | | `notifications` | → `NotificationsConfig` | Yes | | @@ -54,6 +55,13 @@ JSON Schema for the Operator configuration file (`config.toml`). ## Type Definitions +### ProfileIdentity + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | Yes | | +| `name` | `string` | Yes | | + ### AgentsConfig | Property | Type | Required | Description | @@ -368,6 +376,8 @@ REST API server configuration | `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`). Defaults to request host. | +| `shutdown_drain_seconds` | `integer` | No | Maximum time to wait for active agents before shutdown cleanup begins. | +| `shutdown_cleanup_seconds` | `integer` | No | Maximum time reserved for final callbacks and persistent cleanup. | ### GitConfig diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index d2dec52c..9816b797 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -2289,7 +2289,7 @@ "Status" ], "summary": "GET `/api/v1/integrations`", - "description": "Returns the catalog of advertised integrations across every vertical, each\nwith its docs link and official support status (`proto` | `alpha` | `beta` |\n`ga`).", + "description": "Returns the catalog of advertised integrations across every vertical, each\nwith its docs link, Premium availability, and support status (`alpha` | `beta` |\n`ga`). Prototype entries are not advertised.", "operationId": "integrations_catalog", "responses": { "200": { @@ -3619,6 +3619,149 @@ "x-operator-scope": "read" } }, + "/api/v1/license": { + "get": { + "tags": [ + "License" + ], + "operationId": "license_get", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + }, + "put": { + "tags": [ + "License" + ], + "operationId": "license_install", + "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/InstallLicenseRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseResponse" + } + } + } + }, + "400": { + "description": "License rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + }, + "delete": { + "tags": [ + "License" + ], + "operationId": "license_remove", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, "/api/v1/llm-tools": { "get": { "tags": [ @@ -4376,22 +4519,21 @@ "x-operator-scope": "execute" } }, - "/api/v1/projects": { + "/api/v1/profiles": { "get": { "tags": [ - "Projects" + "Configuration" ], - "summary": "List all configured projects with analysis data", - "operationId": "projects_list", + "operationId": "profiles_list", "responses": { "200": { - "description": "List of projects with analysis data", + "description": "Server configurations", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/ProjectSummary" + "$ref": "#/components/schemas/ProfileSummary" } } } @@ -4413,25 +4555,13 @@ } ], "x-operator-scope": "read" - } - }, - "/api/v1/projects/{name}/assess": { + }, "post": { "tags": [ - "Projects" + "Configuration" ], - "summary": "Create an ASSESS ticket for a project", - "operationId": "projects_assess", + "operationId": "profiles_create", "parameters": [ - { - "name": "name", - "in": "path", - "description": "Project name", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "x-operator-csrf", "in": "header", @@ -4442,13 +4572,23 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileNameRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "ASSESS ticket created", + "description": "Draft configuration", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssessTicketResponse" + "$ref": "#/components/schemas/ProfileSummary" } } } @@ -4459,8 +4599,8 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "description": "Project not found", + "409": { + "description": "Name already exists", "content": { "application/json": { "schema": { @@ -4478,24 +4618,34 @@ "sessionCookie": [] } ], - "x-operator-scope": "write" + "x-operator-scope": "admin" } }, - "/api/v1/queue/kanban": { + "/api/v1/profiles/{profile_id}": { "get": { "tags": [ - "Queue" + "Configuration" + ], + "operationId": "profiles_get", + "parameters": [ + { + "name": "profile_id", + "in": "path", + "description": "Configuration ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } ], - "summary": "Get kanban board data with tickets grouped by status column", - "description": "Returns tickets organized into four columns: queue, running, awaiting, done.\nTickets are sorted by priority within each column, then by timestamp (FIFO).", - "operationId": "queue_kanban", "responses": { "200": { - "description": "Kanban board data", + "description": "Configuration metadata", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KanbanBoardResponse" + "$ref": "#/components/schemas/ProfileSummary" } } } @@ -4516,17 +4666,23 @@ } ], "x-operator-scope": "read" - } - }, - "/api/v1/queue/pause": { - "post": { + }, + "patch": { "tags": [ - "Queue" + "Configuration" ], - "summary": "Pause queue processing", - "description": "Sets the queue paused state to true, stopping automatic ticket launches.", - "operationId": "queue_pause", + "operationId": "profiles_rename", "parameters": [ + { + "name": "profile_id", + "in": "path", + "description": "Configuration ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "x-operator-csrf", "in": "header", @@ -4537,13 +4693,23 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileNameRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Queue paused successfully", + "description": "Renamed configuration", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueueControlResponse" + "$ref": "#/components/schemas/ProfileSummary" } } } @@ -4563,35 +4729,26 @@ "sessionCookie": [] } ], - "x-operator-scope": "write" + "x-operator-scope": "admin" } }, - "/api/v1/queue/resume": { - "post": { + "/api/v1/projects": { + "get": { "tags": [ - "Queue" - ], - "summary": "Resume queue processing", - "description": "Sets the queue paused state to false, resuming automatic ticket launches.", - "operationId": "queue_resume", - "parameters": [ - { - "name": "x-operator-csrf", - "in": "header", - "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", - "required": false, - "schema": { - "type": "string" - } - } + "Projects" ], + "summary": "List all configured projects with analysis data", + "operationId": "projects_list", "responses": { "200": { - "description": "Queue resumed successfully", + "description": "List of projects with analysis data", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueueControlResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectSummary" + } } } } @@ -4611,17 +4768,216 @@ "sessionCookie": [] } ], - "x-operator-scope": "write" + "x-operator-scope": "read" } }, - "/api/v1/queue/status": { - "get": { + "/api/v1/projects/{name}/assess": { + "post": { "tags": [ - "Queue" + "Projects" + ], + "summary": "Create an ASSESS ticket for a project", + "operationId": "projects_assess", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Project name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ASSESS ticket created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssessTicketResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Project not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/queue/kanban": { + "get": { + "tags": [ + "Queue" + ], + "summary": "Get kanban board data with tickets grouped by status column", + "description": "Returns tickets organized into four columns: queue, running, awaiting, done.\nActive columns follow `queue.priority_order`, then the ticket's `priority:`\nfield, then timestamp (FIFO). The done column is newest first.", + "operationId": "queue_kanban", + "responses": { + "200": { + "description": "Kanban board data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanBoardResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/queue/pause": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Pause queue processing", + "description": "Sets the queue paused state to true, stopping automatic ticket launches.", + "operationId": "queue_pause", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Queue paused successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueControlResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/queue/resume": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Resume queue processing", + "description": "Sets the queue paused state to false, resuming automatic ticket launches.", + "operationId": "queue_resume", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } ], - "summary": "Get queue status with ticket counts", - "description": "Returns counts of tickets in each state plus breakdown by type.", - "operationId": "queue_status", + "responses": { + "200": { + "description": "Queue resumed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueControlResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/queue/status": { + "get": { + "tags": [ + "Queue" + ], + "summary": "Get queue status with ticket counts", + "description": "Returns counts of tickets in each state plus breakdown by type.", + "operationId": "queue_status", "responses": { "200": { "description": "Queue status with counts", @@ -4884,6 +5240,16 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "description": "Premium required for remote execution", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -4991,11 +5357,332 @@ "operationId": "skills_list", "responses": { "200": { - "description": "List of discovered skill files", + "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/targets": { + "get": { + "tags": [ + "Targets" + ], + "operationId": "targets_list", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetsResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + }, + "post": { + "tags": [ + "Targets" + ], + "operationId": "targets_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": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "description": "Premium required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/targets/{name}": { + "put": { + "tags": [ + "Targets" + ], + "operationId": "targets_update", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "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": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "description": "Premium required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + }, + "delete": { + "tags": [ + "Targets" + ], + "operationId": "targets_remove", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TargetResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "Target is referenced", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/targets/{name}/probe": { + "post": { + "tags": [ + "Targets" + ], + "operationId": "targets_probe", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillsResponse" + "$ref": "#/components/schemas/TargetProbeResponse" } } } @@ -5003,42 +5690,16 @@ "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", + "402": { + "description": "Premium required", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, "403": { "$ref": "#/components/responses/Forbidden" } @@ -5051,7 +5712,7 @@ "sessionCookie": [] } ], - "x-operator-scope": "read" + "x-operator-scope": "admin" } }, "/api/v1/tickets": { @@ -5242,6 +5903,16 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "description": "Premium required for the selected execution target", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -5293,7 +5964,7 @@ "Tickets" ], "summary": "Update a ticket's status", - "description": "Moves a ticket between queue directories based on the target status.\nValid transitions: queued, running, awaiting, done.", + "description": "Moves a ticket between queue directories based on the target status.\nAccepts queued, running, awaiting and completed; `done` is an accepted\nalias for `completed`, which is what gets written to frontmatter.", "operationId": "tickets_update_status", "parameters": [ { @@ -5445,6 +6116,16 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "description": "Premium required for the next execution target", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -7417,8 +8098,24 @@ "error": { "type": "string" }, + "feature": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PremiumFeature" + } + ] + }, "message": { "type": "string" + }, + "required_tier": { + "type": [ + "string", + "null" + ] } } }, @@ -7863,6 +8560,17 @@ } } }, + "InstallLicenseRequest": { + "type": "object", + "required": [ + "license_key" + ], + "properties": { + "license_key": { + "type": "string" + } + } + }, "IntegrationCatalogEntryDto": { "type": "object", "description": "One advertised integration: its vertical, identity, docs link, and support\nstatus.", @@ -7872,7 +8580,8 @@ "slug", "label", "readme_badge", - "status" + "status", + "premium" ], "properties": { "docs_url": { @@ -7886,10 +8595,23 @@ "type": "string", "description": "Display label for the entry (e.g. \"Jira\", \"Anthropic\")." }, + "premium": { + "type": "boolean" + }, "readme_badge": { "type": "boolean", "description": "Whether this entry carries a curated README badge." }, + "session_wrappers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Implemented session controllers for an IDE; absent for other categories." + }, "slug": { "type": "string", "description": "Stable entry slug within the vertical (e.g. \"jira\", \"anthropic-api\")." @@ -8253,7 +8975,7 @@ }, "KanbanProviderKind": { "type": "string", - "description": "Which kanban provider an onboarding request targets.", + "description": "Which external kanban provider an onboarding request targets.\nDeliberately one variant smaller than [`KanbanProviderType`]\n\n[`KanbanProviderType`]: crate::api::providers::kanban::KanbanProviderType", "enum": [ "jira", "linear", @@ -8349,16 +9071,16 @@ "description": "Ticket ID (e.g., \"FEAT-7598\")" }, "priority": { - "type": "string", - "description": "Priority: P0-critical, P1-high, P2-medium, P3-low" + "$ref": "#/components/schemas/TicketPriority", + "description": "Priority level" }, "project": { "type": "string", "description": "Project name" }, "status": { - "type": "string", - "description": "Current status: queued, running, awaiting, completed" + "$ref": "#/components/schemas/TicketStatus", + "description": "Current status" }, "step": { "type": "string", @@ -8643,6 +9365,105 @@ } } }, + "LicenseResponse": { + "type": "object", + "required": [ + "status", + "profile_id", + "premium" + ], + "properties": { + "premium": { + "type": "boolean" + }, + "profile_id": { + "type": "string", + "format": "uuid" + }, + "purchase_url": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/LicenseStatus" + }, + "terms": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LicenseTerms" + } + ] + } + } + }, + "LicenseStatus": { + "type": "string", + "enum": [ + "missing", + "valid", + "expired", + "not_yet_valid", + "invalid" + ] + }, + "LicenseTerms": { + "type": "object", + "required": [ + "version", + "iss", + "aud", + "sub", + "jti", + "profile_id", + "tier", + "iat", + "nbf", + "exp" + ], + "properties": { + "aud": { + "type": "string" + }, + "exp": { + "type": "integer", + "format": "int64" + }, + "iat": { + "type": "integer", + "format": "int64" + }, + "iss": { + "type": "string" + }, + "jti": { + "type": "string" + }, + "nbf": { + "type": "integer", + "format": "int64" + }, + "profile_id": { + "type": "string", + "format": "uuid" + }, + "sub": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "version": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "LinearCredentials": { "type": "object", "description": "Ephemeral Linear credentials supplied by a client during onboarding.", @@ -9443,6 +10264,12 @@ }, "additionalProperties": false }, + "PremiumFeature": { + "type": "string", + "enum": [ + "remote_targets" + ] + }, "PrincipalKind": { "type": "string", "description": "What kind of credential authenticated a request.", @@ -9453,6 +10280,41 @@ "agent_callback" ] }, + "ProfileNameRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "ProfileSummary": { + "type": "object", + "required": [ + "id", + "name", + "initialized", + "is_default" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "initialized": { + "type": "boolean" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, "ProjectSummary": { "type": "object", "description": "Summary of a project with analysis data", @@ -9578,30 +10440,13 @@ }, "QueueByType": { "type": "object", - "description": "Ticket counts by type for queue status", - "required": [ - "inv", - "fix", - "feat", - "spike" - ], - "properties": { - "feat": { - "type": "integer", - "minimum": 0 - }, - "fix": { - "type": "integer", - "minimum": 0 - }, - "inv": { - "type": "integer", - "minimum": 0 - }, - "spike": { - "type": "integer", - "minimum": 0 - } + "description": "Ticket counts keyed by issuetype.\n\nIssuetypes are an open set defined by collections, so this is a map rather\nthan fixed fields. `BTreeMap` keeps the JSON key order stable.", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "propertyNames": { + "type": "string" } }, "QueueConfiguration": { @@ -10383,6 +11228,8 @@ "description": "A step in the setup wizard.", "enum": [ "welcome", + "license", + "execution-mode", "kanban-info", "model-server", "git-provider", @@ -10769,6 +11616,66 @@ } } }, + "TargetProbeResponse": { + "type": "object", + "required": [ + "reachable", + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "reachable": { + "type": "boolean" + } + } + }, + "TargetResponse": { + "allOf": [ + { + "type": "object" + }, + { + "type": "object", + "required": [ + "premium", + "entitled", + "user_declared" + ], + "properties": { + "entitled": { + "type": "boolean" + }, + "premium": { + "type": "boolean" + }, + "user_declared": { + "type": "boolean" + } + } + } + ] + }, + "TargetsResponse": { + "type": "object", + "required": [ + "targets", + "total" + ], + "properties": { + "targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TargetResponse" + } + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, "TicketDetailResponse": { "type": "object", "description": "Full ticket details including content and metadata", @@ -10896,6 +11803,26 @@ } } }, + "TicketPriority": { + "type": "string", + "description": "Ticket urgency, as constrained by `src/schemas/ticket_metadata.schema.json`.\n\nVariants are declared most-urgent first, so the derived `Ord` is the sort order.", + "enum": [ + "P0-critical", + "P1-high", + "P2-medium", + "P3-low" + ] + }, + "TicketStatus": { + "type": "string", + "description": "Workflow status, as constrained by `src/schemas/ticket_metadata.schema.json`.", + "enum": [ + "queued", + "running", + "awaiting", + "completed" + ] + }, "TokenRequest": { "oneOf": [ { @@ -11357,11 +12284,11 @@ "description": "Human-readable message" }, "previous_status": { - "type": "string", + "$ref": "#/components/schemas/TicketStatus", "description": "Previous status before the update" }, "status": { - "type": "string", + "$ref": "#/components/schemas/TicketStatus", "description": "New status after the update" } } diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 4cb4f75a..a304ae79 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -233,7 +233,7 @@ "default": null }, "launch_mode": { - "description": "Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]`\n(derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match)", + "description": "Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]`", "type": [ "string", "null" @@ -287,12 +287,23 @@ "default": null }, "target_name": { - "description": "Name of the resolved execution target this agent launched on", + "description": "Name of the resolved execution target this agent launched on.", "type": [ "string", "null" ], "default": null + }, + "shutdown_recovery": { + "description": "Shutdown recovery strategy.", + "anyOf": [ + { + "$ref": "#/$defs/ShutdownRecovery" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -452,6 +463,13 @@ "opr8r" ] }, + "ShutdownRecovery": { + "type": "string", + "enum": [ + "interrupted_local", + "remote_awaiting_reconciliation" + ] + }, "CompletedTicket": { "type": "object", "properties": { diff --git a/docs/schemas/state.md b/docs/schemas/state.md index 3215eebd..2733fa21 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -64,13 +64,14 @@ This file tracks the current state of agents, completed tickets, and system stat | `completed_steps` | `array` | No | Completed steps for this ticket | | `llm_tool` | `string` \| `null` | No | LLM tool used (e.g., "claude", "gemini", "codex") | | `llm_model` | `string` \| `null` | No | LLM model alias (e.g., "opus", "sonnet", "gpt-4o") | -| `launch_mode` | `string` \| `null` | No | Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) | +| `launch_mode` | `string` \| `null` | No | Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` | | `review_state` | `string` \| `null` | No | Review state for `awaiting_input` agents Values: "`pending_plan`", "`pending_visual`", "`pending_proof`", "`pending_pr_creation`", "`pending_pr_merge`" | | `dev_server_pid` | `integer` \| `null` | No | Server process ID for visual review cleanup (if applicable) | | `worktree_path` | `string` \| `null` | No | Path to the git worktree for this ticket (per-ticket isolation) | | `remote_host` | `string` \| `null` | No | Name of the `RemoteHost` this agent's CLI runs on over SSH (None = local) | | `step_launch_context` | object | No | Launch context fixed at launch time; `complete_step` reads it back to build subsequent step commands with the same delegator/tool/model. | -| `target_name` | `string` \| `null` | No | Name of the resolved execution target this agent launched on | +| `target_name` | `string` \| `null` | No | Name of the resolved execution target this agent launched on. | +| `shutdown_recovery` | object | No | Shutdown recovery strategy. | ### GitExecutionConfig @@ -127,6 +128,8 @@ The persisted context is the baseline for a ticket's whole chain; per-step | `operator_relay` | `boolean` \| `null` | No | Relay MCP injection override from the delegator launch config | | `extra_flags` | `array` | No | Extra CLI flags from the delegator launch config | +### ShutdownRecovery + ### CompletedTicket | Property | Type | Required | Description | diff --git a/docs/startup/index.md b/docs/startup/index.md index 6e27bb3b..b95d992d 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -12,33 +12,37 @@ 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 | 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 | +| 1 | Welcome | Name the configuration and review detected tools and projects | +| 2 | Operator Premium | Install or review the Premium licence for this configuration | +| 3 | Execution Mode | Run agents on this machine, or on remote targets | +| 4 | Kanban Info | Connect an external kanban provider, or skip and connect one later | +| 5 | Model Server | Declare which model providers this workspace uses | +| 6 | Git Provider | Connect a git provider so agents can branch, push and open PRs | +| 7 | Collection Source | Choose which issue type collection to use | +| 8 | Hosted Collections | Browse and select hosted collections (only shown if Browse chosen) | +| 9 | Task Field Config | Configure optional fields for TASK issue type | +| 10 | Session Wrapper Choice | Select which session wrapper to use for launching coding agents | +| 11 | Execution Target | Choose whether agents run locally or in Coder workspaces | +| 12 | Worktree Preference | Choose whether to use git worktrees for ticket isolation | +| 13 | Web UI Password | Optionally set the admin password for the web dashboard | +| 14 | Tmux Onboarding | Help and documentation about tmux session management (shown if tmux selected) | +| 15 | VS Code Setup | VS Code extension setup and verification (shown if VS Code selected) | +| 16 | Cmux Setup | cmux session wrapper setup (shown if cmux selected) | +| 17 | Zellij Setup | Zellij session wrapper setup (shown if Zellij selected) | +| 18 | Acceptance Criteria | Review and configure acceptance criteria for ticket completion | +| 19 | Startup Tickets | Optionally create tickets to bootstrap your projects | +| 20 | Confirm | Review settings and confirm initialization | ## Step Details ### 1. Welcome -*Splash screen showing detected LLM tools and discovered projects* +*Name the configuration and review detected tools and projects* -The welcome screen displays: +Choose a configuration name containing only lowercase letters, digits, hyphens, and underscores. The name identifies this configuration in the web UI, TUI, CLI, and MCP clients; its UUID remains stable when renamed. + +The welcome screen also displays: - Detected LLM tools (Claude, Gemini, Codex, etc.) with version and model count - Discovered projects organized by which LLM tool marker files they contain - The path where the tickets directory will be created @@ -47,22 +51,50 @@ This gives you an overview of your development environment before proceeding. **Navigation**: Enter to continue, Esc to cancel -### 2. Kanban Info +### 2. Operator Premium + +*Install or review the Premium licence for this configuration* + +Multiple local agents and local containers are free. Premium adds remote execution: SSH hosts and Coder workspaces. + +A licence is verified offline - Operator never contacts a licensing service. It is bound to this configuration's identifier, shown on this screen, and survives renaming the configuration. + +Paste a licence key to install one, or continue without: every local workflow stays available. + +**Navigation**: Enter to install, Tab to skip, Esc to go back + +### 3. Execution Mode + +*Run agents on this machine, or on remote targets* + +Both modes support multiple agents running at once. + +- **This machine**: agents and local containers run beside Operator. +- **Remote targets**: agents run on SSH hosts or Coder workspaces and report back to this Operator server. Requires Premium. + +Choosing remote leads to target registration; choosing this machine skips it. + +**Navigation**: ↑/↓ to select, Enter to continue, Esc to go back + +### 4. Kanban Info + +*Connect an external kanban provider, or skip and connect one later* -*Connect a kanban provider, or skip and connect one later* +**Operator** is the board. Tickets worked by agents move through the columns. +It is always on and needs no setup or credentials. -Operator can sync with external kanban providers to pull in issues as tickets. -Supported providers: Jira, Linear, GitHub Projects. +External providers are optional *sync sources*: their issues are pulled in as tickets on the Operator board, and transitions are pushed back. +Supported: Jira, Linear, GitHub Projects, OpenSpec. 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. +**Skip for now** moves on with just the Operator board; press `K` from the dashboard at any time. **Navigation**: ↑/↓ to select, Enter to confirm, Esc to go back -### 3. Model Server +### 5. Model Server *Declare which model providers this workspace uses* @@ -78,7 +110,7 @@ This step is optional - Operator ships working defaults for the first-party vend **Navigation**: ↑/↓ or j/k to navigate, Space to declare, Enter to continue, Esc to go back -### 4. Git Provider +### 6. Git Provider *Connect a git provider so agents can branch, push and open PRs* @@ -92,7 +124,7 @@ This step is optional; Operator works against a local repository with no provide **Navigation**: ↑/↓ or j/k to navigate, Enter to connect, Esc to go back -### 5. Collection Source +### 7. Collection Source *Choose which issue type collection to use* @@ -104,7 +136,7 @@ Select a preset collection of issue types: **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 6. Hosted Collections +### 8. Hosted Collections *Browse and select hosted collections (only shown if Browse chosen)* @@ -116,7 +148,7 @@ 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 +### 9. Task Field Config *Configure optional fields for TASK issue type* @@ -129,7 +161,7 @@ These choices propagate to other issue types. The 'summary' field is always requ **Navigation**: ↑/↓ or j/k to navigate, Space to toggle, Enter to continue, Esc to go back -### 8. Session Wrapper Choice +### 10. Session Wrapper Choice *Select which session wrapper to use for launching coding agents* @@ -143,7 +175,7 @@ Your choice determines which setup steps follow. **Navigation**: ↑/↓ or j/k to navigate, Enter to select, Esc to go back -### 9. Execution Target +### 11. Execution Target *Choose whether agents run locally or in Coder workspaces* @@ -155,7 +187,7 @@ Coder targets disable git worktrees and relay injection, and cannot be combined **Navigation**: ↑/↓ to select, Tab to switch fields, Enter to continue, Esc to go back -### 10. Worktree Preference +### 12. Worktree Preference *Choose whether to use git worktrees for ticket isolation* @@ -167,7 +199,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 -### 11. Web UI Password +### 13. Web UI Password *Optionally set the admin password for the web dashboard* @@ -181,7 +213,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 -### 12. Tmux Onboarding +### 14. Tmux Onboarding *Help and documentation about tmux session management (shown if tmux selected)* @@ -195,7 +227,7 @@ Operator session names start with 'op-' for easy identification. **Navigation**: Enter to continue, Esc to go back -### 13. VS Code Setup +### 15. VS Code Setup *VS Code extension setup and verification (shown if VS Code selected)* @@ -206,7 +238,7 @@ Install the extension from the VS Code marketplace if prompted. **Navigation**: Enter to continue, Esc to go back -### 14. Cmux Setup +### 16. Cmux Setup *cmux session wrapper setup (shown if cmux selected)* @@ -216,7 +248,7 @@ This step verifies the cmux app's CLI binary exists at the configured binary_pat **Navigation**: Enter to continue, Esc to go back -### 15. Zellij Setup +### 17. Zellij Setup *Zellij session wrapper setup (shown if Zellij selected)* @@ -226,7 +258,7 @@ This step verifies Zellij is installed and configures the layout Operator will u **Navigation**: Enter to continue, Esc to go back -### 16. Acceptance Criteria +### 18. Acceptance Criteria *Review and configure acceptance criteria for ticket completion* @@ -237,7 +269,7 @@ The default criteria cover formatting, tests, and lint checks. You can customize **Navigation**: Enter to continue, Esc to go back -### 17. Startup Tickets +### 19. Startup Tickets *Optionally create tickets to bootstrap your projects* @@ -250,7 +282,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 -### 18. Confirm +### 20. Confirm *Review settings and confirm initialization* diff --git a/icons/operator.svg b/icons/operator.svg new file mode 100644 index 00000000..5970712a --- /dev/null +++ b/icons/operator.svg @@ -0,0 +1 @@ +Operator \ No newline at end of file diff --git a/manifest.schema.json b/manifest.schema.json new file mode 100644 index 00000000..0e26fd7a --- /dev/null +++ b/manifest.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Coder Manifest", + "description": "Schema for https://coder.com/download", + "type": "object", + "additionalProperties": false, + "required": ["name", "latest", "dev", "supported"], + "properties": { + "name": { + "type": "string", + "const": "Coder" + }, + "latest": { "$ref": "#/$defs/channel" }, + "dev": { "$ref": "#/$defs/channel" }, + "supported": { + "type": "array", + "items": { "$ref": "#/$defs/channel" } + } + }, + "$defs": { + "channel": { + "type": "object", + "additionalProperties": false, + "required": ["assets", "timestamp", "version", "title", "description"], + "properties": { + "assets": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/releaseAsset" } + }, + "timestamp": { + "type": "integer", + "minimum": 0, + "description": "Unix timestamp" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "title": { "type": "string" }, + "description": { "type": "string" } + } + }, + "releaseAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "asset", + "version", + "name", + "url", + "sha256", + "fileSizeMb", + "architecture" + ], + "properties": { + "asset": { "$ref": "#/$defs/assetMeta" }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "name": { "type": "string" }, + "url": { + "type": "string", + "format": "uri" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "fileSizeMb": { + "type": "number", + "minimum": 0 + }, + "architecture": { + "type": "string", + "enum": ["arm64", "x64", ""] + } + } + }, + "assetMeta": { + "type": "object", + "additionalProperties": false, + "required": [ + "group", + "name", + "extension", + "description", + "architecture", + "osRequired", + "published" + ], + "properties": { + "group": { + "type": "string", + "enum": [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "linux-aarch64", + "windows-x64", + "windows-arm64", + "sdk", + "tools" + ] + }, + "name": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "description": { "type": "string" }, + "architecture": { + "type": "string", + "enum": ["arm64", "x64", ""] + }, + "osRequired": { + "type": "string", + "enum": ["macos", "linux", "windows", ""] + }, + "published": { "type": "boolean" } + } + } + } +} \ No newline at end of file diff --git a/opr8r/src/api.rs b/opr8r/src/api.rs index d63dc9f0..a81ab6fc 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -13,6 +13,7 @@ const API_SESSION_FILE: &str = ".tickets/operator/api-session.json"; /// The operator REST API is authenticated, and the step-completion endpoint /// launches processes. This token is scoped to exactly one ticket and step. const API_TOKEN_ENV: &str = "OPERATOR_API_TOKEN"; +const PROFILE_ID_ENV: &str = "OPERATOR_PROFILE_ID"; /// Retry configuration const MAX_RETRIES: u32 = 3; @@ -22,6 +23,8 @@ const INITIAL_BACKOFF_MS: u64 = 1000; #[derive(Debug, Deserialize)] pub struct ApiSession { pub port: u16, + #[serde(default)] + pub profile_id: Option, #[allow(dead_code)] pub pid: u32, #[allow(dead_code)] @@ -175,6 +178,7 @@ pub struct ApiClient { /// Callback credential injected by operator at launch. Absent only when /// running against a server that pre-dates authentication. token: Option, + profile_id: Option, } #[derive(Debug)] @@ -217,13 +221,9 @@ fn resolve_base_url( } impl ApiClient { - /// Create a new API client with the given base URL - pub fn new(base_url: &str) -> Self { - Self::with_token(base_url, std::env::var(API_TOKEN_ENV).ok()) - } - - /// Create a client with an explicit callback credential. - pub fn with_token(base_url: &str, token: Option) -> Self { + /// Create a client with an explicit callback credential and, when the + /// configuration is known, the id its callbacks must be routed to. + pub fn with_profile(base_url: &str, token: Option, profile_id: Option) -> Self { let client = Client::builder() .timeout(Duration::from_secs(30)) .build() @@ -233,22 +233,35 @@ impl ApiClient { client, base_url: base_url.trim_end_matches('/').to_string(), token: token.filter(|t| !t.trim().is_empty()), + profile_id: profile_id.filter(|id| !id.trim().is_empty()), } } /// Discover the API endpoint: explicit `--api-url`, then the /// `OPERATOR_API_URL` env var (set by remote launches so callbacks route /// through the SSH reverse tunnel), then api-session.json, then default. - pub async fn discover(api_url: Option<&str>) -> Result { + pub async fn discover( + api_url: Option<&str>, + profile_id: Option<&str>, + ) -> Result { let env_url = std::env::var("OPERATOR_API_URL").ok(); + let env_profile = std::env::var(PROFILE_ID_ENV).ok(); // Try to read api-session.json (sync is fine for a tiny JSON file) - let session_port = std::fs::read_to_string(API_SESSION_FILE) + let session = std::fs::read_to_string(API_SESSION_FILE) .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .map(|session| session.port); - - Ok(Self::new(&resolve_base_url(api_url, env_url, session_port))) + .and_then(|content| serde_json::from_str::(&content).ok()); + let session_port = session.as_ref().map(|session| session.port); + let profile_id = profile_id + .map(str::to_owned) + .or(env_profile) + .or_else(|| session.and_then(|session| session.profile_id)); + + Ok(Self::with_profile( + &resolve_base_url(api_url, env_url, session_port), + std::env::var(API_TOKEN_ENV).ok(), + profile_id, + )) } /// Report step completion to the API with retry logic @@ -258,10 +271,13 @@ impl ApiClient { step: &str, request: StepCompleteRequest, ) -> Result { - let url = format!( - "{}/api/v1/tickets/{}/steps/{}/complete", - self.base_url, ticket_id, step - ); + let route = match &self.profile_id { + Some(profile_id) => { + format!("/api/v1/profiles/{profile_id}/tickets/{ticket_id}/steps/{step}/complete") + } + None => format!("/api/v1/tickets/{ticket_id}/steps/{step}/complete"), + }; + let url = format!("{}{route}", self.base_url); self.post_with_retry(&url, &request).await } @@ -298,7 +314,11 @@ impl ApiClient { .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); - last_error = Some(ApiError::ResponseError(status.as_u16(), error_text)); + let error = ApiError::ResponseError(status.as_u16(), error_text); + if matches!(status.as_u16(), 400 | 401 | 402 | 403 | 404 | 409) { + return Err(error); + } + last_error = Some(error); } } Err(e) => { @@ -386,10 +406,10 @@ mod tests { #[test] fn test_api_client_new() { - let client = ApiClient::with_token("http://localhost:7008/", None); + let client = ApiClient::with_profile("http://localhost:7008/", None, None); assert_eq!(client.base_url, "http://localhost:7008"); - let client = ApiClient::with_token("http://localhost:7008", None); + let client = ApiClient::with_profile("http://localhost:7008", None, None); assert_eq!(client.base_url, "http://localhost:7008"); } @@ -398,7 +418,8 @@ mod tests { // An unset env var arrives as an empty string through some shells; // sending `Authorization: Bearer ` would be worse than sending nothing. for blank in ["", " ", "\n"] { - let client = ApiClient::with_token("http://localhost:7008", Some(blank.to_string())); + let client = + ApiClient::with_profile("http://localhost:7008", Some(blank.to_string()), None); assert!( client.token.is_none(), "blank token {blank:?} should be dropped" @@ -408,7 +429,8 @@ mod tests { #[test] fn test_token_is_retained_when_supplied() { - let client = ApiClient::with_token("http://localhost:7008", Some("cb-token".to_string())); + let client = + ApiClient::with_profile("http://localhost:7008", Some("cb-token".to_string()), None); assert_eq!(client.token.as_deref(), Some("cb-token")); } diff --git a/opr8r/src/cli.rs b/opr8r/src/cli.rs index 35c7f783..23ad2345 100644 --- a/opr8r/src/cli.rs +++ b/opr8r/src/cli.rs @@ -27,6 +27,10 @@ pub struct Args { #[arg(long)] pub api_url: Option, + /// Operator configuration UUID. Defaults to OPERATOR_PROFILE_ID. + #[arg(long)] + pub profile_id: Option, + /// Session ID for LLM session tracking (passed to claude --session-id) #[arg(long)] pub session_id: Option, @@ -220,6 +224,7 @@ mod tests { ticket_id: Some("FEAT-1".to_string()), step: Some("plan".to_string()), api_url: None, + profile_id: None, session_id: None, no_auto_proceed: false, verbose: false, diff --git a/opr8r/src/main.rs b/opr8r/src/main.rs index b9aa8de8..10fa1d76 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -144,13 +144,14 @@ async fn main() -> ExitCode { } // Discover and connect to API - let api_client = match ApiClient::discover(args.api_url.as_deref()).await { - Ok(client) => client, - Err(e) => { - print_api_unreachable_error(&e.to_string()); - return ExitCode::from(EXIT_API_UNREACHABLE); - } - }; + let api_client = + match ApiClient::discover(args.api_url.as_deref(), args.profile_id.as_deref()).await { + Ok(client) => client, + Err(e) => { + print_api_unreachable_error(&e.to_string()); + return ExitCode::from(EXIT_API_UNREACHABLE); + } + }; // Report completion to API with operator output let request = build_step_complete_request( diff --git a/shared/types.ts b/shared/types.ts index 66a0f6d2..8e5dfafa 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -264,7 +264,7 @@ agent_session_id: string | null, */ summary: string | null, created_at: string, updated_at: string, }; -export type Config = { +export type Config = { profile: ProfileIdentity, /** * List of projects operator can assign work to */ @@ -367,10 +367,9 @@ export type PanelNamesConfig = { status: string, queue: string, in_progress: str 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. + * Default named execution target. Per-launch and per-delegator choices take precedence. */ -target: string | null, +target: string | null, /** * Docker execution configuration */ @@ -434,7 +433,15 @@ cors_origins: Array, /** * Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. */ -public_url: string | null, }; +public_url: string | null, +/** + * Maximum time to wait for active agents before shutdown cleanup begins. + */ +shutdown_drain_seconds: number, +/** + * Maximum time reserved for final callbacks and persistent cleanup. + */ +shutdown_cleanup_seconds: number, }; export type LlmToolsConfig = { /** @@ -819,6 +826,427 @@ rate_limit_check_interval_secs: bigint, */ rate_limit_warning_threshold: number, }; +export type AcpConfig = { +/** + * Whether the dashboard advertises the `operator acp` stdio entrypoint + * (and editor-config snippet actions). Set to false on machines that + * shouldn't be used as ACP agents. + */ +stdio_advertised: boolean, +/** + * Name of the delegator (from `[[delegators]]`) to use for ACP prompts. + * If unset or not found, falls back to the operator's default delegator + * resolution. + */ +default_delegator: string | null, +/** + * Maximum number of concurrent ACP sessions. New `session/new` requests + * beyond this limit are rejected with a JSON-RPC error. + */ +max_concurrent_sessions: number, }; + +export type McpConfig = { +/** + * Whether to mount MCP HTTP/SSE endpoints on the REST API server. + * Toggling requires an API restart (no hot-swap of the axum router). + */ +http_enabled: boolean, +/** + * Whether the descriptor endpoint advertises the `operator mcp` stdio + * command. Set to false on multi-tenant/remote deployments where clients + * shouldn't spawn local subprocesses. + */ +stdio_advertised: boolean, +/** + * Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + * create) over MCP. Defaults to `false` because any MCP client can call them. + */ +expose_ticket_write_tools: boolean, +/** + * External MCP servers to inject into spawned agent sessions. + * Each entry produces a separate `--mcp-config` file alongside the + * relay config when launching Claude Code agents. + */ +external_servers: Array, }; + +export type RelayConfig = { +/** + * When true, automatically inject the relay MCP server for all delegators. + * When false (default), relay injection is opt-in per delegator. + */ +auto_inject_mcp: boolean, }; + +export type VersionCheckConfig = { +/** + * Enable automatic version checking on startup + */ +enabled: boolean, +/** + * URL to fetch latest version from (optional, can be removed) + */ +url: string | null, +/** + * Timeout in seconds for version check HTTP request + */ +timeout_secs: bigint, }; + +export type SessionsConfig = { +/** + * Which session wrapper to use + */ +wrapper: SessionWrapperType, +/** + * Tmux-specific configuration + */ +tmux: SessionsTmuxConfig, +/** + * VS Code-specific configuration + */ +vscode: SessionsVSCodeConfig, +/** + * cmux-specific configuration + */ +cmux: SessionsCmuxConfig, +/** + * Zellij-specific configuration + */ +zellij: SessionsZellijConfig, }; + +export type SessionWrapperType = "tmux" | "vscode" | "cmux" | "zellij"; + +export type SessionsTmuxConfig = { +/** + * Whether custom tmux config has been generated + */ +config_generated: boolean, +/** + * Socket name for session isolation + */ +socket_name: string, }; + +export type SessionsVSCodeConfig = { +/** + * Port for extension webhook server + */ +webhook_port: number, +/** + * Connection timeout in milliseconds + */ +connect_timeout_ms: bigint, }; + +export type SessionsCmuxConfig = { +/** + * Path to the cmux binary + */ +binary_path: string, +/** + * Require running inside cmux (`CMUX_WORKSPACE_ID` env var present) + */ +require_in_cmux: boolean, +/** + * Where to place new agent sessions: "auto", "workspace", or "window" + */ +placement: CmuxPlacementPolicy, }; + +export type CmuxPlacementPolicy = "auto" | "workspace" | "window"; + +export type SessionsZellijConfig = { +/** + * Require running inside Zellij (ZELLIJ env var present) + */ +require_in_zellij: boolean, }; + +export type ExternalMcpServer = { +/** + * Server name used as the key in the `mcpServers` JSON object + * (e.g., "kanbots"). Must be unique across all external servers. + */ +name: string, +/** + * Command to execute. Supports `${VAR}` interpolation. + */ +command: string, +/** + * Command arguments. Each element supports `${VAR}` interpolation. + */ +args: Array, +/** + * Environment variables passed to the MCP server process. + * Values support `${VAR}` interpolation. + */ +env: { [key in string]: string }, +/** + * Whether this server is enabled. Allows disabling without removing config. + */ +enabled: boolean, +/** + * Path to a JSON sidecar discovery file. Relative paths resolve from + * the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. + * When the file exists, its `mcpServer` spec is used verbatim (overriding + * `command`/`args`/`env`). When absent and `command` is empty, the server + * is silently skipped. + */ +discover_from: string | null, }; + +export type GitConfig = { +/** + * Default commit identity for delegated work. + */ +identity?: GitIdentityConfig | null, gitea: GiteaConfig, forgejo: ForgejoConfig, +/** + * Active provider (auto-detected from remote URL if not specified) + */ +provider: GitProviderConfig | null, +/** + * GitHub-specific configuration + */ +github: GitHubConfig, +/** + * GitLab-specific configuration + */ +gitlab: GitLabConfig, +/** + * Branch naming format (e.g., "{type}/{ticket_id}-{slug}") + */ +branch_format: string, +/** + * Whether to use git worktrees for per-ticket isolation (default: false) + * When false, tickets work directly in the project directory with branches + */ +use_worktrees: boolean, }; + +export type GitHubConfig = { +/** + * Whether GitHub integration is enabled + */ +enabled: boolean, +/** + * Environment variable containing the GitHub token (default: `GITHUB_TOKEN`) + */ +token_env: string, }; + +export type GitLabConfig = { +/** + * Whether GitLab integration is enabled + */ +enabled: boolean, +/** + * Environment variable containing the GitLab token (default: `GITLAB_TOKEN`) + */ +token_env: string, +/** + * GitLab host (default: gitlab.com, can be self-hosted) + */ +host: string | null, }; + +export type GiteaConfig = { enabled: boolean, token_env: string, +/** + * HTTPS host or base URL; defaults to gitea.com. + */ +host: string | null, wip_prefix: string, }; + +export type ForgejoConfig = { enabled: boolean, token_env: string, +/** + * HTTPS host or base URL; defaults to codeberg.org. + */ +host: string | null, wip_prefix: string, }; + +export type KanbanConfig = { +/** + * Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") + */ +jira: { [key in string]: JiraConfig }, +/** + * Linear instances keyed by workspace slug + */ +linear: { [key in string]: LinearConfig }, +/** + * GitHub Projects v2 instances keyed by owner login (user or org) + * + * NOTE: This is the *kanban* GitHub integration (Projects v2), distinct + * from `GitHubConfig` which is the *git provider* used for PRs and + * branches. The two use different env vars and different scopes - see + * `docs/getting-started/kanban/github.md` for the full disambiguation. + */ +github: { [key in string]: GithubProjectsConfig }, +/** + * `OpenSpec` roots keyed by a free-form instance name (e.g., a repo alias). + * Experimental, pull-only: each active change under `/changes/` + * acts as a kanban "project" whose issues are the tasks.md task groups. + */ +openspec: { [key in string]: OpenspecConfig }, }; + +export type ModelServer = { +/** + * Unique name (e.g., "ollama-local", "vllm-gpu1") + */ +name: string, +/** + * Kind: "ollama", "openrouter", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + */ +kind: string, +/** + * Base URL of the inference endpoint (e.g., `http://localhost:11434`). + * `None` for implicit vendor servers means use the SDK default. + */ +base_url: string | null, +/** + * Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) + */ +api_key_env: string | null, +/** + * Additional environment variables set when spawning agents that use this server + */ +extra_env: { [key in string]: string }, +/** + * Optional display name for UI + */ +display_name: string | null, }; + +export type RemoteHost = { +/** + * Unique name referenced by `DelegatorLaunchConfig.host` (e.g., "gpu-vm") + */ +name: string, +/** + * SSH destination, resolved via the user's `~/.ssh/config` + */ +ssh_alias: string, +/** + * Absolute path to the project root on the remote host + */ +workdir: string, +/** + * Optional display name for UI + */ +display_name: string | null, +/** + * SSH config fragment passed with `-F` (used by provisioned coder aliases) + */ +ssh_config_path?: string | null, }; + +export type OsNotificationConfig = { +/** + * Whether OS notifications are enabled + */ +enabled: boolean, +/** + * Play sound with notifications + */ +sound: boolean, +/** + * Events to send (empty = all events) + * Possible values: agent.started, agent.completed, agent.failed, + * `agent.awaiting_input`, `agent.session_lost`, pr.created, pr.merged, + * pr.closed, `pr.ready_to_merge`, `pr.changes_requested`, + * ticket.returned, investigation.created + */ +events: Array, }; + +export type WebhookConfig = { +/** + * Optional name for this webhook (for logging) + */ +name: string | null, +/** + * Whether this webhook is enabled + */ +enabled: boolean, +/** + * Webhook URL + */ +url: string, +/** + * Authentication type: "bearer" or "basic" + */ +auth_type: string | null, +/** + * Environment variable containing the bearer token + */ +token_env: string | null, +/** + * Username for basic auth + */ +username: string | null, +/** + * Environment variable containing the password for basic auth + */ +password_env: string | null, +/** + * Events to send (empty = all events) + */ +events: Array | null, }; + +export type TargetDef = { +/** + * Unique name, referenced by `DelegatorLaunchConfig.target`. + * `local` and `docker` are reserved for synthesized targets. + */ +name: string, +/** + * Human-readable name for UI surfaces + */ +display_name?: string | null, } & ({ "kind": "local" } | { "kind": "docker" } & DockerConfig | { "kind": "coder" } & CoderConfig | { "kind": "ssh" } & SshTarget); + +export type TargetKind = { "kind": "local" } | { "kind": "docker" } & DockerConfig | { "kind": "coder" } & CoderConfig | { "kind": "ssh" } & SshTarget; + +export type SshTarget = { +/** + * Host alias resolved via the user's `~/.ssh/config` (or `ssh_config_path`) + */ +ssh_alias: string, +/** + * Absolute project root on the remote machine + */ +workdir: string, +/** + * SSH config fragment passed with `-F` (used by provisioned coder aliases) + */ +ssh_config_path?: string | null, }; + +export type CoderConfig = { +/** + * Coder template child workspaces are created from (an allowlist - + * never per-ticket input) + */ +template: string, +/** + * Env var NAME holding the Coder deployment URL + */ +url_env: string, +/** + * Env var NAME holding the Coder session token. The variable is stripped + * from every agent's spawn environment on all target kinds. + */ +token_env: string, +/** + * Workspace name prefix for deterministic per-ticket naming + */ +name_prefix: string, +/** + * Project root inside the workspace (None = /home/coder/{project}) + */ +workdir?: string | null, +/** + * Stop the workspace when the ticket completes (never delete) + */ +stop_on_complete: boolean, +/** + * Bound on workspace create + agent-ready wait + */ +create_timeout_secs: bigint, +/** + * Control-plane-reachable `OPERATOR_API_URL` override for detached + * multi-step (empty/None = reverse tunnel default) + */ +callback_url?: string | null, +/** + * Passthrough `--parameter` template parameters for `coder create` + */ +parameters?: { [key in string]: string }, }; + +export type ProfileIdentity = { id: string, name: string, }; + export type State = { paused: boolean, agents: Array, completed: Array, /** * Per-project LLM usage statistics @@ -904,7 +1332,6 @@ llm_tool: string | null, llm_model: string | null, /** * Launch mode: `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]` - * (derived from the resolved execution target; parse with `agents::parse_launch_mode`, never substring-match) */ launch_mode: string | null, /** @@ -929,12 +1356,80 @@ remote_host: string | null, */ step_launch_context: StepLaunchContext | null, /** - * Name of the resolved execution target this agent launched on + * Name of the resolved execution target this agent launched on. + */ +target_name: string | null, +/** + * Shutdown recovery strategy. */ -target_name: string | null, }; +shutdown_recovery?: ShutdownRecovery | null, }; export type CompletedTicket = { ticket_id: string, ticket_type: string, project: string, summary: string, completed_at: string, pr_url: string | null, output_tickets: Array, }; +export type MultiAgentGroup = { +/** + * Unique group identifier + */ +group_id: string, +/** + * Ticket this group belongs to + */ +ticket_id: string, +/** + * Step name being executed + */ +step_name: string, +/** + * Step type (`multi_model`, `multi_prompt`, `matrixed`) + */ +step_type: string, +/** + * Agent IDs in this group (populated as sub-agents launch) + */ +agent_ids: Array, +/** + * Current execution phase + */ +phase: MultiAgentPhase, +/** + * Collected outputs from completed sub-agents, keyed by `variant_key` + * (delegator name for `multi_model`, index for `multi_prompt`, + * `{delegator}:{prompt_idx}` for `matrixed`). + */ +individual_outputs: { [key in string]: JsonValue }, +/** + * Final aggregated output (set when phase = Complete) + */ +aggregated_output: JsonValue | null, +/** + * Total sub-agents expected (`agent_ids.len() + pending_launches.len()`). + */ +expected_total: number, +/** + * Sub-agents that still need launching (waiting for a free slot). + */ +pending_launches: Array, +/** + * Maps launched `agent_id` to the `variant_key` used as the output key. + */ +agent_variant_keys: { [key in string]: string }, }; + +export type MultiAgentPhase = "fan_out" | "voting" | "complete" | "failed"; + +export type PendingSubAgent = { +/** + * Delegator (from `config.delegators`) this sub-agent should use. + */ +delegator_name: string, +/** + * Fully-rendered prompt text for this sub-agent. + */ +prompt: string, +/** + * Key under which this sub-agent's output is recorded (see `individual_outputs`). + */ +variant_key: string, }; + export type IssueTypeResponse = { key: string, name: string, description: string, mode: string, glyph: string, color: string | null, project_required: boolean, source: string, /** * Owning collection under resolution-order lookup @@ -1091,6 +1586,16 @@ health: string, */ actions: Array, }; +export type RowActionDto = { +/** + * Display label for the action button/link. + */ +label: string, +/** + * Browser URL the action opens. + */ +url: string, }; + export type SupportStatus = "proto" | "alpha" | "beta" | "ga"; export type IntegrationCatalogEntryDto = { @@ -1121,7 +1626,11 @@ readme_badge: boolean, /** * Official support / maturity status. */ -status: SupportStatus, }; +status: SupportStatus, premium: boolean, +/** + * Implemented session controllers for an IDE; absent for other categories. + */ +session_wrappers: Array | null, }; export type KanbanProviderCatalogEntry = { /** @@ -1428,6 +1937,8 @@ host?: string | null, */ target?: string | null, }; +export type JsonValue = number | string | boolean | Array | { [key in string]: JsonValue } | null; + export type LlmTask = { /** * LLM task ID (e.g., Claude delegate mode task UUID) @@ -1751,3 +2262,4 @@ worktreePath?: string, * Git branch name */ branch?: string, }; + diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index b06ef984..00916f9f 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -225,7 +225,7 @@ pub fn launch_in_cmux_with_options( )?; // Inject relay env vars so agents can find the hub and register with their ticket ID - if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + if let Some(socket_path) = crate::relay::active_hub_socket() { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\r", ticket.id @@ -401,7 +401,7 @@ pub fn launch_in_cmux_with_relaunch_options( Some(operator_env), options.launch_options.provider.as_ref().map(|p| &p.env), )?; - if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + if let Some(socket_path) = crate::relay::active_hub_socket() { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\r", ticket.id diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index eb2346c0..eb797973 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -384,7 +384,7 @@ fn generate_config_flags( } // Inject relay MCP server based on effective relay setting - let hub_available = std::env::var("RELAY_HUB_SOCKET").is_ok(); + let hub_available = crate::relay::active_hub_socket().is_some(); if resolve_relay_injection(operator_relay, hub_available, config.relay.auto_inject_mcp) { if let Some(config_path) = relay_mcp_config_flag(&session_dir) { cli_flags.push("--mcp-config".to_string()); diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 61246595..db148ccb 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -263,6 +263,8 @@ impl Launcher { ticket: &Ticket, options: LaunchOptions, ) -> Result { + crate::licensing::require_target(&self.config, &options.target)?; + self.require_step_entitlement(ticket, &options)?; let git = crate::git::identity::resolve_config( &self.config, ticket, @@ -406,6 +408,7 @@ impl Launcher { working_dir_str: &str, options: &mut LaunchOptions, ) -> Result<()> { + crate::licensing::require_target(&self.config, &options.target)?; if let crate::config::TargetKind::Coder(coder_cfg) = &options.target.kind { let remote_url = crate::git::GitCli::get_remote_url(std::path::Path::new(working_dir_str)) @@ -440,6 +443,7 @@ impl Launcher { initial_prompt: &str, options: &LaunchOptions, ) -> Result<(String, String)> { + crate::licensing::require_target(&self.config, &options.target)?; // Pre-allocate agent ID so we can inject it into the environment let agent_id = Uuid::new_v4().to_string(); @@ -462,6 +466,7 @@ impl Launcher { }); let operator_env = prompt::OperatorEnvVars { + profile_id: self.config.profile.id, git_context: crate::git::identity::resolve_config( &self.config, ticket, @@ -677,6 +682,37 @@ impl Launcher { Ok(opts) } + fn require_step_entitlement(&self, ticket: &Ticket, options: &LaunchOptions) -> Result<()> { + let Some(step) = ticket.current_step_schema() else { + return Ok(()); + }; + let delegators: Vec<&str> = match step.step_type { + crate::templates::schema::StepTypeTag::MultiModel => step + .multi_model_config + .as_ref() + .map(|config| config.delegators.iter().map(String::as_str).collect()) + .unwrap_or_default(), + crate::templates::schema::StepTypeTag::Matrixed => step + .matrixed_config + .as_ref() + .map(|config| config.delegators.iter().map(String::as_str).collect()) + .unwrap_or_default(), + crate::templates::schema::StepTypeTag::MultiPrompt => step + .multi_prompt_config + .as_ref() + .and_then(|config| config.agent.as_deref()) + .or_else(|| crate::templates::step_type::effective_agent(&step)) + .into_iter() + .collect(), + _ => Vec::new(), + }; + for delegator in delegators { + let resolved = self.sub_agent_options(options, delegator, delegator)?; + crate::licensing::require_target(&self.config, &resolved.target)?; + } + Ok(()) + } + /// Render a prompt template with the ticket's handlebars context. fn render_variant_prompt( &self, @@ -963,6 +999,7 @@ impl Launcher { ticket: &Ticket, options: LaunchOptions, ) -> Result { + crate::licensing::require_target(&self.config, &options.target)?; // Clone ticket so we can update worktree info let mut ticket = ticket.clone(); @@ -1022,6 +1059,10 @@ impl Launcher { // Build operator environment variables HashMap for PreparedLaunch let mut env_vars = std::collections::HashMap::new(); + env_vars.insert( + "OPERATOR_PROFILE_ID".to_string(), + self.config.profile.id.to_string(), + ); env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone()); env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone()); env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone()); @@ -1246,6 +1287,7 @@ impl Launcher { ticket: &Ticket, options: RelaunchOptions, ) -> Result { + crate::licensing::require_target(&self.config, &options.launch_options.target)?; // Clone ticket so we can update worktree info if needed let mut ticket = ticket.clone(); @@ -1320,6 +1362,10 @@ impl Launcher { // Build operator environment variables HashMap for PreparedLaunch let mut env_vars = std::collections::HashMap::new(); + env_vars.insert( + "OPERATOR_PROFILE_ID".to_string(), + self.config.profile.id.to_string(), + ); env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone()); env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone()); env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone()); @@ -1556,6 +1602,7 @@ impl Launcher { /// Used when a tmux session died but the ticket is still in progress. /// Can optionally resume from an existing Claude session ID. pub async fn relaunch(&self, ticket: &Ticket, options: RelaunchOptions) -> Result { + crate::licensing::require_target(&self.config, &options.launch_options.target)?; let resolved = crate::git::identity::resolve_config( &self.config, ticket, @@ -1667,6 +1714,7 @@ impl Launcher { }); let operator_env = prompt::OperatorEnvVars { + profile_id: self.config.profile.id, git_context: crate::git::runtime::current(), agent_id: agent_id.clone(), ticket_id: ticket.id.clone(), diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index 42abddce..ef35221a 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -14,6 +14,7 @@ use crate::templates::{schema::TemplateSchema, TemplateType}; /// for branding (status line, pane title, UI deep-links). #[derive(Debug, Clone, Default)] pub struct OperatorEnvVars { + pub profile_id: uuid::Uuid, pub git_context: Option, pub agent_id: String, pub ticket_id: String, @@ -33,7 +34,8 @@ impl OperatorEnvVars { /// depends on disk-based discovery. pub fn to_export_block(&self) -> String { let mut block = format!( - "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\nexport OPERATOR_API_URL=http://127.0.0.1:{}\n", + "export OPERATOR_PROFILE_ID={}\nexport OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\nexport OPERATOR_API_URL=http://127.0.0.1:{}\n", + shell_escape(&self.profile_id.to_string()), shell_escape(&self.agent_id), shell_escape(&self.ticket_id), shell_escape(&self.project), @@ -560,7 +562,9 @@ mod tests { #[test] fn test_operator_env_vars_to_export_block() { + let profile_id = Uuid::new_v4(); let env = OperatorEnvVars { + profile_id, git_context: None, agent_id: "abc-123".to_string(), ticket_id: "FEAT-042".to_string(), @@ -571,6 +575,9 @@ mod tests { callback_token: String::new(), }; let block = env.to_export_block(); + // opr8r reads this to route its callback at the right configuration, + // and on a remote launch the block travels inside the generated script. + assert!(block.contains(&format!("export OPERATOR_PROFILE_ID='{profile_id}'"))); assert!(block.contains("export OPERATOR_AGENT_ID='abc-123'")); assert!(block.contains("export OPERATOR_TICKET_ID='FEAT-042'")); assert!(block.contains("export OPERATOR_PROJECT='gamesvc'")); @@ -582,6 +589,7 @@ mod tests { #[test] fn test_operator_env_vars_to_pane_title_line() { let env = OperatorEnvVars { + profile_id: Uuid::nil(), git_context: None, agent_id: "abc-123".to_string(), ticket_id: "FEAT-042".to_string(), @@ -605,6 +613,7 @@ mod tests { let config = make_test_config_with_tickets_path(temp_dir.path()); let env = OperatorEnvVars { + profile_id: Uuid::nil(), git_context: None, agent_id: "test-agent-id".to_string(), ticket_id: "FEAT-001".to_string(), @@ -729,6 +738,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let config = make_test_config_with_tickets_path(temp.path()); let env = OperatorEnvVars { + profile_id: Uuid::nil(), git_context: Some(crate::config::GitExecutionConfig { identity: Some(crate::config::GitIdentityConfig { name: "Ticket Agent".into(), diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index a25fcd6b..187ea961 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -512,6 +512,7 @@ use crate::agents::tmux::TmuxClient; fn make_test_operator_env() -> OperatorEnvVars { OperatorEnvVars { + profile_id: Uuid::nil(), git_context: None, agent_id: Uuid::new_v4().to_string(), ticket_id: "TEST-001".to_string(), diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index c64d7580..9c506c68 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -233,7 +233,7 @@ pub fn launch_in_tmux_with_options( )?; // Inject relay env vars so agents can find the hub and register with their ticket ID - if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + if let Some(socket_path) = crate::relay::active_hub_socket() { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}", ticket.id @@ -501,7 +501,7 @@ pub fn launch_in_tmux_with_relaunch_options( )?; // Inject relay env vars so agents can find the hub and register with their ticket ID - if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + if let Some(socket_path) = crate::relay::active_hub_socket() { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}", ticket.id diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 5d5df9be..d1363070 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -160,7 +160,7 @@ pub fn launch_in_zellij_with_options( )?; // Inject relay env vars so agents can find the hub and register with their ticket ID - if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + if let Some(socket_path) = crate::relay::active_hub_socket() { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", ticket.id @@ -340,7 +340,7 @@ pub fn launch_in_zellij_with_relaunch_options( Some(operator_env), options.launch_options.provider.as_ref().map(|p| &p.env), )?; - if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { + if let Some(socket_path) = crate::relay::active_hub_socket() { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", ticket.id diff --git a/src/agents/pr_workflow.rs b/src/agents/pr_workflow.rs index a18e3c70..630e467f 100644 --- a/src/agents/pr_workflow.rs +++ b/src/agents/pr_workflow.rs @@ -34,9 +34,7 @@ impl Default for PrWorkflow { } impl PrWorkflow { - /// Build a workflow over an explicit `PrService`. The injection seam the - /// orchestration layer was missing -- both real constructors hardcoded a - /// router, so nothing above `PrService` could be tested with a mock. + /// Build a workflow over an explicit `PrService`. pub fn with_service(service: Arc) -> Self { Self { hosts: crate::types::pr::ProviderHosts::default(), diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index ead7f5e4..772c7617 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -242,7 +242,8 @@ pub trait KanbanProvider: Send + Sync { /// Detect which kanban providers are configured based on environment variables pub fn detect_configured_providers() -> Vec { - let mut providers = Vec::new(); + // The built-in board needs no credentials and is always available. + let mut providers = vec![KanbanProviderType::Operator.slug().to_string()]; if JiraProvider::from_env() .map(|p| p.is_configured()) @@ -271,6 +272,10 @@ pub fn detect_configured_providers() -> Vec { /// Type of kanban provider #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KanbanProviderType { + /// The built-in board: operator's own markdown tickets under `.tickets/`. + /// Always present, never connected, and never a sync *source* - every other + /// variant syncs *into* this one. + Operator, Jira, Linear, Github, @@ -284,7 +289,8 @@ impl KanbanProviderType { /// surface (TUI status section, web `/#/kanban`, the REST provider catalog /// endpoint, and the VS Code onboarding picker) derives its list from here /// so the options can't drift apart. - pub const ALL: [KanbanProviderType; 4] = [ + pub const ALL: [KanbanProviderType; 5] = [ + KanbanProviderType::Operator, KanbanProviderType::Jira, KanbanProviderType::Linear, KanbanProviderType::Github, @@ -294,6 +300,7 @@ impl KanbanProviderType { /// Get the display name pub fn display_name(&self) -> &'static str { match self { + KanbanProviderType::Operator => "Operator", KanbanProviderType::Jira => "Jira Cloud", KanbanProviderType::Linear => "Linear", KanbanProviderType::Github => "GitHub Projects", @@ -305,6 +312,7 @@ impl KanbanProviderType { /// `ConfigureKanbanProvider` action, and the REST catalog. pub fn slug(&self) -> &'static str { match self { + KanbanProviderType::Operator => "operator", KanbanProviderType::Jira => "jira", KanbanProviderType::Linear => "linear", KanbanProviderType::Github => "github", @@ -319,9 +327,15 @@ impl KanbanProviderType { .find(|p| p.slug() == slug) } + /// Whether this provider is operator's own board rather than an external service + pub fn is_builtin(&self) -> bool { + matches!(self, KanbanProviderType::Operator) + } + /// One-line "connect" description shown next to the provider in list views. pub fn connect_blurb(&self) -> &'static str { match self { + KanbanProviderType::Operator => "Built in - your tickets are the board", KanbanProviderType::Jira => "Connect to Jira Cloud", KanbanProviderType::Linear => "Connect to Linear", KanbanProviderType::Github => "Connect to GitHub Projects", @@ -329,11 +343,13 @@ impl KanbanProviderType { } } - /// The provider's credential/token page. Opened by the TUI "Configure" - /// action and surfaced as the clickable link on the web `/#/kanban` rows - /// (there is no in-browser onboarding wizard - this opens the token page). + /// The provider's credential/token page. Opened by the TUI "Configure" action pub fn setup_url(&self) -> &'static str { match self { + // Nothing to connect - link the docs. + KanbanProviderType::Operator => { + "https://operator.untra.io/getting-started/kanban/operator/" + } KanbanProviderType::Jira => { "https://id.atlassian.com/manage-profile/security/api-tokens" } @@ -349,6 +365,8 @@ impl KanbanProviderType { /// Codicon hint for the VS Code onboarding picker (rendered as `$(icon)`). pub fn icon(&self) -> &'static str { match self { + // A stock codicon, as OpenSpec uses: the `operator-*` glyphs come from the generated icon font + KanbanProviderType::Operator => "layout", KanbanProviderType::Jira => "operator-atlassian", KanbanProviderType::Linear => "operator-linear", KanbanProviderType::Github => "github", @@ -359,6 +377,8 @@ impl KanbanProviderType { /// Get the default environment variable name for the API key pub fn default_api_key_env(&self) -> &'static str { match self { + // The built-in board reads local files; there is no credential. + KanbanProviderType::Operator => "", KanbanProviderType::Jira => "OPERATOR_JIRA_API_KEY", KanbanProviderType::Linear => "OPERATOR_LINEAR_API_KEY", KanbanProviderType::Github => "OPERATOR_GITHUB_TOKEN", @@ -426,8 +446,9 @@ impl DetectedKanbanProvider { .iter() .any(|v| v.contains("TOKEN") || v.contains("API_KEY")) } - // OpenSpec needs no env vars - configuration is a local path. - KanbanProviderType::Openspec => true, + // Neither needs env vars: OpenSpec is a local path, and the + // built-in board is always available. + KanbanProviderType::Operator | KanbanProviderType::Openspec => true, } } } @@ -680,6 +701,8 @@ pub async fn test_provider_credentials(provider: &DetectedKanbanProvider) -> Res Ok(()) } + // The built-in board is always available; there is nothing to test. + KanbanProviderType::Operator => Ok(()), KanbanProviderType::Openspec => Err( "OpenSpec has no credentials to test; configure [kanban.openspec.] root_path" .to_string(), @@ -687,6 +710,34 @@ pub async fn test_provider_credentials(provider: &DetectedKanbanProvider) -> Res } } +/// Validate a provider slug for a command that pulls issues *from* a provider. +/// +/// Rejects the built-in board: it is the sync destination, so pulling from it +/// would re-import operator's own tickets. +pub fn validate_sync_source(slug: &str) -> Result { + let sources = || { + KanbanProviderType::ALL + .into_iter() + .filter(|p| !p.is_builtin()) + .map(|p| format!("'{}'", p.slug())) + .collect::>() + .join(", ") + }; + match KanbanProviderType::from_slug(&slug.to_lowercase()) { + Some(p) if p.is_builtin() => Err(format!( + "{} is the built-in board, not a sync source; its tickets already live in .tickets/. \ + Use one of: {}.", + p.display_name(), + sources() + )), + Some(p) => Ok(p), + None => Err(format!( + "Unknown kanban provider: {slug}. Use one of: {}.", + sources() + )), + } +} + /// Get a provider by name pub fn get_provider(name: &str) -> Option> { match name.to_lowercase().as_str() { @@ -699,7 +750,8 @@ pub fn get_provider(name: &str) -> Option> { "github" => GithubProjectsProvider::from_env() .ok() .map(|p| Box::new(p) as Box), - // openspec cannot be built from env - use get_provider_from_config + // Neither can be built from env: openspec needs a configured root path + // (use get_provider_from_config), and operator is not a sync source. _ => None, } } @@ -761,6 +813,11 @@ pub fn get_provider_from_config( })?; Ok(Box::new(OpenspecProvider::from_config(instance, cfg)) as Box) } + // The built-in board is the sync *destination*; pulling from it would + // re-import operator's own tickets. + "operator" => Err(ApiError::not_configured( + "operator is the built-in local board, not a sync source", + )), _ => Err(ApiError::not_configured(format!( "Unknown provider: '{provider_name}'. Supported: jira, linear, github, openspec" ))), @@ -1022,11 +1079,12 @@ mod tests { } #[test] - fn test_provider_type_all_covers_four_providers() { - assert_eq!(KanbanProviderType::ALL.len(), 4); + fn test_provider_type_all_lists_the_builtin_board_first() { + assert_eq!(KanbanProviderType::ALL.len(), 5); assert_eq!( KanbanProviderType::ALL, [ + KanbanProviderType::Operator, KanbanProviderType::Jira, KanbanProviderType::Linear, KanbanProviderType::Github, @@ -1035,6 +1093,49 @@ mod tests { ); } + #[test] + fn test_validate_sync_source_rejects_the_builtin_board() { + let err = validate_sync_source("operator").unwrap_err(); + assert!(err.contains("not a sync source"), "{err}"); + assert!(err.contains("'jira'"), "{err}"); + assert!(!err.contains("'operator'"), "{err}"); + } + + #[test] + fn test_validate_sync_source_accepts_external_providers() { + for provider in KanbanProviderType::ALL { + let result = validate_sync_source(provider.slug()); + assert_eq!(result.is_ok(), !provider.is_builtin(), "{provider:?}"); + } + assert!(validate_sync_source("nope").is_err()); + } + + #[test] + fn test_operator_is_the_only_builtin_provider() { + let builtin: Vec<_> = KanbanProviderType::ALL + .into_iter() + .filter(KanbanProviderType::is_builtin) + .collect(); + assert_eq!(builtin, vec![KanbanProviderType::Operator]); + } + + /// The built-in board is the sync destination. Constructing it as a + /// provider would let `KanbanSyncService` re-import operator's own tickets. + #[test] + fn test_operator_cannot_be_built_as_a_sync_source() { + assert!(get_provider("operator").is_none()); + let kanban = crate::config::KanbanConfig::default(); + assert!(get_provider_from_config(&kanban, "operator", "ANY").is_err()); + } + + #[test] + fn test_detect_configured_providers_always_includes_the_builtin_board() { + assert_eq!( + detect_configured_providers().first().map(String::as_str), + Some("operator") + ); + } + #[test] fn test_provider_type_slug_from_slug_round_trips() { for provider in KanbanProviderType::ALL { diff --git a/src/api/providers/mod.rs b/src/api/providers/mod.rs index 665c98fc..0f36dadb 100644 --- a/src/api/providers/mod.rs +++ b/src/api/providers/mod.rs @@ -1,12 +1,7 @@ #![allow(dead_code)] #![allow(unused_imports)] -//! Provider trait definitions for external service integrations -//! -//! This module defines the trait interfaces for different provider categories: -//! - AI providers (Anthropic, `OpenAI`, Gemini) -//! - Repository providers (GitHub, GitLab, Azure Repos) -//! - Kanban providers (Jira, Linear) for importing issue types +//! Provider trait definitions for external service integrations. Defines the trait interfaces for different provider categories: pub mod ai; pub mod kanban; diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index de8d2292..4259ee74 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -131,6 +131,12 @@ impl App { // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { + if setup.step == crate::ui::setup::SetupStep::Welcome + && matches!(code, KeyCode::Char(_) | KeyCode::Backspace) + { + setup.handle_configuration_name_key(code); + return Ok(()); + } // The password step needs raw characters, and the wizard bindings // below would eat them: `i` runs initialize_tickets() outright, // `c` quits the app, and `j`/`k`/space navigate. Route text keys to @@ -151,6 +157,21 @@ impl App { setup.handle_password_key(code); return Ok(()); } + if setup.step == crate::ui::setup::SetupStep::License + && matches!( + code, + KeyCode::Char(_) + | KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Left + | KeyCode::Right + | KeyCode::Home + | KeyCode::End + ) + { + setup.handle_license_key(code); + return Ok(()); + } if setup.step == crate::ui::setup::SetupStep::ExecutionTarget && setup.execution_target_state.selected() == Some(1) && matches!( @@ -203,12 +224,16 @@ impl App { // the borrow on `setup_screen` has ended. let open_kanban = setup.take_kanban_dialog_request(); let git_slug = setup.take_git_connect_request(); + let license_key = setup.take_license_install_request(); if open_kanban { self.show_kanban_onboarding_dialog(); } if let Some(slug) = git_slug { self.connect_git_provider_from_setup(&slug); } + if let Some(key) = license_key { + self.install_license_from_setup(&key); + } } } } diff --git a/src/app/license_onboarding.rs b/src/app/license_onboarding.rs new file mode 100644 index 00000000..25850dc4 --- /dev/null +++ b/src/app/license_onboarding.rs @@ -0,0 +1,13 @@ +//! TUI bridge for installing a Premium licence from the setup wizard. +//! +//! Mirrors [`crate::app::git_onboarding`]: the wizard records the request, the +//! app performs it against `Config`, and the outcome is written back. + +impl crate::app::App { + pub(super) fn install_license_from_setup(&mut self, key: &str) { + let outcome = crate::licensing::install(&self.config, key).map_err(|e| e.to_string()); + if let Some(setup) = self.setup_screen.as_mut() { + setup.set_license_outcome(outcome); + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 2ba759f7..039b192d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -34,6 +34,7 @@ mod git_onboarding; mod kanban; mod kanban_onboarding; mod keyboard; +mod license_onboarding; mod pr_workflow; mod review; mod session; @@ -149,16 +150,24 @@ impl App { detected_tools, projects_by_tool, ); + setup.configuration_name = + if config.profile.name == crate::profiles::LEGACY_PROFILE_NAME { + String::new() + } else { + config.profile.name.clone() + }; // Set after construction so `SetupScreen::new`'s signature stays // stable for its other callers and tests. A store that cannot be // opened is treated as "not configured". setup.admin_password_configured = - crate::auth::store::AuthStore::open(&config.state_path()) + crate::auth::store::AuthStore::open(&config.auth_state_path()) .and_then(|store| store.bootstrap_state()) .is_ok_and(|state| { state != crate::rest::dto::auth::BootstrapState::Uninitialized }); + setup.license = Some(crate::licensing::status(&config)); + // Projects will be saved to config during initialize_tickets() (Some(setup), discovered_projects) } else { @@ -252,8 +261,9 @@ impl App { #[cfg(unix)] let relay_hub = match RelayHub::start(hub_socket_path()).await { Ok(hub) => { - // Export socket path so child processes (agents) can find the hub - std::env::set_var("RELAY_HUB_SOCKET", hub.socket_path()); + // Recorded, not exported: the launchers read it and put it in + // each agent's own environment (see crate::relay). + crate::relay::set_active_hub_socket(hub.socket_path().to_path_buf()); tracing::info!(socket = %hub.socket_path().display(), "Relay hub started"); Some(hub) } diff --git a/src/app/tickets.rs b/src/app/tickets.rs index 8ed13242..9fd71f4f 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -45,6 +45,17 @@ impl App { /// Initialize the tickets directory with default templates and save config pub(super) fn initialize_tickets(&mut self) -> Result<()> { let options = self.setup_options(); + if let Some(screen) = self.setup_screen.as_ref() { + if self.config.profile_registry.is_some() { + crate::profiles::rename_registered(&mut self.config, &screen.configuration_name)?; + } else { + crate::profiles::validate_name(&screen.configuration_name)?; + self.config + .profile + .name + .clone_from(&screen.configuration_name); + } + } let result = crate::setup::initialize_workspace(&mut self.config, &options)?; let discovered_full = result.discovered; let discovered_projects = self.config.projects.clone(); @@ -55,7 +66,7 @@ impl App { .as_ref() .and_then(|s| s.admin_password.as_deref()) { - let store = AuthStore::open(&self.config.state_path())?; + let store = AuthStore::open(&self.config.auth_state_path())?; persist_admin_password(&store, Some(password))?; } @@ -146,6 +157,8 @@ impl App { } } + crate::startup::mark_workspace_initialized(&self.config)?; + Ok(()) } diff --git a/src/auth/callback.rs b/src/auth/callback.rs index 3bf1f150..062dc9f7 100644 --- a/src/auth/callback.rs +++ b/src/auth/callback.rs @@ -27,12 +27,12 @@ const CALLBACK_TTL: Duration = Duration::hours(24); /// Opens the auth database rather than holding a handle: launches are infrequent, `Launcher` is constructed from a bare `Config` in the CLI, /// the TUI, and the REST API alike, and threading an auth handle through all three would be a large change for a per-launch cost that is already dominated by spawning a process. pub fn mint(config: &Config, ticket_id: &str, step: &str, session_id: &str) -> Result { - let store = AuthStore::open(&config.state_path()).context("opening the auth store")?; + let store = AuthStore::open(&config.auth_state_path()).context("opening the auth store")?; let key = store .load_or_create_signing_key() .context("loading the token signing key")?; - let claims = callback_claims( + let mut claims = callback_claims( ADMIN_SUBJECT, ticket_id, step, @@ -41,6 +41,7 @@ pub fn mint(config: &Config, ticket_id: &str, step: &str, session_id: &str) -> R Utc::now(), uuid::Uuid::new_v4().to_string(), ); + claims.profile_id = Some(config.profile.id); key.sign(&claims).context("signing the callback token") } @@ -63,7 +64,7 @@ mod tests { let token = mint(&config, "FEAT-42", "build", "sess-1").unwrap(); - let store = AuthStore::open(&config.state_path()).unwrap(); + let store = AuthStore::open(&config.auth_state_path()).unwrap(); let key = store.load_or_create_signing_key().unwrap(); let claims = key.verify(&token, AUDIENCE_CALLBACK).unwrap(); @@ -81,7 +82,7 @@ mod tests { let config = config_in(dir.path()); let token = mint(&config, "FEAT-42", "build", "sess-1").unwrap(); - let store = AuthStore::open(&config.state_path()).unwrap(); + let store = AuthStore::open(&config.auth_state_path()).unwrap(); let key = store.load_or_create_signing_key().unwrap(); assert!(key.verify(&token, AUDIENCE_API).is_err()); } @@ -96,7 +97,7 @@ mod tests { let first = mint(&config, "FEAT-1", "build", "s1").unwrap(); let second = mint(&config, "FEAT-2", "review", "s2").unwrap(); - let store = AuthStore::open(&config.state_path()).unwrap(); + let store = AuthStore::open(&config.auth_state_path()).unwrap(); let key = store.load_or_create_signing_key().unwrap(); assert!(key.verify(&first, AUDIENCE_CALLBACK).is_ok()); assert!(key.verify(&second, AUDIENCE_CALLBACK).is_ok()); @@ -108,7 +109,7 @@ mod tests { let config = config_in(dir.path()); let token = mint(&config, "FEAT-1", "build", "s1").unwrap(); - let store = AuthStore::open(&config.state_path()).unwrap(); + let store = AuthStore::open(&config.auth_state_path()).unwrap(); let key = store.load_or_create_signing_key().unwrap(); let claims = key.verify(&token, AUDIENCE_CALLBACK).unwrap(); diff --git a/src/auth/scope.rs b/src/auth/scope.rs index be7d4340..8730c551 100644 --- a/src/auth/scope.rs +++ b/src/auth/scope.rs @@ -64,14 +64,24 @@ const fn admin(method: &'static str, path: &'static str) -> RouteRule { /// Every mounted route and what it requires. /// -/// Two classifications here are load-bearing and worth stating: -/// /// * **Configuration is `Admin` in both directions.** Even the narrowed public /// projection controls process launch and resource limits. /// * **Health and status are `Read`, not public.** They report the workspace /// directory name and id. `/livez` and `/readyz` exist precisely so probes /// never need that. pub static ROUTE_RULES: &[RouteRule] = &[ + read("GET", "/api/v1/license"), + admin("PUT", "/api/v1/license"), + admin("DELETE", "/api/v1/license"), + read("GET", "/api/v1/targets"), + admin("POST", "/api/v1/targets"), + admin("PUT", "/api/v1/targets/{name}"), + admin("DELETE", "/api/v1/targets/{name}"), + admin("POST", "/api/v1/targets/{name}/probe"), + read("GET", "/api/v1/profiles"), + admin("POST", "/api/v1/profiles"), + read("GET", "/api/v1/profiles/{profile_id}"), + admin("PATCH", "/api/v1/profiles/{profile_id}"), // --- Public: probes ----------------------------------------------------- public("GET", "/livez"), public("GET", "/readyz"), diff --git a/src/auth/tokens.rs b/src/auth/tokens.rs index 785861ed..df248d33 100644 --- a/src/auth/tokens.rs +++ b/src/auth/tokens.rs @@ -32,6 +32,8 @@ pub const AUDIENCE_CALLBACK: &str = "opr8r-callback"; /// Registered and Operator-specific claims. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_id: Option, /// Issuer. pub iss: String, /// Audience. @@ -174,6 +176,7 @@ impl SigningKey { /// Build the claims for an ordinary API access token. pub fn api_claims(subject: &str, scopes: &[Scope], now: DateTime, jti: String) -> Claims { Claims { + profile_id: None, iss: ISSUER.to_string(), aud: AUDIENCE_API.to_string(), sub: subject.to_string(), @@ -209,6 +212,7 @@ pub fn callback_claims( jti: String, ) -> Claims { Claims { + profile_id: None, iss: ISSUER.to_string(), aud: AUDIENCE_CALLBACK.to_string(), sub: subject.to_string(), diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index d6c1a2e8..a7f19e2d 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -29,23 +29,30 @@ use operator::api::providers::kanban::{ JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, }; use operator::config::{ - AgentProfile, AgentsConfig, ApiConfig, CollectionPreset, Config, Delegator, - DelegatorLaunchConfig, DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, - LoggingConfig, NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RemoteAgentRef, - RestApiConfig, SkillDirectoriesOverride, TemplatesConfig, TmuxConfig, ToolCapabilities, - UiConfig, XOperator, YoloConfig, + AcpConfig, AgentProfile, AgentsConfig, ApiConfig, CmuxPlacementPolicy, CoderConfig, + CollectionPreset, Config, Delegator, DelegatorLaunchConfig, DetectedTool, DockerConfig, + ExternalMcpServer, ForgejoConfig, GitConfig, GitHubConfig, GitLabConfig, GiteaConfig, + KanbanConfig, LaunchConfig, LlmProvider, LlmToolsConfig, LoggingConfig, McpConfig, ModelServer, + NotificationsConfig, OsNotificationConfig, PanelNamesConfig, PathsConfig, QueueConfig, + RelayConfig, RemoteAgentRef, RemoteHost, RestApiConfig, SessionWrapperType, SessionsCmuxConfig, + SessionsConfig, SessionsTmuxConfig, SessionsVSCodeConfig, SessionsZellijConfig, + SkillDirectoriesOverride, SshTarget, TargetDef, TargetKind, TemplatesConfig, TmuxConfig, + ToolCapabilities, UiConfig, VersionCheckConfig, WebhookConfig, XOperator, YoloConfig, }; +use operator::profiles::ProfileIdentity; use operator::queue::LlmTask; use operator::rest::dto::{ CollectionResponse, CreateAlertRequest, CreateAlertResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, CreateTicketRequest, CreateTicketResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, - KanbanProviderCatalogEntry, SectionDto, SectionRowDto, SkillEntry, SkillsResponse, - StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + KanbanProviderCatalogEntry, RowActionDto, SectionDto, SectionRowDto, SkillEntry, + SkillsResponse, StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, }; -use operator::state::{AgentState, CompletedTicket, State}; +use operator::state::{ + AgentState, CompletedTicket, MultiAgentGroup, MultiAgentPhase, PendingSubAgent, State, +}; use operator::types::{ AttemptStatus, ExecutionProcess, ProcessStatus, Project, ProjectRepo, RunReason, Session, StepAttempt, @@ -127,10 +134,42 @@ fn generate_typescript() -> String { TemplatesConfig::decl(&cfg), LoggingConfig::decl(&cfg), ApiConfig::decl(&cfg), + AcpConfig::decl(&cfg), + McpConfig::decl(&cfg), + RelayConfig::decl(&cfg), + VersionCheckConfig::decl(&cfg), + SessionsConfig::decl(&cfg), + SessionWrapperType::decl(&cfg), + SessionsTmuxConfig::decl(&cfg), + SessionsVSCodeConfig::decl(&cfg), + SessionsCmuxConfig::decl(&cfg), + CmuxPlacementPolicy::decl(&cfg), + SessionsZellijConfig::decl(&cfg), + ExternalMcpServer::decl(&cfg), + GitConfig::decl(&cfg), + GitHubConfig::decl(&cfg), + GitLabConfig::decl(&cfg), + GiteaConfig::decl(&cfg), + ForgejoConfig::decl(&cfg), + KanbanConfig::decl(&cfg), + ModelServer::decl(&cfg), + RemoteHost::decl(&cfg), + OsNotificationConfig::decl(&cfg), + WebhookConfig::decl(&cfg), + // Execution targets (src/config/targets.rs) + TargetDef::decl(&cfg), + TargetKind::decl(&cfg), + SshTarget::decl(&cfg), + CoderConfig::decl(&cfg), + // Configuration identity (src/profiles.rs) + ProfileIdentity::decl(&cfg), // State types (src/state.rs) State::decl(&cfg), AgentState::decl(&cfg), CompletedTicket::decl(&cfg), + MultiAgentGroup::decl(&cfg), + MultiAgentPhase::decl(&cfg), + PendingSubAgent::decl(&cfg), // REST DTOs (src/rest/dto.rs) IssueTypeResponse::decl(&cfg), IssueTypeSummary::decl(&cfg), @@ -147,6 +186,7 @@ fn generate_typescript() -> String { StatusResponse::decl(&cfg), SectionDto::decl(&cfg), SectionRowDto::decl(&cfg), + RowActionDto::decl(&cfg), // Integration catalog + support status DTO operator::integrations::SupportStatus::decl(&cfg), IntegrationCatalogEntryDto::decl(&cfg), @@ -169,6 +209,10 @@ fn generate_typescript() -> String { DelegatorsResponse::decl(&cfg), CreateDelegatorRequest::decl(&cfg), DelegatorLaunchConfigDto::decl(&cfg), + // `serde_json::Value`, which ts-rs names `JsonValue`. Several DTOs + // carry free-form JSON, and the bundle has no imports, so its + // declaration has to be emitted here or every use dangles. + ::decl(&cfg), // Queue types (src/queue/ticket.rs) LlmTask::decl(&cfg), // Jira API types (src/api/providers/kanban/jira.rs) diff --git a/src/config.rs b/src/config.rs index f58b52f6..74c0d6f2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,17 @@ use ts_rs::TS; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct Config { + #[serde(default)] + pub profile: crate::profiles::ProfileIdentity, + #[serde(skip)] + #[ts(skip)] + pub config_file: Option, + #[serde(skip)] + #[ts(skip)] + pub profile_registry: Option, + #[serde(skip)] + #[ts(skip)] + pub server_auth_path: Option, /// List of projects operator can assign work to #[serde(default)] pub projects: Vec, @@ -714,6 +725,17 @@ fn env_source() -> config::Environment { } impl Config { + /// Where the auth database lives. + /// + /// Authentication is server-scoped, not per configuration: one login serves + /// every configuration the server hosts. Opening `state_path()` instead + /// silently targets a different database, which is why nothing outside this + /// method should reach for the state directory to find auth. + pub fn auth_state_path(&self) -> PathBuf { + self.server_auth_path + .clone() + .unwrap_or_else(|| self.state_path()) + } /// 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 { @@ -722,7 +744,9 @@ impl Config { /// 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") + self.config_file + .clone() + .unwrap_or_else(|| self.state_path().join("config.toml")) } pub fn load(config_path: Option<&str>) -> Result { @@ -759,7 +783,7 @@ impl Config { builder = builder.add_source(env_source()); let config = builder.build().context("Failed to load configuration")?; - let cfg: Self = config.try_deserialize().map_err(|e| { + let mut cfg: Self = config.try_deserialize().map_err(|e| { let mut sources = vec![]; let operator_config = Self::operator_config_path(); if operator_config.exists() { @@ -792,6 +816,9 @@ impl Config { ); } + if let Some(path) = config_path { + cfg.config_file = Some(std::fs::canonicalize(path)?); + } validate_targets(&cfg)?; crate::git::identity::validate_config(&cfg)?; @@ -908,6 +935,10 @@ impl Config { impl Default for Config { fn default() -> Self { Self { + profile: crate::profiles::ProfileIdentity::default(), + config_file: None, + profile_registry: None, + server_auth_path: None, projects: Vec::new(), // Populated during setup agents: AgentsConfig { max_parallel: 5, diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs index 0e4a809f..26204c4f 100644 --- a/src/config/agent_profile.rs +++ b/src/config/agent_profile.rs @@ -7,8 +7,7 @@ //! defines a namespaced interchange format both sides can serialize to and from //! *losslessly*: a shared core, an Operator-namespaced bag (`x_operator`), and an //! AGNT-namespaced bag (`x_agnt`). Each side reads the core and its own bag, and -//! preserves the other side's bag verbatim - the same lossy-but-honest discipline -//! as the `OPERATOR-GAP` markers in [`crate::workflow_gen`]. +//! preserves the other side's bag verbatim . //! //! This is the schema half of the remote-agent bridge. There is deliberately //! **no** runtime client for any remote platform: a profile carrying diff --git a/src/config/targets.rs b/src/config/targets.rs index 377a524a..096cb303 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -195,9 +195,7 @@ fn default_true() -> bool { true } -/// Validate the target registry and every reference into it. Hard errors keep -/// startup honest (an unknown target must never silently fall back to local); -/// deprecated-combination cases warn instead so legacy configs keep working. +/// Validate the target registry and every reference into it. Hard errors bail startup pub fn validate_targets(config: &super::Config) -> anyhow::Result<()> { let mut seen = std::collections::HashSet::new(); for target in &config.targets { diff --git a/src/docs_gen/integrations.rs b/src/docs_gen/integrations.rs index 41170391..72b856b2 100644 --- a/src/docs_gen/integrations.rs +++ b/src/docs_gen/integrations.rs @@ -66,21 +66,25 @@ impl DocGenerator for MaturityDocGenerator { // One table per vertical, in README order. let entries = all_integrations(); for vertical in Vertical::ALL { - let rows: Vec<_> = entries.iter().filter(|e| e.vertical == vertical).collect(); + let rows: Vec<_> = entries + .iter() + .filter(|e| e.vertical == vertical && e.is_public()) + .collect(); if rows.is_empty() { continue; } content.push_str(&format!("\n## {}\n\n", vertical.label())); - content.push_str("| Integration | Status | Docs |\n|---|---|---|\n"); + content.push_str("| Integration | Status | Availability | Docs |\n|---|---|---|---|\n"); for e in rows { let docs = match e.docs_url() { Some(url) => format!("[{}]({})", e.label, url), None => "-".to_string(), }; content.push_str(&format!( - "| {label} | {badge} | {docs} |\n", + "| {label} | {badge} | {availability} | {docs} |\n", label = e.label, badge = status_badge(e.status), + availability = if e.premium { "Premium" } else { "Included" }, )); } } @@ -118,6 +122,9 @@ mod tests { // Per-vertical tables. assert!(content.contains("## Kanban Provider")); assert!(content.contains("## Model Provider")); + assert!(content.contains("## Remote Targets")); + assert!(content.contains("| Premium |")); + assert!(!content.contains("| Cursor |")); // A known row with a docs link. assert!(content.contains("[Jira](https://operator.untra.io/getting-started/kanban/jira/)")); // AUTO-GENERATED header present. diff --git a/src/env_vars.rs b/src/env_vars.rs index da6e7b86..b477a917 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -48,6 +48,8 @@ pub enum EnvVarCategory { LlmTools, /// Logging configuration Logging, + /// Premium licence verification, supplied at build time + Licensing, } impl EnvVarCategory { @@ -64,6 +66,7 @@ impl EnvVarCategory { EnvVarCategory::Tmux => "Tmux", EnvVarCategory::LlmTools => "LLM Tools", EnvVarCategory::Logging => "Logging", + EnvVarCategory::Licensing => "Licensing (build-time)", } } @@ -80,6 +83,7 @@ impl EnvVarCategory { EnvVarCategory::Tmux, EnvVarCategory::LlmTools, EnvVarCategory::Logging, + EnvVarCategory::Licensing, ] } } @@ -352,6 +356,35 @@ pub static ENV_VARS: &[EnvVar] = &[ default: Some("true"), example: Some("false"), }, + // === Licensing (build-time) === + // Read by `option_env!` in src/licensing.rs, so they are baked into the + // binary at compile time and cannot be set at runtime. A build with no + // verification keys rejects every licence, which is the correct default for + // a source build. + EnvVar { + name: "OPERATOR_LICENSE_PUBLIC_KEYS", + description: "JSON map of key id to base64 Ed25519 public key used to verify Premium licences. Compile-time only", + category: EnvVarCategory::Licensing, + required: false, + default: Some("{}"), + example: Some(r#"{"2026-01":"MCowBQYDK2VwAyEA..."}"#), + }, + EnvVar { + name: "OPERATOR_LICENSE_ISSUER", + description: "Expected `iss` claim on a Premium licence. Compile-time only", + category: EnvVarCategory::Licensing, + required: false, + default: Some("operator-licensing"), + example: Some("operator-licensing"), + }, + EnvVar { + name: "OPERATOR_PURCHASE_URL", + description: "External destination shown by the Premium paywall. Compile-time only", + category: EnvVarCategory::Licensing, + required: false, + default: None, + example: Some("https://operator.untra.io/premium"), + }, // Note: RELAY_HUB_SOCKET and RELAY_AGENT_NAME are intentionally excluded from this // registry because they follow the cross-project claude-relay naming convention // (no OPERATOR_ prefix) for wire compatibility with existing TS relay channels. @@ -436,8 +469,8 @@ mod tests { #[test] fn test_all_categories_in_order() { let all = EnvVarCategory::all(); - assert_eq!(all.len(), 10); + assert_eq!(all.len(), 11); assert_eq!(all[0], EnvVarCategory::Authentication); - assert_eq!(all[9], EnvVarCategory::Logging); + assert_eq!(all[10], EnvVarCategory::Licensing); } } diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index e28ac7c6..f6daf615 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -17,8 +17,29 @@ //! Adding a new vertical entry here, plus its docs page (and README badge for //! `Alpha`+), is all that is required to keep the surfaces aligned. +use crate::config::SessionWrapperType; use crate::integrations::SupportStatus; +pub fn ide_session_wrappers(ide: &str) -> Option<&'static [SessionWrapperType]> { + match ide { + "vscode" => Some(&[SessionWrapperType::Vscode]), + "zed" | "cursor" => Some(&[]), + _ => None, + } +} + +pub fn validate_ide_session(ide: &str, wrapper: SessionWrapperType) -> Result<(), String> { + let wrappers = ide_session_wrappers(ide).ok_or_else(|| format!("Unknown IDE: {ide}"))?; + if wrappers.contains(&wrapper) { + Ok(()) + } else { + Err(format!( + "{} cannot manage sessions in {ide}", + wrapper.display_name() + )) + } +} + /// A top-level advertised vertical. The [`label`](Self::label) matches the /// bolded category in `README.md`'s badge list. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -33,11 +54,14 @@ pub enum Vertical { Integration, Workflows, Notification, + Transport, + AgentRelay, + RemoteTargets, } impl Vertical { /// All verticals, in README display order. - pub const ALL: [Vertical; 10] = [ + pub const ALL: [Vertical; 13] = [ Vertical::Kanban, Vertical::Model, Vertical::Git, @@ -48,6 +72,9 @@ impl Vertical { Vertical::Integration, Vertical::Workflows, Vertical::Notification, + Vertical::Transport, + Vertical::AgentRelay, + Vertical::RemoteTargets, ]; /// Stable lowercase slug (wire id for the REST DTO). @@ -63,6 +90,9 @@ impl Vertical { Vertical::Integration => "integration", Vertical::Workflows => "workflows", Vertical::Notification => "notification", + Vertical::Transport => "transport", + Vertical::AgentRelay => "agent-relay", + Vertical::RemoteTargets => "remote-targets", } } @@ -72,30 +102,36 @@ impl Vertical { Vertical::Kanban => "Kanban Provider", Vertical::Model => "Model Provider", Vertical::Git => "Git Version Control", - Vertical::Session => "Session", - Vertical::Editor => "Editor", + Vertical::Session => "Session Management", + Vertical::Editor => "IDE", Vertical::LlmTool => "LLM Tool", Vertical::Platform => "Platform", Vertical::Integration => "Integration", Vertical::Workflows => "Workflow Export Format", Vertical::Notification => "Notification Channel", + Vertical::Transport => "Execution Transport", + Vertical::AgentRelay => "Agent Relay", + Vertical::RemoteTargets => "Remote Targets", } } /// Docs section directory (site-root-relative) that hosts this vertical's /// entry pages - the sidebar nav item URL and the section `index.md`. - /// `Session` and `Editor` deliberately share one section. pub fn docs_section(&self) -> &'static str { match self { Vertical::Kanban => "getting-started/kanban", Vertical::Model => "getting-started/model-servers", Vertical::Git => "getting-started/git", - Vertical::Session | Vertical::Editor => "getting-started/sessions", + Vertical::Session => "getting-started/sessions", + Vertical::Editor => "getting-started/ides", Vertical::LlmTool => "getting-started/agents", Vertical::Platform => "getting-started/platforms", Vertical::Integration => "getting-started/integrations", Vertical::Workflows => "getting-started/workflows", Vertical::Notification => "getting-started/notifications", + Vertical::Transport => "getting-started/transports", + Vertical::AgentRelay => "getting-started/agent-relays", + Vertical::RemoteTargets => "getting-started/remote-targets", } } } @@ -122,9 +158,19 @@ pub struct CatalogEntry { pub readme_badge: bool, /// Official support / maturity status. pub status: SupportStatus, + pub premium: bool, } impl CatalogEntry { + pub fn is_public(&self) -> bool { + self.status >= SupportStatus::Alpha + } + + fn premium(mut self) -> Self { + self.premium = true; + self + } + /// The absolute docs URL this entry resolves to, if documented. pub fn docs_url(&self) -> Option { self.docs_path @@ -140,11 +186,21 @@ impl CatalogEntry { pub fn all_integrations() -> Vec { use SupportStatus::{Alpha, Beta, Ga, Proto}; use Vertical::{ - Editor, Git, Integration, Kanban, LlmTool, Model, Notification, Platform, Session, - Workflows, + AgentRelay, Editor, Git, Integration, Kanban, LlmTool, Model, Notification, Platform, + RemoteTargets, Session, Transport, Workflows, }; vec![ // --- Kanban providers (mirror KanbanProviderType::ALL) --- + // The built-in board leads the vertical: every other entry syncs into it. + entry( + Kanban, + "operator", + "Operator", + Some("getting-started/kanban/operator"), + Some("operator"), + true, + Ga, + ), entry( Kanban, "jira", @@ -268,7 +324,7 @@ pub fn all_integrations() -> Vec { false, Alpha, ), - // --- Session wrappers (mirror SessionWrapperType::ALL; vscode lives under Editor) --- + // --- Session wrappers (mirror SessionWrapperType::ALL) --- entry( Session, "tmux", @@ -296,12 +352,21 @@ pub fn all_integrations() -> Vec { true, Beta, ), - // --- Editors --- + entry( + Session, + "vscode", + "VS Code Terminals", + Some("getting-started/sessions/vscode-terminals"), + Some("vscode"), + true, + Beta, + ), + // --- IDEs --- entry( Editor, "vscode", "VS Code", - Some("getting-started/sessions/vscode"), + Some("getting-started/ides/vscode"), Some("vscode"), true, Beta, @@ -310,7 +375,7 @@ pub fn all_integrations() -> Vec { Editor, "zed", "Zed", - Some("getting-started/sessions/zed"), + Some("getting-started/ides/zed"), Some("zed"), true, Alpha, @@ -319,7 +384,7 @@ pub fn all_integrations() -> Vec { Editor, "cursor", "Cursor", - Some("getting-started/sessions/cursor"), + Some("getting-started/ides/cursor"), Some("cursor"), false, Proto, @@ -363,14 +428,15 @@ pub fn all_integrations() -> Vec { Beta, ), entry( - Platform, + RemoteTargets, "coder", "Coder", - Some("getting-started/platforms/coder"), + Some("getting-started/remote-targets/coder"), Some("coder"), true, Alpha, - ), + ) + .premium(), entry( Platform, "kubernetes", @@ -380,6 +446,44 @@ pub fn all_integrations() -> Vec { true, Alpha, ), + entry( + Transport, + "local", + "Local", + Some("getting-started/transports/local"), + None, + false, + Alpha, + ), + entry( + Transport, + "ssh", + "SSH", + Some("getting-started/transports/ssh"), + None, + false, + Alpha, + ) + .premium(), + entry( + AgentRelay, + "claude-relay", + "Claude Relay", + Some("getting-started/agent-relays/claude-relay"), + Some("claude"), + false, + Alpha, + ), + entry( + RemoteTargets, + "ssh", + "SSH Hosts", + Some("getting-started/remote-targets/ssh"), + None, + false, + Alpha, + ) + .premium(), // --- Integrations (documented, no README badge row) --- entry( Integration, @@ -441,9 +545,7 @@ pub fn all_integrations() -> Vec { pub fn onboardable(vertical: Vertical) -> Vec { all_integrations() .into_iter() - .filter(|e| { - e.vertical == vertical && e.status >= SupportStatus::Alpha && e.docs_path.is_some() - }) + .filter(|e| e.vertical == vertical && e.is_public() && e.docs_path.is_some()) .collect() } @@ -471,6 +573,7 @@ fn entry( icon, readme_badge, status, + premium: false, } } @@ -483,6 +586,28 @@ mod tests { assert!(!all_integrations().is_empty()); } + #[test] + fn remote_execution_is_premium_independently_of_maturity() { + for entry in all_integrations() + .into_iter() + .filter(|e| e.vertical == Vertical::RemoteTargets) + { + assert!(entry.premium); + assert!(entry.is_public()); + } + assert!(!entry_for(Vertical::Transport, "local").unwrap().premium); + assert!(entry_for(Vertical::Transport, "ssh").unwrap().premium); + assert!(!entry_for(Vertical::Editor, "cursor").unwrap().is_public()); + } + + #[test] + fn ide_controllers_are_not_interchangeable() { + assert!(validate_ide_session("vscode", SessionWrapperType::Vscode).is_ok()); + assert!(validate_ide_session("vscode", SessionWrapperType::Cmux).is_err()); + assert!(validate_ide_session("cursor", SessionWrapperType::Vscode).is_err()); + assert!(validate_ide_session("zed", SessionWrapperType::Tmux).is_err()); + } + #[test] fn test_proto_entries_are_not_badged() { for e in all_integrations() { diff --git a/src/lib.rs b/src/lib.rs index 18618063..eef1c8e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ pub mod collections; pub mod config; pub mod editors; pub mod git; +pub mod licensing; +pub mod profiles; pub mod queue; pub mod rest; pub mod setup; diff --git a/src/licensing.rs b/src/licensing.rs new file mode 100644 index 00000000..9ed290c5 --- /dev/null +++ b/src/licensing.rs @@ -0,0 +1,703 @@ +use std::collections::BTreeMap; +use std::io::Write; +use std::sync::Mutex; + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::config::{Config, TargetDef, TargetKind}; + +pub const PREMIUM_TIER: &str = "premium"; +pub const LICENSE_AUDIENCE: &str = "operator-license"; +pub const LICENSE_VERSION: u32 = 1; +const LICENSE_FILE: &str = "license.key"; +const MAX_LICENSE_BYTES: usize = 32 * 1024; +const PUBLIC_KEY_BYTES: usize = 32; +static LICENSE_UPDATE: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum PremiumFeature { + RemoteTargets, +} + +impl std::fmt::Display for PremiumFeature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RemoteTargets => f.write_str("remote_targets"), + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +#[error("{feature} requires a valid Premium license for this configuration")] +pub struct NotEntitled { + pub feature: PremiumFeature, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, TS)] +#[ts(export)] +pub struct LicenseTerms { + pub version: u32, + pub iss: String, + pub aud: String, + pub sub: String, + pub jti: String, + pub profile_id: Uuid, + pub tier: String, + pub iat: i64, + pub nbf: i64, + pub exp: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum LicenseStatus { + Missing, + Valid, + Expired, + NotYetValid, + Invalid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, TS)] +#[ts(export)] +pub struct LicenseResponse { + pub status: LicenseStatus, + pub profile_id: Uuid, + pub premium: bool, + pub terms: Option, + pub purchase_url: Option, +} + +impl LicenseResponse { + /// The free tier, without reading the filesystem. For constructing + /// fixtures and defaults; real answers come from [`status`]. + #[allow(dead_code)] // Used from test fixtures across both crate targets + pub fn free(profile_id: Uuid) -> Self { + Self { + status: LicenseStatus::Missing, + profile_id, + premium: false, + terms: None, + purchase_url: None, + } + } +} + +pub struct Verifier { + keys: BTreeMap, + issuer: String, +} + +impl Verifier { + /// A verifier over an explicit key set. Enforcement always goes through + /// [`Verifier::bundled`]; this exists so tests and issuing tools can verify + /// against a key that is not compiled in. + #[allow(dead_code)] // Verification seam: used from tests, not the binary + pub fn from_keys(keys: BTreeMap, issuer: String) -> Self { + Self { keys, issuer } + } + + pub fn bundled() -> Result { + Ok(Self { + keys: serde_json::from_str(option_env!("OPERATOR_LICENSE_PUBLIC_KEYS").unwrap_or("{}")) + .context("invalid bundled license verification keys")?, + issuer: option_env!("OPERATOR_LICENSE_ISSUER") + .unwrap_or("operator-licensing") + .to_owned(), + }) + } + + fn cache_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.issuer.hash(&mut hasher); + for (key_id, key) in &self.keys { + key_id.hash(&mut hasher); + key.hash(&mut hasher); + } + hasher.finish() + } + + /// Signature and claim checks. Deliberately time-independent so the result + /// can be memoised; validity against the clock is [`status_for`]. + fn decode(&self, key: &str, profile_id: Uuid) -> Result { + anyhow::ensure!( + !profile_id.is_nil(), + "configuration identity has not been initialized" + ); + anyhow::ensure!(key.len() <= MAX_LICENSE_BYTES, "license is too large"); + let bytes = STANDARD + .decode(key.trim()) + .context("license must be Base64 encoded")?; + let token = std::str::from_utf8(&bytes).context("license is not a JWT")?; + let header = jsonwebtoken::decode_header(token).context("invalid license JWT")?; + anyhow::ensure!( + header.alg == Algorithm::EdDSA, + "unsupported license algorithm" + ); + let encoded = header + .kid + .as_ref() + .and_then(|kid| self.keys.get(kid)) + .context("unknown license signing key")?; + let public = STANDARD + .decode(encoded) + .context("invalid license verification key")?; + anyhow::ensure!( + public.len() == PUBLIC_KEY_BYTES, + "invalid license verification key" + ); + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&[&self.issuer]); + validation.set_audience(&[LICENSE_AUDIENCE]); + validation.set_required_spec_claims(&["exp", "iat", "nbf", "iss", "aud", "sub"]); + validation.validate_exp = false; + validation.validate_nbf = false; + let terms = jsonwebtoken::decode::( + token, + &DecodingKey::from_ed_der(&public), + &validation, + ) + .context("license signature or claims rejected")? + .claims; + anyhow::ensure!( + terms.version == LICENSE_VERSION, + "unsupported license version" + ); + anyhow::ensure!( + terms.profile_id == profile_id, + "license belongs to another configuration" + ); + anyhow::ensure!(terms.tier == PREMIUM_TIER, "unsupported license tier"); + anyhow::ensure!( + !terms.sub.trim().is_empty() && !terms.jti.trim().is_empty(), + "missing license identity" + ); + anyhow::ensure!( + terms.iat >= 0 && terms.nbf >= terms.iat && terms.exp > terms.nbf, + "invalid license validity interval" + ); + Ok(terms) + } + + fn verify( + &self, + key: &str, + profile_id: Uuid, + now: i64, + ) -> Result<(LicenseStatus, LicenseTerms)> { + let terms = self.decode(key, profile_id)?; + Ok((status_for(&terms, now), terms)) + } +} + +/// Where `now` falls relative to the licence's validity interval. +fn status_for(terms: &LicenseTerms, now: i64) -> LicenseStatus { + if now >= terms.exp { + LicenseStatus::Expired + } else if now < terms.nbf || now < terms.iat { + LicenseStatus::NotYetValid + } else { + LicenseStatus::Valid + } +} + +/// What the selected configuration is allowed to do. +/// +/// Derived from the licence on every read, so an expiry that passes while the +/// process runs takes effect without a file change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Entitlements { + pub status: LicenseStatus, + pub premium: bool, +} + +impl Entitlements { + pub fn allows(&self, feature: PremiumFeature) -> bool { + match feature { + PremiumFeature::RemoteTargets => self.premium, + } + } +} + +/// Which configuration's licence a cache entry belongs to. One process can +/// host several configurations, and a licence is pinned to exactly one. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CacheKey { + path: std::path::PathBuf, + profile: Uuid, + verifier: u64, +} + +/// The licence bytes an entry was verified from. A rewrite that changes neither +/// is indistinguishable, which is why writes call [`invalidate`] explicitly. +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileStamp { + modified: Option, + len: u64, +} + +/// The verification outcome, without the time-dependent part. +#[derive(Debug, Clone)] +enum Verified { + Terms(Box), + Rejected, +} + +type Cache = std::collections::HashMap; + +static CACHE: std::sync::RwLock> = std::sync::RwLock::new(None); + +/// Decodes performed rather than served from cache; the memoisation is not +/// otherwise observable. +#[cfg(test)] +static DECODES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +fn cached(key: &CacheKey, stamp: &FileStamp) -> Option { + let guard = CACHE.read().ok()?; + guard + .as_ref()? + .get(key) + .and_then(|(cached, verified)| (cached == stamp).then(|| verified.clone())) +} + +fn store(key: CacheKey, stamp: FileStamp, verified: &Verified) { + if let Ok(mut guard) = CACHE.write() { + guard + .get_or_insert_with(Cache::default) + .insert(key, (stamp, verified.clone())); + } +} + +/// Drop every memoised verification. Called whenever a licence file is written +/// or removed, because filesystem timestamps are too coarse to rely on for a +/// rewrite that lands in the same tick. +fn invalidate() { + if let Ok(mut guard) = CACHE.write() { + *guard = None; + } +} + +/// Entitlements for `config`, using the bundled verification keys. +pub fn entitlements(config: &Config) -> Entitlements { + let response = status(config); + Entitlements { + status: response.status, + premium: response.premium, + } +} + +#[allow(dead_code)] // Used by `entitlements_with`, which the binary never calls +fn entitlements_from(response: &LicenseResponse) -> Entitlements { + Entitlements { + status: response.status, + premium: response.premium, + } +} + +pub fn status(config: &Config) -> LicenseResponse { + match Verifier::bundled() { + Ok(verifier) => status_with(config, &verifier, chrono::Utc::now().timestamp()), + // No bundled keys means no licence can ever verify; report that as + // invalid rather than pretending the file is absent. + Err(_) => rejected(config), + } +} + +/// [`status`] against an explicit verifier and clock. The verification tests and issuing tools; enforcement always uses [`status`]. +pub fn status_with(config: &Config, verifier: &Verifier, now: i64) -> LicenseResponse { + let path = config.state_path().join(LICENSE_FILE); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return missing(config), + Err(_) => return rejected(config), + }; + let cache_key = CacheKey { + path: path.clone(), + profile: config.profile.id, + verifier: verifier.cache_key(), + }; + let stamp = FileStamp { + modified: metadata.modified().ok(), + len: metadata.len(), + }; + + let verified = match cached(&cache_key, &stamp) { + Some(verified) => verified, + None => { + #[cfg(test)] + DECODES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let verified = match std::fs::read_to_string(&path) { + Ok(key) => match verifier.decode(&key, config.profile.id) { + Ok(terms) => Verified::Terms(Box::new(terms)), + Err(_) => Verified::Rejected, + }, + Err(_) => Verified::Rejected, + }; + store(cache_key, stamp, &verified); + verified + } + }; + + match verified { + Verified::Rejected => rejected(config), + Verified::Terms(terms) => { + let status = status_for(&terms, now); + LicenseResponse { + status, + profile_id: config.profile.id, + premium: status == LicenseStatus::Valid, + terms: Some(*terms), + purchase_url: purchase_url(), + } + } + } +} + +/// Entitlements against an explicit verifier and clock. +#[allow(dead_code)] // Verification seam: used from tests, not the binary +pub fn entitlements_with(config: &Config, verifier: &Verifier, now: i64) -> Entitlements { + entitlements_from(&status_with(config, verifier, now)) +} + +fn purchase_url() -> Option { + option_env!("OPERATOR_PURCHASE_URL") + .filter(|url| !url.is_empty()) + .map(str::to_owned) +} + +fn missing(config: &Config) -> LicenseResponse { + LicenseResponse { + status: LicenseStatus::Missing, + profile_id: config.profile.id, + premium: false, + terms: None, + purchase_url: purchase_url(), + } +} + +fn rejected(config: &Config) -> LicenseResponse { + LicenseResponse { + status: LicenseStatus::Invalid, + ..missing(config) + } +} + +pub fn install(config: &Config, key: &str) -> Result { + install_with( + config, + &Verifier::bundled()?, + chrono::Utc::now().timestamp(), + key, + ) +} + +/// [`install`] against an explicit verifier and clock. +/// +/// The replacement is verified before anything is written, so a rejected key +/// leaves the installed licence untouched. +pub fn install_with( + config: &Config, + verifier: &Verifier, + now: i64, + key: &str, +) -> Result { + let _guard = LICENSE_UPDATE + .lock() + .map_err(|_| anyhow::anyhow!("license update lock unavailable"))?; + let (status, _) = verifier.verify(key, config.profile.id, now)?; + anyhow::ensure!( + status == LicenseStatus::Valid, + "license is not currently valid" + ); + persist(config, key.trim())?; + invalidate(); + Ok(status_with(config, verifier, now)) +} + +fn persist(config: &Config, key: &str) -> Result<()> { + let directory = config.state_path(); + std::fs::create_dir_all(&directory)?; + let temporary = directory.join(format!(".license-{}.tmp", Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let result = (|| -> Result<()> { + let mut file = options.open(&temporary)?; + file.write_all(key.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, directory.join(LICENSE_FILE))?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +pub fn remove(config: &Config) -> Result { + let _guard = LICENSE_UPDATE + .lock() + .map_err(|_| anyhow::anyhow!("license update lock unavailable"))?; + match std::fs::remove_file(config.state_path().join(LICENSE_FILE)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + invalidate(); + Ok(status(config)) +} + +pub fn require_premium(config: &Config, feature: PremiumFeature) -> Result<(), NotEntitled> { + if entitlements(config).allows(feature) { + Ok(()) + } else { + Err(NotEntitled { feature }) + } +} + +pub fn require_target(config: &Config, target: &TargetDef) -> Result<(), NotEntitled> { + match target.kind { + TargetKind::Ssh(_) | TargetKind::Coder(_) => { + require_premium(config, PremiumFeature::RemoteTargets) + } + TargetKind::Local | TargetKind::Docker(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The verification cache and its decode counter are process-wide, so the + /// tests that observe them run one at a time. + static SERIAL: Mutex<()> = Mutex::new(()); + + fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + use jsonwebtoken::{EncodingKey, Header}; + use ring::signature::{Ed25519KeyPair, KeyPair}; + + fn fixture() -> (Verifier, EncodingKey, LicenseTerms) { + let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap(); + let verifier = Verifier { + keys: BTreeMap::from([("test".into(), STANDARD.encode(pair.public_key().as_ref()))]), + issuer: "test-issuer".into(), + }; + let terms = LicenseTerms { + version: LICENSE_VERSION, + iss: verifier.issuer.clone(), + aud: LICENSE_AUDIENCE.into(), + sub: "customer".into(), + jti: Uuid::new_v4().to_string(), + profile_id: Uuid::new_v4(), + tier: PREMIUM_TIER.into(), + iat: 100, + nbf: 100, + exp: 200, + }; + (verifier, EncodingKey::from_ed_der(document.as_ref()), terms) + } + + fn sign(encoding: &EncodingKey, terms: &LicenseTerms) -> String { + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some("test".into()); + STANDARD.encode(jsonwebtoken::encode(&header, terms, encoding).unwrap()) + } + + #[test] + fn verifies_signature_identity_and_time_boundaries() { + let (verifier, encoding, terms) = fixture(); + let key = sign(&encoding, &terms); + assert_eq!( + verifier.verify(&key, terms.profile_id, 100).unwrap().0, + LicenseStatus::Valid + ); + assert_eq!( + verifier.verify(&key, terms.profile_id, 99).unwrap().0, + LicenseStatus::NotYetValid + ); + assert_eq!( + verifier.verify(&key, terms.profile_id, 200).unwrap().0, + LicenseStatus::Expired + ); + assert!(verifier.verify(&key, Uuid::new_v4(), 150).is_err()); + let (_, wrong_key, _) = fixture(); + assert!(verifier + .verify(&sign(&wrong_key, &terms), terms.profile_id, 150) + .is_err()); + } + + #[test] + fn rejects_wrong_domain_tier_version_and_malformed_input() { + let (verifier, encoding, terms) = fixture(); + for modified in [ + LicenseTerms { + aud: crate::auth::tokens::AUDIENCE_API.into(), + ..terms.clone() + }, + LicenseTerms { + iss: "attacker".into(), + ..terms.clone() + }, + LicenseTerms { + tier: "unknown".into(), + ..terms.clone() + }, + LicenseTerms { + version: LICENSE_VERSION + 1, + ..terms.clone() + }, + ] { + assert!(verifier + .verify(&sign(&encoding, &modified), terms.profile_id, 150) + .is_err()); + } + assert!(verifier.verify("not-a-key", terms.profile_id, 150).is_err()); + } + + /// Pins today's default: a source build carries no verification keys, so + /// every licence is rejected and Premium is unreachable. `build.rs` keeps + /// that state out of release artifacts; this keeps it from being a mystery + /// when someone hits it locally. + #[test] + fn a_build_with_no_bundled_keys_rejects_every_licence() { + let bundled = Verifier::bundled().expect("bundled key set must parse"); + let (_, encoding, terms) = fixture(); + let signed = sign(&encoding, &terms); + let outcome = bundled.decode(&signed, terms.profile_id); + + if bundled.keys.is_empty() { + let error = outcome.expect_err("no keys means nothing can verify"); + assert!( + error.to_string().contains("unknown license signing key"), + "unexpected rejection: {error}" + ); + } else { + // A build configured with real keys still must not accept a + // licence signed by this test's throwaway key. + assert!(outcome.is_err(), "a foreign key must never verify"); + } + } + + /// The launch path calls this once per gate, seven times per launch; the + /// signature must be verified once, not seven times. + #[test] + fn repeated_reads_verify_the_signature_once() { + use std::sync::atomic::Ordering; + let _serial = serial(); + + let (verifier, encoding, terms) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + config.profile.id = terms.profile_id; + install_with(&config, &verifier, terms.nbf, &sign(&encoding, &terms)).unwrap(); + + invalidate(); + let before = DECODES.load(Ordering::Relaxed); + for _ in 0..8 { + assert!(entitlements_with(&config, &verifier, terms.nbf).premium); + } + assert_eq!( + DECODES.load(Ordering::Relaxed) - before, + 1, + "eight reads must decode the licence once" + ); + } + + /// A licence replaced on disk must take effect immediately: the cache keys + /// on the file, not on the process. + #[test] + fn entitlement_is_recomputed_when_the_licence_changes_on_disk() { + let _serial = serial(); + let (verifier, encoding, terms) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + config.profile.id = terms.profile_id; + + let key = sign(&encoding, &terms); + assert!(install_with(&config, &verifier, 150, &key).is_ok()); + assert!(entitlements_with(&config, &verifier, 150).premium); + + remove(&config).unwrap(); + assert!(!entitlements_with(&config, &verifier, 150).premium); + assert_eq!( + status_with(&config, &verifier, 150).status, + LicenseStatus::Missing + ); + } + + #[test] + fn cached_verification_is_scoped_to_the_verifier() { + let _serial = serial(); + let (verifier, encoding, terms) = fixture(); + let (other_verifier, _, _) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + config.profile.id = terms.profile_id; + + install_with(&config, &verifier, 150, &sign(&encoding, &terms)).unwrap(); + assert!(entitlements_with(&config, &verifier, 150).premium); + assert_eq!( + status_with(&config, &other_verifier, 150).status, + LicenseStatus::Invalid + ); + } + + /// The cache stores verified terms, never the derived boolean: an expiry + /// that passes while the process runs must stop granting entitlement even + /// though the file never changed. + #[test] + fn a_cached_valid_licence_stops_granting_entitlement_once_it_expires() { + let _serial = serial(); + let (verifier, encoding, terms) = fixture(); + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + config.profile.id = terms.profile_id; + + let key = sign(&encoding, &terms); + install_with(&config, &verifier, terms.nbf, &key).unwrap(); + assert!(entitlements_with(&config, &verifier, terms.nbf).premium); + + let expired = entitlements_with(&config, &verifier, terms.exp); + assert!( + !expired.premium, + "an expired licence must not grant premium" + ); + assert_eq!(expired.status, LicenseStatus::Expired); + } + + #[test] + fn missing_license_allows_local_but_denies_remote() { + let _serial = serial(); + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + assert!(require_target(&config, &TargetDef::local()).is_ok()); + assert!(require_premium(&config, PremiumFeature::RemoteTargets).is_err()); + assert!(install(&config, "bad-key").is_err()); + assert_eq!(status(&config).status, LicenseStatus::Missing); + } +} diff --git a/src/main.rs b/src/main.rs index f5c52a97..cbda6336 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,8 @@ mod config; mod editors; mod git; mod issuetypes; +mod licensing; +mod profiles; // Vertical catalog + capability inventory: consumed by the lib's REST/docs // layers and the external parity tests; several items read as unused in the bin. #[allow(dead_code, unused_imports)] @@ -129,6 +131,10 @@ pub struct Cli { #[arg(short, long)] config: Option, + /// Named configuration hosted by this Operator server + #[arg(long, conflicts_with = "config")] + profile: Option, + /// Enable debug logging #[arg(short, long)] debug: bool, @@ -372,7 +378,14 @@ async fn main() -> Result<()> { let cli = Cli::parse(); // Load configuration first (needed for logging setup) - let config = Config::load(cli.config.as_deref())?; + let mut config = if let Some(name) = &cli.profile { + profiles::select(name)? + } else { + Config::load(cli.config.as_deref())? + }; + if !matches!(cli.command, Some(Commands::Docs { .. })) && cli.profile.is_none() { + profiles::register_legacy(&mut config)?; + } // Determine if we're running in TUI mode (no subcommand) let is_tui_mode = cli.command.is_none(); @@ -796,7 +809,6 @@ async fn cmd_import( provider: Option, reference: Option, ) -> Result<()> { - use api::providers::kanban::KanbanProviderType; use services::kanban_sync::KanbanSyncService; let service = KanbanSyncService::new(config); @@ -811,10 +823,8 @@ async fn cmd_import( Some(p) => p.to_lowercase(), }; - if KanbanProviderType::from_slug(&provider).is_none() { - anyhow::bail!( - "Unknown kanban provider: {provider}. Use 'jira', 'linear', 'github', or 'openspec'." - ); + if let Err(message) = api::providers::kanban::validate_sync_source(&provider) { + anyhow::bail!(message); } let collections: Vec<(String, String)> = if let Some(reference) = reference { @@ -919,7 +929,7 @@ async fn cmd_auth(config: &Config, action: AuthAction) -> Result<()> { None => read_password_from_stdin("New admin password: ")?, }; - let store = AuthStore::open(&config.state_path())?; + let store = AuthStore::open(&config.auth_state_path())?; store.set_admin_password(&password)?; // Everything issued under the old password is now suspect: the // reason for a reset is usually that something leaked. @@ -935,7 +945,7 @@ async fn cmd_auth(config: &Config, action: AuthAction) -> Result<()> { } AuthAction::Status { limit } => { - let store = AuthStore::open(&config.state_path())?; + let store = AuthStore::open(&config.auth_state_path())?; println!("Bootstrap state: {:?}", store.bootstrap_state()?); let keys = store.list_access_keys()?; @@ -1162,11 +1172,8 @@ fn cmd_setup( // Validate kanban provider if specified if let Some(ref provider) = kanban_provider { - if api::providers::kanban::KanbanProviderType::from_slug(&provider.to_lowercase()).is_none() - { - anyhow::bail!( - "Unknown kanban provider: {provider}. Use 'jira', 'linear', 'github', or 'openspec'." - ); + if let Err(message) = api::providers::kanban::validate_sync_source(provider) { + anyhow::bail!(message); } } diff --git a/src/mcp/descriptor.rs b/src/mcp/descriptor.rs index c2d39e35..0956ffeb 100644 --- a/src/mcp/descriptor.rs +++ b/src/mcp/descriptor.rs @@ -71,9 +71,11 @@ pub async fn descriptor( State(state): State, Host(host): Host, ) -> Json { - let base = format!("http://{host}"); + let base = super::public_base_url(&state, &host); + let profile_api = super::profile_api_base(&state, &host); + let config = state.config(); - let stdio = if state.config().mcp.stdio_advertised { + let stdio = if config.mcp.stdio_advertised { let command = std::env::current_exe() .ok() .and_then(|p| p.to_str().map(str::to_string)) @@ -82,11 +84,16 @@ pub async fn descriptor( .ok() .and_then(|p| p.to_str().map(str::to_string)) .unwrap_or_default(); - Some(StdioCommand { - command, - args: vec!["mcp".to_string()], - cwd, - }) + let args = if config.profile.id.is_nil() { + vec!["mcp".to_string()] + } else { + vec![ + "--profile".to_string(), + config.profile.name.clone(), + "mcp".to_string(), + ] + }; + Some(StdioCommand { command, args, cwd }) } else { None }; @@ -95,7 +102,7 @@ pub async fn descriptor( server_name: "operator".to_string(), server_id: "operator-mcp".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - transport_url: format!("{base}/api/v1/mcp/sse"), + transport_url: format!("{profile_api}/mcp/sse"), label: "Operator MCP Server".to_string(), openapi_url: Some(format!("{base}/api-docs/openapi.json")), stdio, @@ -142,6 +149,27 @@ mod tests { ); } + #[tokio::test] + async fn descriptor_scopes_transports_to_the_configuration() { + let mut config = Config::default(); + config.profile.id = uuid::Uuid::new_v4(); + config.profile.name = "team-one".to_string(); + config.mcp.stdio_advertised = true; + let profile_id = config.profile.id; + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let response = descriptor(State(state), Host("localhost:7008".to_string())).await; + + assert_eq!( + response.transport_url, + format!("http://localhost:7008/api/v1/profiles/{profile_id}/mcp/sse") + ); + assert_eq!( + response.stdio.as_ref().unwrap().args.as_slice(), + ["--profile", "team-one", "mcp"] + ); + } + #[tokio::test] async fn test_descriptor_stdio_present_when_advertised() { let state = state_with_stdio(true); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index e34c8180..0e558caf 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -51,3 +51,13 @@ pub fn public_base_url(state: &crate::rest::state::ApiState, host: &str) -> Stri .public_base_url() .unwrap_or_else(|| format!("http://{host}")) } + +pub fn profile_api_base(state: &crate::rest::state::ApiState, host: &str) -> String { + let base = public_base_url(state, host); + let profile_id = state.config().profile.id; + if profile_id.is_nil() { + format!("{base}/api/v1") + } else { + format!("{base}/api/v1/profiles/{profile_id}") + } +} diff --git a/src/mcp/tickets.rs b/src/mcp/tickets.rs index fb187606..8f5ddbcc 100644 --- a/src/mcp/tickets.rs +++ b/src/mcp/tickets.rs @@ -5,31 +5,9 @@ use serde_json::{json, Value}; -use crate::queue::{Queue, Ticket}; +use crate::queue::{Queue, Ticket, TicketColumn}; use crate::rest::state::ApiState; - -/// Kanban transitions pushed upstream after a successful local move. -enum KanbanTransition { - Claimed, - Completed, - Requeued, -} - -/// Fire-and-forget: mirror a ticket move to the upstream kanban board. -/// No-op unless bidirectional sync is configured (`state.kanban_sync`). -fn push_kanban_transition(state: &ApiState, ticket: &Ticket, transition: KanbanTransition) { - let Some(ks) = state.kanban_sync.clone() else { - return; - }; - let ticket = ticket.clone(); - tokio::spawn(async move { - match transition { - KanbanTransition::Claimed => ks.on_ticket_claimed(&ticket).await, - KanbanTransition::Completed => ks.on_ticket_completed(&ticket).await, - KanbanTransition::Requeued => ks.on_ticket_requeued(&ticket).await, - } - }); -} +use crate::services::ticket_transitions; fn ticket_to_json(t: &Ticket) -> Value { json!({ @@ -97,16 +75,8 @@ pub async fn claim_ticket(args: Value, state: &ApiState) -> Result Result<(), String> { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - queue.claim_ticket(&ticket).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - push_kanban_transition(state, &ticket_for_push, KanbanTransition::Claimed); + ticket_transitions::move_ticket(state, &ticket, TicketColumn::InProgress).await?; Ok(json!({ "id": id_str, "moved_to": "in-progress" })) } @@ -116,16 +86,8 @@ pub async fn complete_ticket(args: Value, state: &ApiState) -> Result Result<(), String> { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - queue.complete_ticket(&ticket).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - push_kanban_transition(state, &ticket_for_push, KanbanTransition::Completed); + ticket_transitions::move_ticket(state, &ticket, TicketColumn::Completed).await?; Ok(json!({ "id": id_str, "moved_to": "completed" })) } @@ -135,16 +97,8 @@ pub async fn return_to_queue(args: Value, state: &ApiState) -> Result Result<(), String> { - let queue = Queue::new(&config).map_err(|e| e.to_string())?; - queue.return_to_queue(&ticket).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())??; - push_kanban_transition(state, &ticket_for_push, KanbanTransition::Requeued); + ticket_transitions::move_ticket(state, &ticket, TicketColumn::Queue).await?; Ok(json!({ "id": id_str, "moved_to": "queue" })) } diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index 2e49c0a4..e367db7e 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -21,7 +21,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::StreamExt as _; use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; -use crate::mcp::public_base_url; +use crate::mcp::profile_api_base; use crate::rest::middleware::auth::Authenticated; use crate::rest::state::{ApiState, McpSession}; @@ -65,8 +65,8 @@ pub async fn sse_handler( // Generated from the configured public URL, not the request `Host` header, // which a caller controls and which is plain `http` behind TLS termination. - let base = public_base_url(&state, &host); - let message_url = format!("{base}/api/v1/mcp/message?sessionId={session_id}"); + let base = profile_api_base(&state, &host); + let message_url = format!("{base}/mcp/message?sessionId={session_id}"); let session_id_cleanup = session_id.clone(); let sessions_cleanup = Arc::clone(&state.mcp_sessions); diff --git a/src/profiles.rs b/src/profiles.rs new file mode 100644 index 00000000..5a58dbf9 --- /dev/null +++ b/src/profiles.rs @@ -0,0 +1,648 @@ +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::config::Config; +use crate::rest::state::ApiState; + +pub const MAX_PROFILE_NAME_LENGTH: usize = 64; +pub const LEGACY_PROFILE_NAME: &str = "legacy"; +const REGISTRY_FILE: &str = "profiles.sqlite"; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, ToSchema)] +#[ts(export)] +pub struct ProfileIdentity { + pub id: Uuid, + pub name: String, +} + +impl Default for ProfileIdentity { + fn default() -> Self { + Self { + id: Uuid::nil(), + name: LEGACY_PROFILE_NAME.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, ToSchema)] +#[ts(export)] +pub struct ProfileSummary { + pub id: Uuid, + pub name: String, + pub initialized: bool, + pub is_default: bool, +} + +pub fn validate_name(name: &str) -> Result<()> { + anyhow::ensure!( + !name.is_empty() && name.len() <= MAX_PROFILE_NAME_LENGTH + && name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_'), + "Configuration names must contain 1-{MAX_PROFILE_NAME_LENGTH} lowercase letters, digits, - or _" + ); + Ok(()) +} + +pub fn registry_path() -> Result { + Ok(dirs::config_dir() + .context("User configuration directory unavailable")? + .join("operator") + .join(REGISTRY_FILE)) +} + +fn open_registry(path: &Path) -> Result { + std::fs::create_dir_all(path.parent().context("Registry needs a parent directory")?)?; + let connection = Connection::open(path)?; + connection.busy_timeout(std::time::Duration::from_secs(5))?; + connection.execute_batch("CREATE TABLE IF NOT EXISTS profiles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, config_path TEXT NOT NULL UNIQUE); CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);")?; + Ok(connection) +} + +fn absolute_paths(config: &mut Config, root: &Path) { + for value in [ + &mut config.paths.tickets, + &mut config.paths.state, + &mut config.paths.projects, + &mut config.paths.worktrees, + ] { + let path = Path::new(value); + if path.is_relative() { + *value = root.join(path).to_string_lossy().into_owned(); + } + } +} + +pub fn load_registered(path: &Path, registry: &Path) -> Result { + let path = canonical_config_path(path)?; + let input = std::fs::read_to_string(&path)?; + let defaults = serde_json::to_string(&Config::default())?; + let mut config: Config = config::Config::builder() + .add_source(config::File::from_str(&defaults, config::FileFormat::Json)) + .add_source(config::File::from_str(&input, config::FileFormat::Toml)) + .build()? + .try_deserialize()?; + config.config_file = Some(path.clone()); + config.profile_registry = Some(registry.to_path_buf()); + let connection = open_registry(registry)?; + let (id, name): (String, String) = connection + .query_row( + "SELECT id, name FROM profiles WHERE config_path = ?1", + [path.to_string_lossy().as_ref()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .context("Configuration is not registered")?; + config.profile = ProfileIdentity { + id: Uuid::parse_str(&id)?, + name, + }; + config.server_auth_path = connection + .query_row( + "SELECT value FROM settings WHERE key='auth_path'", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .map(PathBuf::from); + absolute_paths( + &mut config, + path.parent().context("Configuration needs a parent")?, + ); + crate::config::validate_targets(&config)?; + Ok(config) +} + +pub fn select(name: &str) -> Result { + validate_name(name)?; + let registry = registry_path()?; + let connection = open_registry(®istry)?; + let path: String = connection + .query_row( + "SELECT config_path FROM profiles WHERE name = ?1", + [name], + |row| row.get(0), + ) + .with_context(|| format!("Unknown configuration '{name}'"))?; + load_registered(Path::new(&path), ®istry) +} + +pub fn rename_registered(config: &mut Config, name: &str) -> Result<()> { + validate_name(name)?; + let registry = config + .profile_registry + .as_deref() + .context("Configuration is not registered")?; + let connection = open_registry(registry)?; + let changed = connection + .execute( + "UPDATE profiles SET name = ?1 WHERE id = ?2", + params![name, config.profile.id.to_string()], + ) + // Names are UNIQUE in the registry; a collision is a user-facing + // conflict, not a database error to be shown verbatim. + .map_err(|error| match error { + rusqlite::Error::SqliteFailure(code, _) + if code.code == rusqlite::ErrorCode::ConstraintViolation => + { + anyhow::anyhow!("Configuration name '{name}' already exists") + } + other => anyhow::Error::new(other), + })?; + anyhow::ensure!(changed == 1, "Configuration is not registered"); + config.profile.name = name.to_owned(); + Ok(()) +} + +/// The registry key for a configuration file. +/// +/// Canonicalising the file only once it exists would key the first run and +/// every later one differently (on macOS `/var` resolves to `/private/var`), +/// registering a second row for the same workspace and minting a new id - which +/// silently invalidates its licence. The parent directory always exists, so +/// canonicalise that and rejoin the file name. +fn canonical_config_path(path: &Path) -> Result { + let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { + return Ok(path.to_path_buf()); + }; + std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + Ok(parent.canonicalize()?.join(name)) +} + +pub fn register_legacy(config: &mut Config) -> Result<()> { + register_in(config, ®istry_path()?) +} + +/// Register `config` in the registry at `registry`, adopting its identity if a +/// row already exists for this config path. Split from [`register_legacy`] so +/// tests can point at a temporary registry instead of the user's own. +fn register_in(config: &mut Config, registry: &Path) -> Result<()> { + let registry = registry.to_path_buf(); + let mut connection = open_registry(®istry)?; + // Immediate, not deferred: every `operator` invocation runs this, so two + // concurrent processes would otherwise both read "no row", both pick the + // same name and one would lose the INSERT to the UNIQUE constraint and + // exit. Taking the write lock up front makes them queue on `busy_timeout`. + let transaction = + connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + absolute_paths(config, &std::env::current_dir()?); + let path = canonical_config_path(&config.operator_config_path_for())?; + config.config_file = Some(path.clone()); + config.profile_registry = Some(registry); + let existing = transaction.query_row( + "SELECT id, name FROM profiles WHERE config_path = ?1", + [path.to_string_lossy().as_ref()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ); + let mut registered = false; + match existing { + Ok((id, name)) => { + config.profile = ProfileIdentity { + id: Uuid::parse_str(&id)?, + name, + } + } + Err(rusqlite::Error::QueryReturnedNoRows) => { + // Always mint: `profile.id` round-trips through config.toml, and the + // licence pins it, so an id arriving from the file is an assertion + // this process is not entitled to trust. + config.profile.id = Uuid::new_v4(); + let mut suffix = 1; + let base = config.profile.name.clone(); + validate_name(&base)?; + while transaction + .query_row( + "SELECT 1 FROM profiles WHERE name = ?1", + [&config.profile.name], + |_| Ok(()), + ) + .is_ok() + { + suffix += 1; + config.profile.name = format!("{base}-{suffix}"); + validate_name(&config.profile.name)?; + } + registered = true; + transaction.execute( + "INSERT INTO profiles (id,name,config_path) VALUES (?1,?2,?3)", + params![ + config.profile.id.to_string(), + config.profile.name, + path.to_string_lossy() + ], + )?; + transaction.execute( + "INSERT OR IGNORE INTO settings (key,value) VALUES ('default_profile',?1)", + [config.profile.id.to_string()], + )?; + } + Err(error) => return Err(error.into()), + } + transaction.commit()?; + if registered { + config.save()?; + } + connection.execute( + "INSERT OR IGNORE INTO settings (key,value) VALUES ('auth_path',?1)", + [config.state_path().to_string_lossy().as_ref()], + )?; + config.server_auth_path = Some(PathBuf::from(connection.query_row( + "SELECT value FROM settings WHERE key='auth_path'", + [], + |row| row.get::<_, String>(0), + )?)); + if config.tickets_path().join("queue").is_dir() + && !crate::startup::workspace_initialized(config) + { + crate::startup::mark_workspace_initialized(config)?; + } + Ok(()) +} + +pub struct ServerProfiles { + registry: Mutex, + registry_path: PathBuf, + states: RwLock>, + primary: ApiState, + default_id: Uuid, +} + +impl ServerProfiles { + pub fn open(primary: ApiState) -> Result> { + let config = primary.config(); + // Always on disk. An in-memory registry would accept a configuration, + // report success, and lose it on restart - worse than refusing. + let registry_path = config + .profile_registry + .clone() + .unwrap_or_else(|| config.state_path().join(REGISTRY_FILE)); + let registry = open_registry(®istry_path)?; + let mut default_id = registry + .query_row( + "SELECT value FROM settings WHERE key='default_profile'", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(|value| Uuid::parse_str(&value).ok()) + .unwrap_or(config.profile.id); + let mut states = std::collections::HashMap::from([(config.profile.id, primary.clone())]); + { + let mut query = registry.prepare("SELECT config_path FROM profiles")?; + for row in query.query_map([], |row| row.get::<_, String>(0))? { + let path = row?; + let loaded = match load_registered(Path::new(&path), ®istry_path) { + Ok(loaded) => loaded, + Err(error) => { + tracing::warn!(config_path = %path, %error, "Registered configuration is unavailable"); + continue; + } + }; + if loaded.profile.id != config.profile.id { + let state = ApiState::with_auth( + loaded.clone(), + loaded.tickets_path(), + Arc::clone(&primary.auth), + ); + states.insert(loaded.profile.id, state); + } + } + } + if !states.contains_key(&default_id) { + default_id = config.profile.id; + registry.execute( + "INSERT INTO settings (key,value) VALUES ('default_profile',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + [default_id.to_string()], + )?; + } + Ok(Arc::new(Self { + registry: Mutex::new(registry), + registry_path, + states: RwLock::new(states), + primary, + default_id, + })) + } + + pub fn default_state(&self) -> ApiState { + self.state(self.default_id) + .unwrap_or_else(|| self.primary.clone()) + } + + pub fn state(&self, id: Uuid) -> Option { + self.states + .read() + .expect("profile registry poisoned") + .get(&id) + .cloned() + } + + pub fn list(&self) -> Vec { + let mut profiles: Vec<_> = self + .states + .read() + .expect("profile registry poisoned") + .values() + .map(|state| self.summary_for(&state.config())) + .collect(); + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + profiles + } + + pub fn summary(&self, config: &Config) -> ProfileSummary { + self.summary_for(config) + } + + fn summary_for(&self, config: &Config) -> ProfileSummary { + ProfileSummary { + id: config.profile.id, + name: config.profile.name.clone(), + initialized: crate::startup::workspace_initialized(config), + is_default: config.profile.id == self.default_id, + } + } + + pub fn create(&self, name: &str) -> Result { + validate_name(name)?; + let registry = self.registry.lock().expect("profile registry poisoned"); + anyhow::ensure!( + !self.list().iter().any(|p| p.name == name), + "Configuration name already exists" + ); + let id = Uuid::new_v4(); + let root = self + .registry_path + .parent() + .context("Missing registry directory")? + .join("profiles") + .join(id.to_string()); + let mut config = Config::default(); + config.profile = ProfileIdentity { + id, + name: name.into(), + }; + config.config_file = Some(canonical_config_path(&root.join("config.toml"))?); + config.profile_registry = Some(self.registry_path.clone()); + config.server_auth_path = Some(self.primary.config().auth_state_path()); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + config.paths.state = root.join("state").to_string_lossy().into_owned(); + config.paths.worktrees = root.join("worktrees").to_string_lossy().into_owned(); + config.paths.projects = self + .primary + .config() + .projects_path() + .to_string_lossy() + .into_owned(); + config.rest_api = self.primary.config().rest_api.clone(); + config.sessions.tmux.socket_name = format!("operator-{id}"); + config.save()?; + registry.execute( + "INSERT INTO profiles (id,name,config_path) VALUES (?1,?2,?3)", + params![ + id.to_string(), + name, + config.operator_config_path_for().to_string_lossy() + ], + )?; + let summary = self.summary_for(&config); + let state = ApiState::with_auth( + config.clone(), + config.tickets_path(), + Arc::clone(&self.primary.auth), + ); + self.states + .write() + .expect("profile registry poisoned") + .insert(id, state); + Ok(summary) + } + + pub async fn rename( + &self, + id: Uuid, + name: String, + ) -> Result { + use crate::rest::error::ApiError; + validate_name(&name).map_err(|e| ApiError::ValidationError(e.to_string()))?; + let state = self + .state(id) + .ok_or_else(|| ApiError::NotFound("Configuration not found".into()))?; + state + .mutate_config(|config| { + let mut registry = self.registry.lock().expect("profile registry poisoned"); + let transaction = registry + .transaction() + .map_err(|e| ApiError::InternalError(e.to_string()))?; + if self.list().iter().any(|p| p.id != id && p.name == name) { + return Err(ApiError::Conflict( + "Configuration name already exists".into(), + )); + } + transaction + .execute( + "UPDATE profiles SET name=?1 WHERE id=?2", + params![name, id.to_string()], + ) + .map_err(|e| ApiError::Conflict(e.to_string()))?; + config.profile.name.clone_from(&name); + // Commit before mutate_config saves: a registry row whose + // config.toml lags is read back correctly next run; the reverse + // loses the rename. + transaction + .commit() + .map_err(|e| ApiError::InternalError(e.to_string()))?; + Ok(self.summary_for(config)) + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_reject_paths_unicode_and_empty_values() { + for value in ["", "../escape", "Upper", "space name", "é", ".", "a/b"] { + assert!(validate_name(value).is_err(), "{value}"); + } + assert!(validate_name("demo_123-test").is_ok()); + assert!(validate_name(&"a".repeat(MAX_PROFILE_NAME_LENGTH + 1)).is_err()); + } + + /// The licence pins `profile_id`, and `config.toml` is user-editable, so + /// the registry - not the file - decides which configuration this is. + /// Otherwise a leaked licence plus a one-line edit defeats the binding. + #[test] + fn a_config_declared_id_cannot_override_the_registry() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + let root = temp.path().join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + + let mut config = Config::default(); + config.paths.state = root.to_string_lossy().into_owned(); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + register_in(&mut config, ®istry).unwrap(); + let assigned = config.profile.id; + assert!(!assigned.is_nil()); + + // Someone edits profile.id in config.toml and restarts. + let mut tampered = Config::default(); + tampered.paths.state = root.to_string_lossy().into_owned(); + tampered.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + tampered.profile.id = Uuid::new_v4(); + register_in(&mut tampered, ®istry).unwrap(); + + assert_eq!( + tampered.profile.id, assigned, + "the registry's id must win over the one declared in config.toml" + ); + } + + /// Renaming onto a name already in use is something a user can do by + /// accident, so it must read as a conflict rather than as a SQLite error. + #[test] + fn renaming_onto_an_existing_name_reports_a_conflict() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + + let mut configs = Vec::new(); + for i in 0..2 { + let root = temp.path().join(format!("workspace-{i}")); + std::fs::create_dir_all(&root).unwrap(); + let mut config = Config::default(); + config.paths.state = root.to_string_lossy().into_owned(); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + register_in(&mut config, ®istry).unwrap(); + configs.push(config); + } + + let taken = configs[0].profile.name.clone(); + let error = rename_registered(&mut configs[1], &taken) + .expect_err("a name already in use must be refused"); + + assert!( + error.to_string().contains("already exists"), + "unexpected error: {error}" + ); + } + + /// The configuration id is what a licence is pinned to, so it must survive + /// restarts. It previously did not: the first run keyed the registry on a + /// non-canonical path (config.toml did not exist yet) and the second on a + /// canonical one, producing a second row and a new id. + #[test] + fn the_configuration_id_is_stable_across_restarts() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + let root = temp.path().join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + + let identity = |()| { + let mut config = Config::default(); + config.paths.state = root.to_string_lossy().into_owned(); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + register_in(&mut config, ®istry).unwrap(); + config.profile + }; + + let first = identity(()); + let second = identity(()); + let third = identity(()); + + assert_eq!(first.id, second.id); + assert_eq!(second.id, third.id); + assert_eq!(first.name, third.name, "a stable id keeps a stable name"); + } + + /// A first registration must mint its own id rather than trust one that + /// arrived in the configuration file. + #[test] + fn a_first_registration_ignores_an_id_supplied_by_the_config_file() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + let root = temp.path().join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + + let planted = Uuid::new_v4(); + let mut config = Config::default(); + config.paths.state = root.to_string_lossy().into_owned(); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + config.profile.id = planted; + register_in(&mut config, ®istry).unwrap(); + + assert_ne!( + config.profile.id, planted, + "a planted id must not become this configuration's identity" + ); + } + + /// Two processes registering at once must both succeed with distinct + /// names: `operator` runs this on every invocation, so a lost race exits + /// the process before it can serve anything. + #[test] + fn concurrent_registration_of_two_configs_assigns_distinct_names() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join(REGISTRY_FILE); + open_registry(®istry).unwrap(); + + let configs: Vec<_> = (0..2) + .map(|i| { + let root = temp.path().join(format!("workspace-{i}")); + std::fs::create_dir_all(&root).unwrap(); + let mut config = Config::default(); + config.paths.state = root.to_string_lossy().into_owned(); + config.paths.tickets = root.join("tickets").to_string_lossy().into_owned(); + config + }) + .collect(); + + let results: Vec<_> = std::thread::scope(|scope| { + let handles: Vec<_> = configs + .into_iter() + .map(|mut config| { + let registry = registry.clone(); + scope + .spawn(move || register_in(&mut config, ®istry).map(|()| config.profile)) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let identities: Vec<_> = results + .into_iter() + .map(|r| r.expect("both registrations must succeed")) + .collect(); + assert_ne!(identities[0].id, identities[1].id); + assert_ne!(identities[0].name, identities[1].name); + } + + #[test] + fn named_profiles_have_isolated_storage_and_survive_reload() { + let temp = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = temp.path().join("state").to_string_lossy().into_owned(); + config.paths.tickets = temp.path().join("tickets").to_string_lossy().into_owned(); + config.profile_registry = Some(temp.path().join(REGISTRY_FILE)); + let primary = ApiState::new(config.clone(), config.tickets_path()); + let profiles = ServerProfiles::open(primary.clone()).unwrap(); + let a = profiles.create("first").unwrap(); + let b = profiles.create("second").unwrap(); + assert_ne!(a.id, b.id); + assert!(!a.initialized); + assert!(profiles.create("first").is_err()); + assert_ne!( + profiles.state(a.id).unwrap().tickets_path, + profiles.state(b.id).unwrap().tickets_path + ); + drop(profiles); + let reopened = ServerProfiles::open(primary).unwrap(); + assert_eq!(reopened.state(a.id).unwrap().config().profile.name, "first"); + } +} diff --git a/src/queue/mod.rs b/src/queue/mod.rs index 63337067..6889a5c1 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -6,7 +6,7 @@ mod ticket; mod watcher; pub use creator::TicketCreator; -pub use ticket::{LlmTask, StepAdvanceResult, Ticket}; +pub use ticket::{LlmTask, StepAdvanceResult, Ticket, TicketPriority, TicketStatus}; pub use watcher::QueueWatcher; use anyhow::{Context, Result}; @@ -16,6 +16,27 @@ use std::path::PathBuf; use crate::config::Config; +/// One of operator's three ticket states, and the directory that holds it. +/// +/// The board column a ticket appears in is a property of which directory it +/// lives in, so this is also the unit an external board transition is keyed on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TicketColumn { + Queue, + InProgress, + Completed, +} + +impl TicketColumn { + pub fn dir_name(&self) -> &'static str { + match self { + TicketColumn::Queue => "queue", + TicketColumn::InProgress => "in-progress", + TicketColumn::Completed => "completed", + } + } +} + pub struct Queue { config: Config, queue_path: PathBuf, @@ -37,7 +58,8 @@ impl Queue { }) } - /// List all tickets in queue, sorted by priority then FIFO + /// List all tickets in queue: issuetype rank, then the ticket's own + /// priority, then FIFO. The web board sorts on the same three keys. pub fn list_by_priority(&self) -> Result> { let mut tickets = self.list_queue()?; @@ -47,6 +69,7 @@ impl Queue { priority_a .cmp(&priority_b) + .then_with(|| a.priority_level().cmp(&b.priority_level())) .then_with(|| a.timestamp.cmp(&b.timestamp)) }); @@ -130,34 +153,67 @@ impl Queue { Ticket::from_file(&path) } - /// Move ticket from queue to in-progress - pub fn claim_ticket(&self, ticket: &Ticket) -> Result<()> { - let src = self.queue_path.join(&ticket.filename); - let dst = self.in_progress_path.join(&ticket.filename); + /// Directory a ticket lives in for one of operator's three states. + fn column_path(&self, column: TicketColumn) -> &std::path::Path { + match column { + TicketColumn::Queue => &self.queue_path, + TicketColumn::InProgress => &self.in_progress_path, + TicketColumn::Completed => &self.completed_path, + } + } - fs::rename(&src, &dst).context("Failed to move ticket to in-progress")?; + /// Where this ticket's file actually is right now. + /// + /// `Ticket::filepath` is authoritative when it still resolves, but a ticket + /// value can outlive the path it was read from (it may have been moved + /// since, or synthesized). Falling back to a filename lookup across the + /// three columns matches how tickets are located everywhere else. + fn locate(&self, ticket: &Ticket) -> Option { + let recorded = PathBuf::from(&ticket.filepath); + if recorded.is_file() { + return Some(recorded); + } + [ + TicketColumn::Queue, + TicketColumn::InProgress, + TicketColumn::Completed, + ] + .into_iter() + .map(|c| self.column_path(c).join(&ticket.filename)) + .find(|p| p.is_file()) + } + /// Move a ticket into `column` from wherever it currently lives. + /// + /// Moving a ticket to the column it is already in is a no-op. + pub fn move_ticket(&self, ticket: &Ticket, column: TicketColumn) -> Result<()> { + let dst = self.column_path(column).join(&ticket.filename); + let src = self + .locate(ticket) + .ok_or_else(|| anyhow::anyhow!("Ticket file not found for '{}'", ticket.filename))?; + if src == dst { + return Ok(()); + } + fs::create_dir_all(self.column_path(column)) + .with_context(|| format!("Failed to create {} directory", column.dir_name()))?; + fs::rename(&src, &dst) + .with_context(|| format!("Failed to move ticket to {}", column.dir_name()))?; Ok(()) } + /// Move ticket from queue to in-progress + pub fn claim_ticket(&self, ticket: &Ticket) -> Result<()> { + self.move_ticket(ticket, TicketColumn::InProgress) + } + /// Move ticket from in-progress to completed pub fn complete_ticket(&self, ticket: &Ticket) -> Result<()> { - let src = self.in_progress_path.join(&ticket.filename); - let dst = self.completed_path.join(&ticket.filename); - - fs::rename(&src, &dst).context("Failed to move ticket to completed")?; - - Ok(()) + self.move_ticket(ticket, TicketColumn::Completed) } /// Move ticket from in-progress back to queue pub fn return_to_queue(&self, ticket: &Ticket) -> Result<()> { - let src = self.in_progress_path.join(&ticket.filename); - let dst = self.queue_path.join(&ticket.filename); - - fs::rename(&src, &dst).context("Failed to move ticket back to queue")?; - - Ok(()) + self.move_ticket(ticket, TicketColumn::Queue) } /// Create a new investigation ticket from an external alert @@ -384,6 +440,31 @@ mod tests { assert_eq!(tickets[4].ticket_type, "SPIKE"); } + /// Within one issuetype the ticket's own `priority:` outranks FIFO, so the + /// launcher and the web board pick the same next ticket. + #[test] + fn test_list_by_priority_breaks_type_ties_by_ticket_priority() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let queue_dir = temp_dir.path().join("queue"); + + let write = |timestamp: &str, project: &str, priority: &str| { + fs::write( + queue_dir.join(format!("{timestamp}-FEAT-{project}-summary.md")), + format!("---\npriority: {priority}\n---\n# FEAT: Test Summary\n"), + ) + .unwrap(); + }; + write("20241231-1000", "older", "P3-low"); + write("20241231-1200", "newer", "P0-critical"); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets[0].project, "newer"); + assert_eq!(tickets[1].project, "older"); + } + #[test] fn test_next_ticket_selection() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index e3375772..700c2d67 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::fs; use std::path::Path; use ts_rs::TS; +use utoipa::ToSchema; use crate::templates::{schema::TemplateSchema, TemplateType}; @@ -39,6 +40,134 @@ pub struct LlmTask { pub blocked_by: Vec, } +/// Ticket urgency, as constrained by `src/schemas/ticket_metadata.schema.json`. +/// +/// Variants are declared most-urgent first, so the derived `Ord` is the sort order. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Default, + Serialize, + Deserialize, + ToSchema, + JsonSchema, + TS, +)] +#[ts(export)] +pub enum TicketPriority { + #[serde(rename = "P0-critical")] + P0Critical, + #[serde(rename = "P1-high")] + P1High, + #[default] + #[serde(rename = "P2-medium")] + P2Medium, + #[serde(rename = "P3-low")] + P3Low, +} + +impl TicketPriority { + /// The frontmatter spelling of this level. + pub fn as_str(self) -> &'static str { + match self { + TicketPriority::P0Critical => "P0-critical", + TicketPriority::P1High => "P1-high", + TicketPriority::P2Medium => "P2-medium", + TicketPriority::P3Low => "P3-low", + } + } + + /// Read a frontmatter value, never failing: a hand-edited ticket must not + /// break the board. `P4-trivial` predates the closed set and folds into low. + pub fn from_frontmatter(raw: &str) -> Self { + let raw = raw.trim().to_lowercase(); + match raw.as_str() { + _ if raw.starts_with("p0") || raw == "critical" => TicketPriority::P0Critical, + _ if raw.starts_with("p1") => TicketPriority::P1High, + _ if raw.starts_with("p3") => TicketPriority::P3Low, + _ if raw.starts_with("p4") || raw == "trivial" || raw == "lowest" => { + TicketPriority::P3Low + } + _ => TicketPriority::P2Medium, + } + } +} + +impl std::fmt::Display for TicketPriority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Workflow status, as constrained by `src/schemas/ticket_metadata.schema.json`. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Default, + Serialize, + Deserialize, + ToSchema, + JsonSchema, + TS, +)] +#[ts(export)] +#[serde(rename_all = "lowercase")] +pub enum TicketStatus { + #[default] + Queued, + Running, + Awaiting, + /// Older API clients still send `done`; `completed` is what we write. + #[serde(alias = "done")] + Completed, +} + +impl TicketStatus { + /// The frontmatter spelling of this status. + pub fn as_str(self) -> &'static str { + match self { + TicketStatus::Queued => "queued", + TicketStatus::Running => "running", + TicketStatus::Awaiting => "awaiting", + TicketStatus::Completed => "completed", + } + } + + /// Recognise a status, including the spellings older tickets and API + /// clients still use. `None` means "not a status we know", which callers + /// bucketing by directory need in order to keep their fallback arm. + pub fn parse(raw: &str) -> Option { + match raw.trim().to_lowercase().as_str() { + "queued" => Some(TicketStatus::Queued), + "running" | "active" => Some(TicketStatus::Running), + "awaiting" | "waiting" | "blocked" => Some(TicketStatus::Awaiting), + "completed" | "done" => Some(TicketStatus::Completed), + _ => None, + } + } + + /// Read a frontmatter value, defaulting the way `Ticket::from_file` does. + pub fn from_frontmatter(raw: &str) -> Self { + Self::parse(raw).unwrap_or(TicketStatus::Queued) + } +} + +impl std::fmt::Display for TicketStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Clone)] pub struct Ticket { pub filename: String, @@ -357,6 +486,11 @@ impl Ticket { .unwrap_or_else(|| self.step.clone()) } + /// The `priority:` frontmatter field as a comparable level. + pub fn priority_level(&self) -> TicketPriority { + TicketPriority::from_frontmatter(&self.priority) + } + /// Advance to the next step in the workflow /// Returns a `StepAdvanceResult` indicating the new step and whether an agent switch is needed pub fn advance_step(&mut self) -> Result { @@ -876,6 +1010,107 @@ fn extract_summary(content: &str) -> String { mod tests { use super::*; + #[test] + fn test_ticket_priority_parses_schema_values() { + for (raw, expected) in [ + ("P0-critical", TicketPriority::P0Critical), + ("P1-high", TicketPriority::P1High), + ("P2-medium", TicketPriority::P2Medium), + ("P3-low", TicketPriority::P3Low), + ] { + assert_eq!(TicketPriority::from_frontmatter(raw), expected); + assert_eq!(expected.as_str(), raw); + } + } + + #[test] + fn test_ticket_priority_unknown_falls_back_to_medium() { + for raw in ["", "urgent", "high", "P9-nonsense"] { + assert_eq!( + TicketPriority::from_frontmatter(raw), + TicketPriority::P2Medium + ); + } + } + + #[test] + fn test_ticket_priority_p4_trivial_parses_as_low() { + for raw in ["P4-trivial", "p4", "trivial", "lowest"] { + assert_eq!(TicketPriority::from_frontmatter(raw), TicketPriority::P3Low); + } + } + + #[test] + fn test_ticket_priority_ord_is_urgency_order() { + let mut levels = vec![ + TicketPriority::P3Low, + TicketPriority::P0Critical, + TicketPriority::P2Medium, + TicketPriority::P1High, + ]; + levels.sort(); + assert_eq!( + levels, + vec![ + TicketPriority::P0Critical, + TicketPriority::P1High, + TicketPriority::P2Medium, + TicketPriority::P3Low, + ] + ); + } + + #[test] + fn test_ticket_priority_serializes_to_schema_values() { + let json = serde_json::to_string(&TicketPriority::P1High).unwrap(); + assert_eq!(json, "\"P1-high\""); + let parsed: TicketPriority = serde_json::from_str("\"P0-critical\"").unwrap(); + assert_eq!(parsed, TicketPriority::P0Critical); + } + + #[test] + fn test_ticket_status_parses_schema_values() { + for (raw, expected) in [ + ("queued", TicketStatus::Queued), + ("running", TicketStatus::Running), + ("awaiting", TicketStatus::Awaiting), + ("completed", TicketStatus::Completed), + ] { + assert_eq!(TicketStatus::parse(raw), Some(expected)); + assert_eq!(expected.as_str(), raw); + } + } + + #[test] + fn test_ticket_status_accepts_done_alias() { + assert_eq!(TicketStatus::parse("done"), Some(TicketStatus::Completed)); + let parsed: TicketStatus = serde_json::from_str("\"done\"").unwrap(); + assert_eq!(parsed, TicketStatus::Completed); + } + + #[test] + fn test_ticket_status_serializes_completed_not_done() { + let json = serde_json::to_string(&TicketStatus::Completed).unwrap(); + assert_eq!(json, "\"completed\""); + } + + #[test] + fn test_ticket_status_maps_legacy_waiting_and_blocked_to_awaiting() { + for raw in ["waiting", "blocked"] { + assert_eq!(TicketStatus::parse(raw), Some(TicketStatus::Awaiting)); + } + assert_eq!(TicketStatus::parse("active"), Some(TicketStatus::Running)); + } + + #[test] + fn test_ticket_status_parse_returns_none_for_unknown() { + assert_eq!(TicketStatus::parse("gibberish"), None); + assert_eq!( + TicketStatus::from_frontmatter("gibberish"), + TicketStatus::Queued + ); + } + #[test] fn test_parse_filename() { let (ts, tt, proj) = diff --git a/src/relay/mod.rs b/src/relay/mod.rs index 03b6d5be..18e9b765 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -9,3 +9,61 @@ #[cfg(unix)] pub use operator_relay::hub; pub use operator_relay::socket_path; + +/// The socket of the hub this process started, if any. +/// +/// Agents need the path in their own environment, but writing it into +/// *Operator's* environment with `set_var` made a launch detail into +/// process-global state - and, under Rust 2024, a data race. The hub is a +/// server-wide resource, so one process-wide slot is the honest shape; each +/// launcher reads it and exports it into the child it spawns. +static ACTIVE_HUB_SOCKET: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Record the hub this process is serving. First writer wins; a second hub +/// would not be reachable at a different path anyway. +pub fn set_active_hub_socket(path: std::path::PathBuf) { + let _ = ACTIVE_HUB_SOCKET.set(path); +} + +/// The hub socket to hand a launched agent. +/// +/// Prefers the hub this process started, then `$RELAY_HUB_SOCKET` so an +/// externally managed claude-relay hub is still found. +pub fn active_hub_socket() -> Option { + if let Some(path) = ACTIVE_HUB_SOCKET.get() { + return Some(path.to_string_lossy().into_owned()); + } + std::env::var("RELAY_HUB_SOCKET").ok() +} + +#[cfg(test)] +mod tests { + /// The hub socket must reach agents through their own environment, never by + /// mutating Operator's. `set_var` here was a process-global write from a + /// launch path, and the thing standing between one server and several + /// configurations sharing one environment. + #[test] + fn the_hub_socket_is_not_written_into_the_process_environment() { + const SOURCES: &[(&str, &str)] = &[ + ("src/app/mod.rs", include_str!("../app/mod.rs")), + ( + "src/agents/launcher/tmux_session.rs", + include_str!("../agents/launcher/tmux_session.rs"), + ), + ( + "src/agents/launcher/cmux_session.rs", + include_str!("../agents/launcher/cmux_session.rs"), + ), + ( + "src/agents/launcher/zellij_session.rs", + include_str!("../agents/launcher/zellij_session.rs"), + ), + ]; + for (name, source) in SOURCES { + assert!( + !source.contains("set_var(\"RELAY_HUB_SOCKET\""), + "{name} writes RELAY_HUB_SOCKET into the process environment" + ); + } + } +} diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index 430c823f..b5b5044f 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; +use crate::queue::{TicketPriority, TicketStatus}; + // ============================================================================= // Health/Status DTOs // ============================================================================= @@ -50,15 +52,15 @@ pub struct KanbanTicketCard { pub ticket_type: String, /// Project name pub project: String, - /// Current status: queued, running, awaiting, completed - pub status: String, + /// Current status + pub status: TicketStatus, /// Current step name pub step: String, /// Human-readable step name #[serde(skip_serializing_if = "Option::is_none")] pub step_display_name: Option, - /// Priority: P0-critical, P1-high, P2-medium, P3-low - pub priority: String, + /// Priority level + pub priority: TicketPriority, /// Timestamp for sorting (YYYYMMDD-HHMM format) pub timestamp: String, /// Ticket markdown filename (joins with the tickets dir + status folder @@ -89,15 +91,14 @@ pub struct KanbanBoardResponse { // Queue Status DTOs // ============================================================================= -/// Ticket counts by type for queue status -#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +/// Ticket counts keyed by issuetype. +/// +/// Issuetypes are an open set defined by collections, so this is a map rather +/// than fixed fields. `BTreeMap` keeps the JSON key order stable. +#[derive(Debug, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] -pub struct QueueByType { - pub inv: usize, - pub fix: usize, - pub feat: usize, - pub spike: usize, -} +#[serde(transparent)] +pub struct QueueByType(pub std::collections::BTreeMap); /// Queue status response with ticket counts #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] @@ -559,9 +560,9 @@ pub struct UpdateTicketStatusResponse { /// Ticket ID pub id: String, /// Previous status before the update - pub previous_status: String, + pub previous_status: TicketStatus, /// New status after the update - pub status: String, + pub status: TicketStatus, /// Human-readable message pub message: String, } @@ -594,10 +595,10 @@ mod tests { summary: "Add thing".to_string(), ticket_type: "FEAT".to_string(), project: "gamesvc".to_string(), - status: "queued".to_string(), + status: TicketStatus::Queued, step: "execute".to_string(), step_display_name: None, - priority: "P2-medium".to_string(), + priority: TicketPriority::P2Medium, timestamp: "20260616-1200".to_string(), }; let json = serde_json::to_string(&card).unwrap(); @@ -612,10 +613,10 @@ mod tests { summary: "Add thing".to_string(), ticket_type: "FEAT".to_string(), project: "gamesvc".to_string(), - status: "queued".to_string(), + status: TicketStatus::Queued, step: "execute".to_string(), step_display_name: Some("Execute".to_string()), - priority: "P2-medium".to_string(), + priority: TicketPriority::P2Medium, timestamp: "20260616-1200".to_string(), }; let json = serde_json::to_string(&card).unwrap(); @@ -629,18 +630,24 @@ mod tests { in_progress: 1, awaiting: 2, completed: 7, - by_type: QueueByType { - inv: 1, - fix: 1, - feat: 1, - spike: 0, - }, + by_type: QueueByType( + [ + ("INV".to_string(), 1), + ("FIX".to_string(), 1), + ("FEAT".to_string(), 1), + ("SPIKE".to_string(), 0), + ("CHORE".to_string(), 2), + ] + .into_iter() + .collect(), + ), }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"by_type\":{")); let parsed: QueueStatusResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.by_type.inv, 1); - assert_eq!(parsed.by_type.spike, 0); + assert_eq!(parsed.by_type.0.get("INV"), Some(&1)); + assert_eq!(parsed.by_type.0.get("SPIKE"), Some(&0)); + assert_eq!(parsed.by_type.0.get("CHORE"), Some(&2)); assert_eq!(parsed.completed, 7); } diff --git a/src/rest/dto/integrations.rs b/src/rest/dto/integrations.rs index 53011974..718b2e30 100644 --- a/src/rest/dto/integrations.rs +++ b/src/rest/dto/integrations.rs @@ -32,12 +32,17 @@ pub struct IntegrationCatalogEntryDto { pub readme_badge: bool, /// Official support / maturity status. pub status: SupportStatus, + pub premium: bool, + /// Implemented session controllers for an IDE; absent for other categories. + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrappers: Option>, } /// Project the catalog source-of-truth into wire DTOs. pub fn integration_catalog() -> Vec { all_integrations() .into_iter() + .filter(crate::integrations::catalog::CatalogEntry::is_public) .map(|e| IntegrationCatalogEntryDto { vertical: e.vertical.slug().to_string(), vertical_label: e.vertical.label().to_string(), @@ -46,6 +51,16 @@ pub fn integration_catalog() -> Vec { docs_url: e.docs_url(), readme_badge: e.readme_badge, status: e.status, + premium: e.premium, + session_wrappers: (e.vertical == crate::integrations::Vertical::Editor) + .then(|| crate::integrations::catalog::ide_session_wrappers(e.slug)) + .flatten() + .map(|wrappers| { + wrappers + .iter() + .map(|wrapper| wrapper.display_name().to_string()) + .collect() + }), }) .collect() } @@ -57,7 +72,10 @@ mod tests { #[test] fn test_integration_catalog_projects_all_entries() { let dtos = integration_catalog(); - assert_eq!(dtos.len(), all_integrations().len()); + assert_eq!( + dtos.len(), + all_integrations().iter().filter(|e| e.is_public()).count() + ); let jira = dtos.iter().find(|d| d.slug == "jira").unwrap(); assert_eq!(jira.vertical, "kanban"); assert_eq!(jira.status, SupportStatus::Beta); @@ -68,10 +86,11 @@ mod tests { } #[test] - fn test_proto_entry_has_no_docs_url() { + fn test_proto_entries_are_not_public() { let dtos = integration_catalog(); - let lmstudio = dtos.iter().find(|d| d.slug == "lmstudio").unwrap(); - assert!(lmstudio.docs_url.is_none()); - assert!(!lmstudio.readme_badge); + assert!(dtos.iter().all(|d| d.status != SupportStatus::Proto)); + assert!(dtos + .iter() + .any(|d| d.vertical == "remote-targets" && d.premium)); } } diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index 1bcc81de..4a19c6e2 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -95,7 +95,10 @@ pub struct KanbanProviderCatalogEntry { // Kanban Onboarding DTOs // ============================================================================= -/// Which kanban provider an onboarding request targets. +/// Which external kanban provider an onboarding request targets. +/// Deliberately one variant smaller than [`KanbanProviderType`] +/// +/// [`KanbanProviderType`]: crate::api::providers::kanban::KanbanProviderType #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS, PartialEq, Eq)] #[ts(export)] #[serde(rename_all = "lowercase")] @@ -106,6 +109,20 @@ pub enum KanbanProviderKind { Openspec, } +impl KanbanProviderKind { + /// Every provider a client can submit credentials for, in catalog order. + /// + /// Exists for the parity guard below; the runtime surfaces are driven by + /// `KanbanProviderType::ALL` and the serde tags on this enum. + #[cfg(test)] + pub const ALL: [KanbanProviderKind; 4] = [ + KanbanProviderKind::Jira, + KanbanProviderKind::Linear, + KanbanProviderKind::Github, + KanbanProviderKind::Openspec, + ]; +} + /// Ephemeral Jira credentials supplied by a client during onboarding. /// /// These are never persisted to disk by the onboarding endpoints that take @@ -541,6 +558,35 @@ mod tests { assert!(!json.contains("\"github\":")); } + /// The onboarding surface must stay a projection of the provider catalog: + /// every connectable provider appears here. + #[test] + fn test_onboarding_kinds_cover_every_non_builtin_provider() { + use crate::api::providers::kanban::KanbanProviderType; + + let connectable: Vec<&str> = KanbanProviderType::ALL + .into_iter() + .filter(|p| !p.is_builtin()) + .map(|p| p.slug()) + .collect(); + let kinds: Vec = KanbanProviderKind::ALL + .iter() + .map(|k| { + serde_json::to_value(k) + .unwrap() + .as_str() + .unwrap() + .to_string() + }) + .collect(); + + assert_eq!(connectable, kinds); + assert_eq!( + KanbanProviderType::ALL.len(), + KanbanProviderKind::ALL.len() + 1 + ); + } + #[test] fn test_list_statuses_request_deserializes_with_one_provider_body() { let json = r#"{ diff --git a/src/rest/error.rs b/src/rest/error.rs index 66b9803f..803c0308 100644 --- a/src/rest/error.rs +++ b/src/rest/error.rs @@ -42,6 +42,7 @@ pub enum ApiError { /// A cookie-authenticated mutation arrived without a valid CSRF token or /// with a mismatched `Origin`. CsrfFailed(String), + NotEntitled(crate::licensing::NotEntitled), } /// Error response body @@ -49,6 +50,10 @@ pub enum ApiError { pub struct ErrorResponse { pub error: String, pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub feature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_tier: Option, } impl ApiError { @@ -67,17 +72,28 @@ impl ApiError { ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg), ApiError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg), ApiError::CsrfFailed(msg) => (StatusCode::FORBIDDEN, "csrf_failed", msg), + ApiError::NotEntitled(error) => ( + StatusCode::PAYMENT_REQUIRED, + "not_entitled", + error.to_string(), + ), } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { + let feature = match &self { + Self::NotEntitled(error) => Some(error.feature), + _ => None, + }; let (status, error, message) = self.parts(); let body = Json(ErrorResponse { error: error.to_string(), message, + feature, + required_tier: feature.map(|_| crate::licensing::PREMIUM_TIER.to_owned()), }); // Only a 401 carries a challenge. A 403 means the credential was @@ -110,7 +126,16 @@ impl From for ApiError { impl From for ApiError { fn from(err: anyhow::Error) -> Self { - ApiError::InternalError(err.to_string()) + match err.downcast_ref::() { + Some(error) => Self::NotEntitled(error.clone()), + None => Self::InternalError(err.to_string()), + } + } +} + +impl From for ApiError { + fn from(error: crate::licensing::NotEntitled) -> Self { + Self::NotEntitled(error) } } @@ -119,6 +144,28 @@ mod tests { use super::*; use http_body_util::BodyExt; + #[tokio::test] + async fn entitlement_denial_has_distinct_status_and_feature() { + let error = crate::licensing::NotEntitled { + feature: crate::licensing::PremiumFeature::RemoteTargets, + }; + let response = + ApiError::from(anyhow::Error::new(error).context("launch rejected")).into_response(); + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + assert!(response.headers().get(header::WWW_AUTHENTICATE).is_none()); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(body.error, "not_entitled"); + assert_eq!( + body.feature, + Some(crate::licensing::PremiumFeature::RemoteTargets) + ); + assert_eq!( + body.required_tier.as_deref(), + Some(crate::licensing::PREMIUM_TIER) + ); + } + #[tokio::test] async fn test_not_found_response() { let error = ApiError::NotFound("Type 'FOO' not found".to_string()); diff --git a/src/rest/middleware/auth.rs b/src/rest/middleware/auth.rs index 543a0bd5..fb47bf74 100644 --- a/src/rest/middleware/auth.rs +++ b/src/rest/middleware/auth.rs @@ -113,6 +113,11 @@ pub async fn resolve_principal(state: &ApiState, headers: &HeaderMap) -> Option< // audience, so it can never satisfy an ordinary API route; the handler // additionally matches its ticket/step claims against the request path. if let Ok(claims) = state.auth.signing_key.verify(&token, AUDIENCE_CALLBACK) { + if claims.profile_id != Some(state.config().profile.id) + && !(claims.profile_id.is_none() && state.config().profile.id.is_nil()) + { + return None; + } return Some(Principal { subject: claims.sub.clone(), scopes: claims.scopes(), diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 9f15a35e..8ef642b7 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -24,6 +24,7 @@ pub mod dto; pub mod error; pub mod middleware; pub mod openapi; +mod profile_router; pub mod routes; pub mod server; pub mod state; @@ -95,6 +96,28 @@ fn auth_router() -> OpenApiRouter { .routes(routes!(routes::auth::revoke_access_key)) } +/// Configuration registry, licence and remote-target routes. +/// +/// Split out of [`documented_router`] for the same reason as [`auth_router`]: +/// this is the entitlement-relevant subset, where every mutation either changes +/// what the installation is licensed for or what it can execute on. +fn premium_router() -> OpenApiRouter { + OpenApiRouter::new() + // Configuration registry. + .routes(routes!(routes::profiles::list, routes::profiles::create)) + .routes(routes!(routes::profiles::get_one, routes::profiles::rename)) + // Licence. + .routes(routes!( + routes::license::get, + routes::license::install, + routes::license::remove + )) + // Remote targets. + .routes(routes!(routes::targets::list, routes::targets::create)) + .routes(routes!(routes::targets::update, routes::targets::remove)) + .routes(routes!(routes::targets::probe)) +} + fn first_run_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(routes::setup::status)) @@ -121,6 +144,7 @@ fn first_run_router() -> OpenApiRouter { fn documented_router() -> OpenApiRouter { OpenApiRouter::with_openapi(ApiDoc::openapi()) .merge(auth_router()) + .merge(premium_router()) // Health endpoints .routes(routes!(routes::health::health)) .routes(routes!(routes::health::status)) @@ -302,6 +326,13 @@ fn cors_layer(config: &crate::config::Config) -> CorsLayer { } pub fn build_router(state: ApiState) -> Router { + let profiles = crate::profiles::ServerProfiles::open(state) + .expect("configuration registry must be available"); + let default = profiles.default_state(); + profile_router::mount(build_profile_router(default), profiles) +} + +pub(crate) fn build_profile_router(state: ApiState) -> Router { let config = state.config(); let cors = cors_layer(&config); @@ -320,13 +351,7 @@ pub fn build_router(state: ApiState) -> Router { } // Swagger UI and its spec are merged in here, and the SPA fallback is - // registered here, so that the auth layer below covers both. - // - // Ordering is load-bearing: `Router::fallback` registered *after* `.layer` - // is not wrapped by that layer. With the fallback added last, an unknown - // `/api/...` path bypassed authorization entirely and was answered with the - // SPA shell instead of a 401 - which also meant a route mounted without a - // `ROUTE_RULES` entry would silently serve HTML rather than fail closed. + // registered here, so that the auth layer below covers both. Order matters: `Router::fallback` registered *after* `.layer` let router = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_spec())); @@ -351,7 +376,8 @@ pub fn build_router(state: ApiState) -> Router { /// Start the REST API server (standalone mode with session file and logging) pub async fn serve(state: ApiState, port: u16) -> Result<()> { let tickets_path = state.tickets_path.clone(); - let state_path = state.config().state_path(); + let state_path = state.config().auth_state_path(); + let profile_id = state.config().profile.id; let host_ip = state.config().rest_api.host_ip(); let app = build_router(state.clone()); let addr = SocketAddr::new(host_ip, port); @@ -360,7 +386,7 @@ pub async fn serve(state: ApiState, port: u16) -> Result<()> { tracing::info!("Swagger UI available at http://{}/swagger-ui", addr); // Write session file for client discovery - write_session_file(&tickets_path, &state_path, port)?; + write_session_file(&tickets_path, &state_path, port, profile_id)?; let listener = tokio::net::TcpListener::bind(addr).await?; @@ -380,6 +406,7 @@ fn write_session_file( tickets_path: &std::path::Path, state_path: &std::path::Path, port: u16, + profile_id: uuid::Uuid, ) -> Result<()> { let operator_dir = tickets_path.join("operator"); std::fs::create_dir_all(&operator_dir)?; @@ -387,6 +414,7 @@ fn write_session_file( let session_file = operator_dir.join("api-session.json"); let session = ApiSessionInfo { port, + profile_id, pid: std::process::id(), started_at: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), diff --git a/src/rest/profile_router.rs b/src/rest/profile_router.rs new file mode 100644 index 00000000..95aab638 --- /dev/null +++ b/src/rest/profile_router.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, Request, State}, + response::{IntoResponse, Response}, + Router, +}; +use tower::ServiceExt; +use uuid::Uuid; + +use super::error::ApiError; +use crate::profiles::ServerProfiles; + +pub fn is_server_path(path: &str) -> bool { + path.starts_with("/api/v1/auth/") + || path == "/api/v1/profiles" + || path.starts_with("/api/v1/profiles/") + || path == "/api/v1/integrations" + || path == "/api/v1/health" +} + +pub fn mount(default: Router, profiles: Arc) -> Router { + Router::new() + .route( + "/api/v1/profiles/{profile_id}/{*path}", + axum::routing::any(dispatch), + ) + .with_state(Arc::clone(&profiles)) + .merge(default.layer(axum::Extension(profiles))) +} + +async fn dispatch( + State(profiles): State>, + Path((id, path)): Path<(Uuid, String)>, + request: Request, +) -> Response { + let Some(state) = profiles.state(id) else { + return ApiError::NotFound("Configuration not found".into()).into_response(); + }; + let path = format!("/api/v1/{path}"); + if is_server_path(&path) { + return ApiError::NotFound("Use the server-level route for this operation".into()) + .into_response(); + } + let (mut parts, body) = request.into_parts(); + let uri = match parts.uri.query() { + Some(query) => format!("{path}?{query}"), + None => path, + }; + let Ok(uri) = uri.parse() else { + return ApiError::BadRequest("Invalid configuration route".into()).into_response(); + }; + parts.uri = uri; + parts.extensions.clear(); + let request = Request::from_parts(parts, body); + let router = super::build_profile_router(state); + match router.oneshot(request).await { + Ok(response) => response, + Err(never) => match never {}, + } +} diff --git a/src/rest/routes/integrations.rs b/src/rest/routes/integrations.rs index edcc0ba8..ada64328 100644 --- a/src/rest/routes/integrations.rs +++ b/src/rest/routes/integrations.rs @@ -11,8 +11,8 @@ use crate::rest::dto::{integration_catalog, IntegrationCatalogEntryDto}; /// GET `/api/v1/integrations` /// /// Returns the catalog of advertised integrations across every vertical, each -/// with its docs link and official support status (`proto` | `alpha` | `beta` | -/// `ga`). +/// with its docs link, Premium availability, and support status (`alpha` | `beta` | +/// `ga`). Prototype entries are not advertised. #[utoipa::path( get, path = "/api/v1/integrations", diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index b55e8a3b..fc2e1fe7 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -22,6 +22,8 @@ fn build_provider_catalog(kanban: &KanbanConfig) -> Vec true, KanbanProviderType::Jira => !kanban.jira.is_empty(), KanbanProviderType::Linear => !kanban.linear.is_empty(), KanbanProviderType::Github => !kanban.github.is_empty(), @@ -273,7 +275,10 @@ mod tests { let catalog = build_provider_catalog(&kanban); let slugs: Vec<&str> = catalog.iter().map(|e| e.slug.as_str()).collect(); - assert_eq!(slugs, vec!["jira", "linear", "github", "openspec"]); + assert_eq!( + slugs, + vec!["operator", "jira", "linear", "github", "openspec"] + ); let github = catalog.iter().find(|e| e.slug == "github").unwrap(); assert!(github.configured); @@ -290,6 +295,12 @@ mod tests { let openspec = catalog.iter().find(|e| e.slug == "openspec").unwrap(); assert!(!openspec.configured); assert_eq!(openspec.display_name, "OpenSpec"); + + // The built-in board leads the list and is configured out of the box. + let operator = catalog.first().unwrap(); + assert_eq!(operator.slug, "operator"); + assert!(operator.configured); + assert_eq!(operator.display_name, "Operator"); } #[test] diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 3d826c2d..29929e5c 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -151,6 +151,7 @@ fn prepared_launch_to_response(prepared: PreparedLaunch) -> LaunchTicketResponse responses( (status = 200, description = "Ticket launched successfully", body = LaunchTicketResponse), (status = 404, description = "Ticket not found"), + (status = 402, description = "Premium required for the selected execution target"), (status = 409, description = "Ticket already in progress"), (status = 503, description = "Operator is draining"), (status = 400, description = "Invalid request") @@ -244,13 +245,13 @@ async fn launch_admitted_ticket( let prepared = launcher .prepare_relaunch(&ticket, relaunch_options) .await - .map_err(|e| ApiError::InternalError(e.to_string()))?; + .map_err(ApiError::from)?; prepared_launch_to_response(prepared) } else { launcher .relaunch(&ticket, relaunch_options) .await - .map_err(|e| ApiError::InternalError(e.to_string()))?; + .map_err(ApiError::from)?; server_side_response(&state, &ticket)? } } else { @@ -261,13 +262,13 @@ async fn launch_admitted_ticket( let prepared = launcher .prepare_launch(&ticket, launch_options) .await - .map_err(|e| ApiError::InternalError(e.to_string()))?; + .map_err(ApiError::from)?; prepared_launch_to_response(prepared) } else { launcher .launch_with_options(&ticket, launch_options) .await - .map_err(|e| ApiError::InternalError(e.to_string()))?; + .map_err(ApiError::from)?; server_side_response(&state, &ticket)? } }; @@ -417,6 +418,30 @@ fn build_next_step_command( let app_state = crate::state::State::load(config)?; let agent = find_completing_agent(&app_state.agents, &ticket.id, request.session_id.as_deref()); + if let Some(agent) = agent { + let remote_mode = agent + .launch_mode + .as_deref() + .map(crate::agents::launcher::parse_launch_mode) + .is_some_and(|mode| { + matches!( + mode.kind, + crate::agents::launcher::LaunchModeKind::Ssh + | crate::agents::launcher::LaunchModeKind::Coder + ) + }); + if remote_mode || agent.remote_host.is_some() { + crate::licensing::require_premium( + config, + crate::licensing::PremiumFeature::RemoteTargets, + )?; + } + if let Some(name) = &agent.target_name { + let target = crate::agents::delegator_resolution::resolve_named_target(config, name)?; + crate::licensing::require_target(config, &target)?; + } + } + let ctx = agent .and_then(|a| a.step_launch_context.clone()) .unwrap_or_else(|| StepLaunchContext { @@ -669,6 +694,7 @@ async fn run_proof_review_hook( responses( (status = 200, description = "Step completion recorded", body = StepCompleteResponse), (status = 404, description = "Ticket not found"), + (status = 402, description = "Premium required for the next execution target"), (status = 400, description = "Invalid request") ) )] @@ -841,7 +867,7 @@ pub async fn complete_step( Ok(Json(StepCompleteResponse { status, next_step: next_step_info, - auto_proceed, + auto_proceed: auto_proceed && next_command.is_some(), next_command, output_valid, should_iterate, @@ -1637,6 +1663,67 @@ mod tests { .and_then(|c| c.session_id.clone()) } + /// Workflow continuity: an agent that finishes a step on a remote host + /// while the licence is gone must still have its completion recorded. What + /// stops is the *next* command - never the report of work already done. + #[tokio::test] + async fn completion_is_recorded_when_entitlement_is_unavailable() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9100", "scan"); + let agent_id = add_chain_agent(&fixture.state, &ticket, "opus", "session-remote"); + State::mutate(&fixture.state.config(), |app_state| { + app_state + .update_agent_remote_host(&agent_id, "build-host") + .unwrap(); + }) + .unwrap(); + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "scan".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), + Json(make_chain_complete_request("session-remote")), + ) + .await + .expect("the completion report itself must be accepted") + .0; + + assert!( + !response.auto_proceed, + "an unentitled continuation must not claim it is proceeding" + ); + assert!( + response.next_command.is_none(), + "no next command may be issued without entitlement" + ); + assert!( + response.output_valid, + "the work that was done is still reported as done" + ); + } + + /// The same agent, once entitled, does proceed - so the assertion above is + /// about entitlement and not about some unrelated reason to stop. + #[tokio::test] + async fn the_same_local_agent_does_proceed() { + let fixture = make_chain_fixture(); + let ticket = write_sync_ticket(&fixture.state, "SYNC-9101", "scan"); + add_chain_agent(&fixture.state, &ticket, "opus", "session-local"); + + let response = complete_step( + State(fixture.state.clone()), + Path((ticket.id.clone(), "scan".to_string())), + Authenticated(crate::auth::scope::Principal::local("admin")), + Json(make_chain_complete_request("session-local")), + ) + .await + .unwrap() + .0; + + assert!(response.auto_proceed); + assert!(response.next_command.is_some()); + } + #[tokio::test] async fn test_complete_step_next_command_uses_persisted_context() { let fixture = make_chain_fixture(); diff --git a/src/rest/routes/license.rs b/src/rest/routes/license.rs new file mode 100644 index 00000000..1c33dbf6 --- /dev/null +++ b/src/rest/routes/license.rs @@ -0,0 +1,37 @@ +use axum::{extract::State, Json}; +use serde::Deserialize; +use utoipa::ToSchema; + +use crate::licensing::{self, LicenseResponse}; +use crate::rest::{error::ApiError, state::ApiState}; + +#[derive(Deserialize, ToSchema)] +pub struct InstallLicenseRequest { + pub license_key: String, +} + +#[utoipa::path(get, path = "/api/v1/license", + operation_id = "license_get", tag = "License", responses((status = 200, body = LicenseResponse)))] +pub async fn get(State(state): State) -> Json { + Json(licensing::status(&state.config())) +} + +#[utoipa::path(put, path = "/api/v1/license", + operation_id = "license_install", tag = "License", request_body = InstallLicenseRequest, + responses((status = 200, body = LicenseResponse), (status = 400, description = "License rejected")))] +pub async fn install( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + licensing::install(&state.config(), &request.license_key) + .map(Json) + .map_err(|error| ApiError::ValidationError(error.to_string())) +} + +#[utoipa::path(delete, path = "/api/v1/license", + operation_id = "license_remove", tag = "License", responses((status = 200, body = LicenseResponse)))] +pub async fn remove(State(state): State) -> Result, ApiError> { + licensing::remove(&state.config()) + .map(Json) + .map_err(ApiError::from) +} diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 6603bc3b..6c8bfe2c 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -12,14 +12,17 @@ pub mod issuetypes; pub mod kanban; pub mod kanban_onboarding; pub mod launch; +pub mod license; pub mod llm_tools; pub mod model_servers; pub mod probes; +pub mod profiles; pub mod projects; pub mod queue; pub mod sections; pub mod setup; pub mod skills; pub mod steps; +pub mod targets; pub mod tickets; pub mod workflow; diff --git a/src/rest/routes/profiles.rs b/src/rest/routes/profiles.rs new file mode 100644 index 00000000..a0451f5b --- /dev/null +++ b/src/rest/routes/profiles.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use axum::{extract::Path, Extension, Json}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::profiles::{ProfileSummary, ServerProfiles}; +use crate::rest::error::ApiError; + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ProfileNameRequest { + pub name: String, +} + +#[utoipa::path(get, path = "/api/v1/profiles", + operation_id = "profiles_list", tag = "Configuration", responses((status=200, description="Server configurations", body=Vec)))] +pub async fn list( + Extension(profiles): Extension>, +) -> Json> { + Json(profiles.list()) +} + +#[utoipa::path(post, path = "/api/v1/profiles", + operation_id = "profiles_create", tag = "Configuration", request_body=ProfileNameRequest, responses((status=200, description="Draft configuration", body=ProfileSummary), (status=409, description="Name already exists")))] +pub async fn create( + Extension(profiles): Extension>, + Json(request): Json, +) -> Result, ApiError> { + crate::profiles::validate_name(&request.name) + .map_err(|e| ApiError::ValidationError(e.to_string()))?; + if profiles + .list() + .iter() + .any(|profile| profile.name == request.name) + { + return Err(ApiError::Conflict( + "Configuration name already exists".into(), + )); + } + profiles + .create(&request.name) + .map(Json) + .map_err(|e| ApiError::Conflict(e.to_string())) +} + +#[utoipa::path(patch, path = "/api/v1/profiles/{profile_id}", + operation_id = "profiles_rename", tag = "Configuration", params(("profile_id"=Uuid, Path, description="Configuration ID")), request_body=ProfileNameRequest, responses((status=200, description="Renamed configuration", body=ProfileSummary)))] +pub async fn rename( + Extension(profiles): Extension>, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + profiles.rename(id, request.name).await.map(Json) +} + +#[utoipa::path(get, path = "/api/v1/profiles/{profile_id}", + operation_id = "profiles_get", tag = "Configuration", params(("profile_id"=Uuid, Path, description="Configuration ID")), responses((status=200, description="Configuration metadata", body=ProfileSummary)))] +pub async fn get_one( + Extension(profiles): Extension>, + Path(id): Path, +) -> Result, ApiError> { + profiles + .state(id) + .map(|state| Json(profiles.summary(&state.config()))) + .ok_or_else(|| ApiError::NotFound("Configuration not found".into())) +} diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index c86f89bd..51ca7172 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -10,7 +10,8 @@ use axum::{ }; use chrono::Utc; -use crate::queue::{Queue, Ticket}; +use crate::config::Config; +use crate::queue::{Queue, Ticket, TicketStatus}; use crate::rest::dto::{ KanbanBoardResponse, KanbanSyncResponse, KanbanTicketCard, QueueByType, QueueControlResponse, QueueStatusResponse, @@ -26,19 +27,32 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { summary: ticket.summary.clone(), ticket_type: ticket.ticket_type.clone(), project: ticket.project.clone(), - status: ticket.status.clone(), + status: TicketStatus::from_frontmatter(&ticket.status), step: ticket.step.clone(), step_display_name: ticket.current_step_display_name().into(), - priority: ticket.priority.clone(), + priority: ticket.priority_level(), timestamp: ticket.timestamp.clone(), filename: ticket.filename.clone(), } } +/// Order one active column the way the launcher orders the queue: issuetype +/// rank from `queue.priority_order`, then the ticket's own priority, then FIFO. +fn sort_active_column(config: &Config, column: &mut [KanbanTicketCard]) { + column.sort_by(|a, b| { + config + .priority_index(&a.ticket_type) + .cmp(&config.priority_index(&b.ticket_type)) + .then_with(|| a.priority.cmp(&b.priority)) + .then_with(|| a.timestamp.cmp(&b.timestamp)) + }); +} + /// Get kanban board data with tickets grouped by status column /// /// Returns tickets organized into four columns: queue, running, awaiting, done. -/// Tickets are sorted by priority within each column, then by timestamp (FIFO). +/// Active columns follow `queue.priority_order`, then the ticket's `priority:` +/// field, then timestamp (FIFO). The done column is newest first. #[utoipa::path( operation_id = "queue_kanban", get, @@ -50,7 +64,8 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { )] pub async fn kanban(State(state): State) -> Result, ApiError> { // Create a queue from the config - let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; + let config = state.config(); + let queue = Queue::new(&config).map_err(|e| ApiError::InternalError(e.to_string()))?; // Load tickets from each directory let queued_tickets = queue @@ -72,19 +87,20 @@ pub async fn kanban(State(state): State) -> Result awaiting_col.push(card), + match TicketStatus::parse(&ticket.status) { + Some(TicketStatus::Awaiting) => awaiting_col.push(card), _ => queue_col.push(card), } } - // In-progress directory tickets: check their status field + // In-progress directory tickets: check their status field. An unrecognised + // status stays in the running column rather than jumping back to queue. for ticket in &in_progress_tickets { let card = ticket_to_card(ticket); - match ticket.status.as_str() { - "awaiting" | "waiting" | "blocked" => awaiting_col.push(card), - "queued" => queue_col.push(card), - _ => running_col.push(card), // running, active, etc. + match TicketStatus::parse(&ticket.status) { + Some(TicketStatus::Awaiting) => awaiting_col.push(card), + Some(TicketStatus::Queued) => queue_col.push(card), + _ => running_col.push(card), } } @@ -93,34 +109,9 @@ pub async fn kanban(State(state): State) -> Result FIX > FEAT > SPIKE), then by timestamp - let priority_order = |t: &KanbanTicketCard| -> u8 { - match t.ticket_type.as_str() { - "INV" => 0, - "FIX" => 1, - "FEAT" => 2, - "SPIKE" => 3, - _ => 4, - } - }; - - queue_col.sort_by(|a, b| { - priority_order(a) - .cmp(&priority_order(b)) - .then_with(|| a.timestamp.cmp(&b.timestamp)) - }); - - running_col.sort_by(|a, b| { - priority_order(a) - .cmp(&priority_order(b)) - .then_with(|| a.timestamp.cmp(&b.timestamp)) - }); - - awaiting_col.sort_by(|a, b| { - priority_order(a) - .cmp(&priority_order(b)) - .then_with(|| a.timestamp.cmp(&b.timestamp)) - }); + sort_active_column(&config, &mut queue_col); + sort_active_column(&config, &mut running_col); + sort_active_column(&config, &mut awaiting_col); // Done column: most recently completed first (reverse timestamp order) done_col.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); @@ -152,7 +143,8 @@ pub async fn kanban(State(state): State) -> Result) -> Result, ApiError> { // Create a queue from the config - let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; + let config = state.config(); + let queue = Queue::new(&config).map_err(|e| ApiError::InternalError(e.to_string()))?; // Load tickets from each directory let queued_tickets = queue @@ -170,53 +162,35 @@ pub async fn status(State(state): State) -> Result *inv += 1, - "FIX" => *fix += 1, - "FEAT" => *feat += 1, - "SPIKE" => *spike += 1, - _ => {} - } - }; + // Configured types are always present so the shape is stable; anything a + // collection defines is added as it is encountered. + let mut by_type: std::collections::BTreeMap = config + .queue + .priority_order + .iter() + .map(|t| (t.clone(), 0)) + .collect(); + let mut count_type = |ticket: &Ticket| { + *by_type.entry(ticket.ticket_type.clone()).or_insert(0) += 1; + }; // Process queued tickets for ticket in &queued_tickets { - match ticket.status.as_str() { - "awaiting" => awaiting_count += 1, + match TicketStatus::parse(&ticket.status) { + Some(TicketStatus::Awaiting) => awaiting_count += 1, _ => queued_count += 1, } - count_type( - ticket, - &mut inv_count, - &mut fix_count, - &mut feat_count, - &mut spike_count, - ); + count_type(ticket); } // Process in-progress tickets for ticket in &in_progress_tickets { - match ticket.status.as_str() { - "awaiting" | "waiting" | "blocked" => awaiting_count += 1, - "queued" => queued_count += 1, + match TicketStatus::parse(&ticket.status) { + Some(TicketStatus::Awaiting) => awaiting_count += 1, + Some(TicketStatus::Queued) => queued_count += 1, _ => in_progress_count += 1, } - count_type( - ticket, - &mut inv_count, - &mut fix_count, - &mut feat_count, - &mut spike_count, - ); + count_type(ticket); } // Completed count @@ -227,12 +201,7 @@ pub async fn status(State(state): State) -> Result ApiState { + let mut config = Config::default(); + config.paths.tickets = dir.to_string_lossy().into_owned(); + ApiState::new(config, dir.to_path_buf()) + } + + fn state_with_order(dir: &std::path::Path, order: &[&str]) -> ApiState { + let mut config = Config::default(); + config.paths.tickets = dir.to_string_lossy().into_owned(); + config.queue.priority_order = order.iter().map(|t| (*t).to_string()).collect(); + ApiState::new(config, dir.to_path_buf()) + } + + /// Writes `//--operator-.md`. + fn write_ticket( + root: &std::path::Path, + column: &str, + timestamp: &str, + ticket_type: &str, + id: &str, + priority: &str, + status: &str, + ) { + let dir = root.join(column); + std::fs::create_dir_all(&dir).unwrap(); + let slug = id.to_lowercase(); + std::fs::write( + dir.join(format!("{timestamp}-{ticket_type}-operator-{slug}.md")), + format!( + "---\nid: {id}\nstatus: {status}\npriority: {priority}\nstep: plan\n---\n\n# {id}\n" + ), + ) + .unwrap(); + } + + fn ids(column: &[KanbanTicketCard]) -> Vec<&str> { + column.iter().map(|c| c.id.as_str()).collect() + } + #[tokio::test] async fn test_kanban_empty() { let state = make_state(); @@ -379,6 +389,182 @@ mod tests { assert!(response.queue.is_empty() || !response.queue.is_empty()); } + /// `TASK` is third in the default `queue.priority_order`, so it must not + /// sort below `FEAT` on the board. + #[tokio::test] + async fn test_kanban_orders_task_above_feat() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "queue", + "20240101-1100", + "FEAT", + "FEAT-1", + "P2-medium", + "queued", + ); + write_ticket( + temp.path(), + "queue", + "20240101-1200", + "TASK", + "TASK-1", + "P2-medium", + "queued", + ); + + let board = kanban(State(make_state_in(temp.path()))).await.unwrap(); + assert_eq!(ids(&board.queue), vec!["TASK-1", "FEAT-1"]); + } + + /// The ticket's own `priority:` breaks ties within a type, ahead of FIFO. + #[tokio::test] + async fn test_kanban_p0_sorts_above_p3_of_same_type() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "queue", + "20240101-1100", + "FEAT", + "FEAT-LOW", + "P3-low", + "queued", + ); + write_ticket( + temp.path(), + "queue", + "20240101-1200", + "FEAT", + "FEAT-HOT", + "P0-critical", + "queued", + ); + + let board = kanban(State(make_state_in(temp.path()))).await.unwrap(); + assert_eq!(ids(&board.queue), vec!["FEAT-HOT", "FEAT-LOW"]); + } + + /// Collection-defined types are absent from `priority_order`, so they sort + /// last - but must still order among themselves by priority, not tie. + #[tokio::test] + async fn test_kanban_unknown_issuetype_sorts_last_and_breaks_ties_by_priority() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "queue", + "20240101-1000", + "CHORE", + "CHORE-LOW", + "P3-low", + "queued", + ); + write_ticket( + temp.path(), + "queue", + "20240101-1100", + "CHORE", + "CHORE-HOT", + "P0-critical", + "queued", + ); + write_ticket( + temp.path(), + "queue", + "20240101-1200", + "FEAT", + "FEAT-1", + "P3-low", + "queued", + ); + + let board = kanban(State(make_state_in(temp.path()))).await.unwrap(); + assert_eq!(ids(&board.queue), vec!["FEAT-1", "CHORE-HOT", "CHORE-LOW"]); + } + + #[tokio::test] + async fn test_kanban_priority_order_follows_config() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "queue", + "20240101-1100", + "FEAT", + "FEAT-1", + "P2-medium", + "queued", + ); + write_ticket( + temp.path(), + "queue", + "20240101-1200", + "SPIKE", + "SPIKE-1", + "P2-medium", + "queued", + ); + + let state = state_with_order(temp.path(), &["SPIKE", "FEAT"]); + let board = kanban(State(state)).await.unwrap(); + assert_eq!(ids(&board.queue), vec!["SPIKE-1", "FEAT-1"]); + } + + /// The done column is recency-ordered, not priority-ordered. + #[tokio::test] + async fn test_kanban_done_column_is_newest_first() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "completed", + "20240101-1100", + "INV", + "INV-OLD", + "P0-critical", + "completed", + ); + write_ticket( + temp.path(), + "completed", + "20240101-1200", + "FEAT", + "FEAT-NEW", + "P3-low", + "completed", + ); + + let board = kanban(State(make_state_in(temp.path()))).await.unwrap(); + assert_eq!(ids(&board.done), vec!["FEAT-NEW", "INV-OLD"]); + } + + /// Every issuetype is counted, not just the four that used to be hardcoded. + #[tokio::test] + async fn test_queue_status_counts_task_and_custom_types() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "queue", + "20240101-1100", + "TASK", + "TASK-1", + "P2-medium", + "queued", + ); + write_ticket( + temp.path(), + "queue", + "20240101-1200", + "CHORE", + "CHORE-1", + "P2-medium", + "queued", + ); + + let response = status(State(make_state_in(temp.path()))).await.unwrap(); + assert_eq!(response.by_type.0.get("TASK"), Some(&1)); + assert_eq!(response.by_type.0.get("CHORE"), Some(&1)); + // Configured types are always present, even at zero. + assert_eq!(response.by_type.0.get("FEAT"), Some(&0)); + } + #[test] fn test_ticket_to_card() { // Create a minimal ticket for testing @@ -404,7 +590,27 @@ step: plan assert_eq!(card.id, "FEAT-1234"); assert_eq!(card.ticket_type, "FEAT"); assert_eq!(card.project, "operator"); - assert_eq!(card.status, "queued"); - assert_eq!(card.priority, "P2-medium"); + assert_eq!(card.status, TicketStatus::Queued); + assert_eq!(card.priority, crate::queue::TicketPriority::P2Medium); + } + + /// A status the schema does not know keeps the ticket in its directory's + /// column rather than snapping back to the queue. + #[tokio::test] + async fn test_kanban_buckets_unknown_in_progress_status_as_running() { + let temp = tempfile::tempdir().unwrap(); + write_ticket( + temp.path(), + "in-progress", + "20240101-1100", + "FEAT", + "FEAT-1", + "P2-medium", + "gibberish", + ); + + let board = kanban(State(make_state_in(temp.path()))).await.unwrap(); + assert_eq!(ids(&board.running), vec!["FEAT-1"]); + assert!(board.queue.is_empty()); } } diff --git a/src/rest/routes/setup.rs b/src/rest/routes/setup.rs index 1c3494b4..421e50ba 100644 --- a/src/rest/routes/setup.rs +++ b/src/rest/routes/setup.rs @@ -280,6 +280,7 @@ fn selected_execution_target( request_body = SetupInitializeRequest, responses( (status = 200, body = SetupInitializeResponse), + (status = 402, description = "Premium required for remote execution"), (status = 409, description = "Workspace already initialized") ) )] @@ -297,6 +298,7 @@ pub async fn initialize( let (hosted_collections, custom_collection, active_collection) = selected_collections(&request, resolved)?; let execution_target = selected_execution_target(request.execution_target, request.wrapper)?; + crate::licensing::require_target(&state.config(), &execution_target)?; let options = SetupOptions { preset: request.preset, task_fields, @@ -321,6 +323,7 @@ pub async fn initialize( initialize_workspace(config, &options).map_err(ApiError::from) }) .await?; + crate::startup::mark_workspace_initialized(&state.config()).map_err(ApiError::from)?; let registry = crate::startup::templates::load_registry(&tickets_path); *state.registry.write().await = registry; diff --git a/src/rest/routes/targets.rs b/src/rest/routes/targets.rs new file mode 100644 index 00000000..bd8d14b4 --- /dev/null +++ b/src/rest/routes/targets.rs @@ -0,0 +1,314 @@ +use std::time::Duration; + +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::Serialize; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::agents::delegator_resolution::resolve_named_target; +use crate::config::{Config, TargetDef, TargetKind}; +use crate::licensing::{self, PremiumFeature}; +use crate::rest::{error::ApiError, state::ApiState}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(15); +const SSH_CONNECT_TIMEOUT: &str = "ConnectTimeout=10"; +const MAX_TARGET_NAME_LENGTH: usize = 64; + +#[derive(Serialize, ToSchema, TS)] +#[ts(export)] +pub struct TargetResponse { + #[serde(flatten)] + #[schema(value_type = Object)] + pub target: TargetDef, + pub premium: bool, + pub entitled: bool, + pub user_declared: bool, +} + +#[derive(Serialize, ToSchema, TS)] +#[ts(export)] +pub struct TargetsResponse { + pub targets: Vec, + pub total: usize, +} + +#[derive(Serialize, ToSchema, TS)] +#[ts(export)] +pub struct TargetProbeResponse { + pub reachable: bool, + pub message: String, +} + +fn response(config: &Config, target: TargetDef) -> TargetResponse { + projection(config, licensing::entitlements(config), target) +} + +/// Built from entitlements resolved once by the caller, so listing N targets +/// does not verify the licence N times. +fn projection( + config: &Config, + entitlements: licensing::Entitlements, + target: TargetDef, +) -> TargetResponse { + let premium = matches!(target.kind, TargetKind::Ssh(_) | TargetKind::Coder(_)); + TargetResponse { + entitled: !premium || entitlements.allows(PremiumFeature::RemoteTargets), + user_declared: config.targets.iter().any(|entry| entry.name == target.name), + target, + premium, + } +} + +fn validate(target: &TargetDef) -> Result<(), ApiError> { + if target.name.is_empty() + || target.name.len() > MAX_TARGET_NAME_LENGTH + || !target + .name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') + { + return Err(ApiError::ValidationError( + "Target names must contain 1-64 lowercase letters, digits, hyphens, or underscores" + .into(), + )); + } + if let TargetKind::Ssh(ssh) = &target.kind { + if ssh.ssh_alias.trim().is_empty() + || ssh.ssh_alias.starts_with('-') + || ssh.ssh_alias.chars().any(char::is_whitespace) + { + return Err(ApiError::ValidationError( + "SSH target requires a valid SSH alias".into(), + )); + } + if !ssh.workdir.starts_with('/') { + return Err(ApiError::ValidationError( + "SSH target requires an absolute working directory".into(), + )); + } + } + Ok(()) +} + +fn ensure_unreferenced(config: &Config, name: &str) -> Result<(), ApiError> { + if config.launch.target.as_deref() == Some(name) + || config.delegators.iter().any(|delegator| { + delegator.launch_config.as_ref().is_some_and(|launch| { + launch.target.as_deref() == Some(name) || launch.host.as_deref() == Some(name) + }) + }) + { + return Err(ApiError::Conflict(format!( + "Target '{name}' is referenced by launch configuration" + ))); + } + ensure_inactive(config, name) +} + +fn ensure_inactive(config: &Config, name: &str) -> Result<(), ApiError> { + let runtime = crate::state::State::load(config).map_err(ApiError::from)?; + if runtime.agents.iter().any(|agent| { + (agent.target_name.as_deref() == Some(name) || agent.remote_host.as_deref() == Some(name)) + && matches!( + agent.status.as_str(), + "running" | "awaiting_input" | "completing" + ) + }) { + return Err(ApiError::Conflict(format!( + "Target '{name}' has active work" + ))); + } + Ok(()) +} + +#[utoipa::path(get, path = "/api/v1/targets", + operation_id = "targets_list", tag = "Targets", responses((status = 200, body = TargetsResponse)))] +pub async fn list(State(state): State) -> Result, ApiError> { + let config = state.config(); + let entitlements = licensing::entitlements(&config); + let targets = crate::config::targets::launchable_target_names(&config) + .iter() + .map(|name| { + resolve_named_target(&config, name) + .map(|target| projection(&config, entitlements, target)) + }) + .collect::, _>>() + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + Ok(Json(TargetsResponse { + total: targets.len(), + targets, + })) +} + +#[utoipa::path(post, path = "/api/v1/targets", + operation_id = "targets_create", tag = "Targets", request_body = serde_json::Value, + responses((status = 200, body = TargetResponse), (status = 402, description = "Premium required")))] +pub async fn create( + State(state): State, + Json(target): Json, +) -> Result, ApiError> { + validate(&target)?; + let result = state + .mutate_config(move |config| { + licensing::require_target(config, &target)?; + if crate::config::targets::known_target_name(config, &target.name) { + return Err(ApiError::Conflict(format!( + "Target '{}' already exists", + target.name + ))); + } + config.targets.push(target.clone()); + crate::config::targets::validate_targets(config) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + Ok(response(config, target)) + }) + .await?; + Ok(Json(result)) +} + +#[utoipa::path(put, path = "/api/v1/targets/{name}", + operation_id = "targets_update", tag = "Targets", request_body = serde_json::Value, + params(("name" = String, Path)), responses((status = 200, body = TargetResponse), (status = 402, description = "Premium required")))] +pub async fn update( + State(state): State, + Path(name): Path, + Json(target): Json, +) -> Result, ApiError> { + validate(&target)?; + if target.name != name { + return Err(ApiError::ValidationError( + "Target renaming is not supported; create a new target instead".into(), + )); + } + let result = state + .mutate_config(move |config| { + licensing::require_target(config, &target)?; + let index = config + .targets + .iter() + .position(|entry| entry.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Declared target '{name}' not found")))?; + licensing::require_target(config, &config.targets[index])?; + ensure_inactive(config, &name)?; + config.targets[index] = target.clone(); + crate::config::targets::validate_targets(config) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + Ok(response(config, target)) + }) + .await?; + Ok(Json(result)) +} + +#[utoipa::path(delete, path = "/api/v1/targets/{name}", + operation_id = "targets_remove", tag = "Targets", params(("name" = String, Path)), + responses((status = 200, body = TargetResponse), (status = 409, description = "Target is referenced")))] +pub async fn remove( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let result = state + .mutate_config(move |config| { + let index = config + .targets + .iter() + .position(|entry| entry.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Declared target '{name}' not found")))?; + ensure_unreferenced(config, &name)?; + let target = config.targets.remove(index); + Ok(response(config, target)) + }) + .await?; + Ok(Json(result)) +} + +#[utoipa::path(post, path = "/api/v1/targets/{name}/probe", + operation_id = "targets_probe", tag = "Targets", params(("name" = String, Path)), + responses((status = 200, body = TargetProbeResponse), (status = 402, description = "Premium required")))] +pub async fn probe( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let config = state.config(); + let target = resolve_named_target(&config, &name) + .map_err(|error| ApiError::NotFound(error.to_string()))?; + licensing::require_target(&config, &target)?; + let reachable = match &target.kind { + TargetKind::Ssh(ssh) => { + validate(&target)?; + let mut command = tokio::process::Command::new("ssh"); + command.kill_on_drop(true); + command.args(["-o", "BatchMode=yes", "-o", SSH_CONNECT_TIMEOUT]); + if let Some(path) = &ssh.ssh_config_path { + command.args(["-F", path]); + } + command.args(["--", &ssh.ssh_alias, "true"]); + command + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + tokio::time::timeout(PROBE_TIMEOUT, command.status()) + .await + .is_ok_and(|result| result.is_ok_and(|status| status.success())) + } + TargetKind::Coder(coder) => { + licensing::require_premium(&config, PremiumFeature::RemoteTargets)?; + let session = crate::agents::launcher::coder::resolve_session(coder) + .map_err(|error| ApiError::ValidationError(error.to_string()))?; + let client = reqwest::Client::builder() + .timeout(PROBE_TIMEOUT) + .build() + .map_err(|error| ApiError::InternalError(error.to_string()))?; + client + .get(format!( + "{}/api/v2/users/me", + session.url.trim_end_matches('/') + )) + .header("Coder-Session-Token", session.token) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + } + TargetKind::Local => true, + TargetKind::Docker(_) => { + let mut command = tokio::process::Command::new("docker"); + command + .kill_on_drop(true) + .arg("info") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + tokio::time::timeout(PROBE_TIMEOUT, command.status()) + .await + .is_ok_and(|result| result.is_ok_and(|status| status.success())) + } + }; + Ok(Json(TargetProbeResponse { + reachable, + message: if reachable { + "Connection succeeded" + } else { + "Connection failed; check the target configuration and credentials" + } + .into(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_alias_cannot_inject_options() { + let target = TargetDef { + name: "test".into(), + display_name: None, + kind: TargetKind::Ssh(crate::config::SshTarget { + ssh_alias: "-oProxyCommand=bad".into(), + workdir: "/project".into(), + ssh_config_path: None, + }), + }; + assert!(validate(&target).is_err()); + } +} diff --git a/src/rest/routes/tickets.rs b/src/rest/routes/tickets.rs index 66a470b0..c16fcb14 100644 --- a/src/rest/routes/tickets.rs +++ b/src/rest/routes/tickets.rs @@ -11,13 +11,14 @@ use axum::{ }; use crate::queue::creator::TicketCreator; -use crate::queue::{Queue, Ticket}; +use crate::queue::{Queue, Ticket, TicketColumn, TicketStatus}; use crate::rest::dto::{ CreateAlertRequest, CreateAlertResponse, CreateTicketRequest, CreateTicketResponse, TicketDetailResponse, UpdateTicketStatusRequest, UpdateTicketStatusResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; +use crate::services::ticket_transitions; use crate::templates::TemplateType; /// Find a ticket across all directories (queue, in-progress, completed) @@ -96,7 +97,8 @@ pub async fn get_one( /// Update a ticket's status /// /// Moves a ticket between queue directories based on the target status. -/// Valid transitions: queued, running, awaiting, done. +/// Accepts queued, running, awaiting and completed; `done` is an accepted +/// alias for `completed`, which is what gets written to frontmatter. #[utoipa::path( operation_id = "tickets_update_status", put, @@ -117,58 +119,53 @@ pub async fn update_status( Path(ticket_id): Path, Json(request): Json, ) -> Result, ApiError> { - let valid_statuses = ["queued", "running", "awaiting", "done"]; - if !valid_statuses.contains(&request.status.as_str()) { - return Err(ApiError::BadRequest(format!( - "Invalid status '{}'. Must be one of: {}", - request.status, - valid_statuses.join(", ") - ))); - } + // Parsed here rather than on the request DTO so an unknown value stays a + // 400 from us instead of a 422 from the JSON extractor. + let target = TicketStatus::parse(&request.status).ok_or_else(|| { + ApiError::BadRequest(format!( + "Invalid status '{}'. Must be one of: queued, running, awaiting, completed", + request.status + )) + })?; let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; let ticket = find_ticket_anywhere(&queue, &ticket_id)?; - let previous_status = ticket.status.clone(); - let target_status = request.status.as_str(); + let previous_status = TicketStatus::from_frontmatter(&ticket.status); - // Determine target directory - let tickets_path = state.config().tickets_path(); - let dst_dir = match target_status { - "queued" => tickets_path.join("queue"), - "running" | "awaiting" => tickets_path.join("in-progress"), - "done" => tickets_path.join("completed"), - _ => unreachable!(), + let column = match target { + TicketStatus::Queued => TicketColumn::Queue, + TicketStatus::Running | TicketStatus::Awaiting => TicketColumn::InProgress, + TicketStatus::Completed => TicketColumn::Completed, }; - let src = std::path::PathBuf::from(&ticket.filepath); - let dst = dst_dir.join(&ticket.filename); - - // Ensure target directory exists - std::fs::create_dir_all(&dst_dir) - .map_err(|e| ApiError::InternalError(format!("Failed to create directory: {e}")))?; + // The shared write path: it also mirrors the move to the board this ticket + // was synced from. A direct rename here would strand it upstream. + ticket_transitions::move_ticket(&state, &ticket, column) + .await + .map_err(ApiError::InternalError)?; - // Move the file if source and destination differ - if src != dst { - std::fs::rename(&src, &dst) - .map_err(|e| ApiError::InternalError(format!("Failed to move ticket: {e}")))?; - } + let dst = state + .config() + .tickets_path() + .join(column.dir_name()) + .join(&ticket.filename); - // Update the status field in the ticket file - if previous_status != target_status { + // Compared against the raw field so a legacy `done` is normalised too. + if ticket.status != target.as_str() { let mut moved_ticket = Ticket::from_file(&dst) .map_err(|e| ApiError::InternalError(format!("Failed to reload ticket: {e}")))?; moved_ticket - .update_field("status", target_status) + .update_field("status", target.as_str()) .map_err(|e| ApiError::InternalError(format!("Failed to update status field: {e}")))?; } Ok(Json(UpdateTicketStatusResponse { id: ticket.id, previous_status, - status: target_status.to_string(), - message: format!("Ticket moved to '{target_status}'"), + status: target, + message: format!("Ticket moved to '{target}'"), })) } @@ -312,11 +309,61 @@ mod tests { #[test] fn test_valid_statuses() { - let valid = ["queued", "running", "awaiting", "done"]; - for s in &valid { - assert!(valid.contains(s)); + for raw in ["queued", "running", "awaiting", "completed", "done"] { + assert!(TicketStatus::parse(raw).is_some(), "{raw} should parse"); } - assert!(!valid.contains(&"invalid")); + assert!(TicketStatus::parse("invalid").is_none()); + } + + /// The API still accepts `done`, but the metadata schema only knows + /// `completed` - that is what has to land in the file. + #[tokio::test] + async fn test_update_status_done_writes_completed_frontmatter() { + let temp = tempfile::TempDir::new().unwrap(); + let state = make_state_in(temp.path()); + let created = create( + State(state.clone()), + Json(CreateTicketRequest { + template: "feat".to_string(), + project: Some("gamesvc".to_string()), + summary: Some("Ship it".to_string()), + values: std::collections::HashMap::new(), + }), + ) + .await + .expect("create should succeed") + .0; + + let moved = update_status( + State(state.clone()), + Path(created.id.clone()), + Json(UpdateTicketStatusRequest { + status: "done".to_string(), + }), + ) + .await + .expect("done should be accepted"); + assert_eq!(moved.0.status, TicketStatus::Completed); + + let landed = temp.path().join("completed").join(&created.filename); + let body = std::fs::read_to_string(&landed).unwrap(); + assert!(body.contains("status: completed"), "got:\n{body}"); + assert!(!body.contains("status: done")); + } + + #[tokio::test] + async fn test_update_status_rejects_unknown_status() { + let temp = tempfile::TempDir::new().unwrap(); + let state = make_state_in(temp.path()); + let result = update_status( + State(state), + Path("FEAT-001".to_string()), + Json(UpdateTicketStatusRequest { + status: "shipped".to_string(), + }), + ) + .await; + assert!(matches!(result, Err(ApiError::BadRequest(_)))); } #[tokio::test] @@ -336,6 +383,86 @@ mod tests { assert!(result.is_err()); } + /// Every column move must relocate the file, including the two the three + /// named transitions do not cover: skipping in-progress, and reopening a + /// completed ticket. + #[tokio::test] + async fn test_update_status_moves_the_ticket_between_every_column() { + let temp = tempfile::TempDir::new().unwrap(); + let state = make_state_in(temp.path()); + let created = create( + State(state.clone()), + Json(CreateTicketRequest { + template: "feat".to_string(), + project: Some("gamesvc".to_string()), + summary: Some("Add pagination".to_string()), + values: std::collections::HashMap::new(), + }), + ) + .await + .expect("create should succeed") + .0; + + // `done` is the request word; `completed` is the stored one. + for (status, expected, dir) in [ + ("done", TicketStatus::Completed, "completed"), + ("queued", TicketStatus::Queued, "queue"), + ("running", TicketStatus::Running, "in-progress"), + ("done", TicketStatus::Completed, "completed"), + ] { + let moved = update_status( + State(state.clone()), + Path(created.id.clone()), + Json(UpdateTicketStatusRequest { + status: status.to_string(), + }), + ) + .await + .unwrap_or_else(|e| panic!("move to {status} failed: {e:?}")); + assert_eq!(moved.0.status, expected); + + let landed = temp.path().join(dir).join(&created.filename); + assert!(landed.exists(), "{status} should land the ticket in {dir}/"); + let body = std::fs::read_to_string(&landed).unwrap(); + assert!( + body.contains(&format!("status: {expected}")), + "status field not rewritten for {status}" + ); + } + } + + /// Moving a ticket to the column it already occupies must not lose the file. + #[tokio::test] + async fn test_update_status_to_the_same_column_is_a_no_op() { + let temp = tempfile::TempDir::new().unwrap(); + let state = make_state_in(temp.path()); + let created = create( + State(state.clone()), + Json(CreateTicketRequest { + template: "feat".to_string(), + project: Some("gamesvc".to_string()), + summary: Some("Stay put".to_string()), + values: std::collections::HashMap::new(), + }), + ) + .await + .expect("create should succeed") + .0; + + let moved = update_status( + State(state.clone()), + Path(created.id.clone()), + Json(UpdateTicketStatusRequest { + status: "queued".to_string(), + }), + ) + .await + .expect("no-op move should succeed"); + assert_eq!(moved.0.status, TicketStatus::Queued); + + assert!(temp.path().join("queue").join(&created.filename).exists()); + } + #[tokio::test] async fn test_create_unknown_template_is_bad_request() { let state = make_state(); diff --git a/src/rest/server.rs b/src/rest/server.rs index 7e730e2d..93929ca6 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -17,6 +17,8 @@ use crate::rest::{build_router, ApiState}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiSessionInfo { pub port: u16, + #[serde(default)] + pub profile_id: uuid::Uuid, pub pid: u32, pub started_at: String, pub version: String, @@ -31,6 +33,7 @@ fn write_session_file( tickets_path: &Path, state_path: &Path, port: u16, + profile_id: uuid::Uuid, ) -> std::io::Result { let operator_dir = tickets_path.join("operator"); std::fs::create_dir_all(&operator_dir)?; @@ -38,6 +41,7 @@ fn write_session_file( let session_file = operator_dir.join("api-session.json"); let session = ApiSessionInfo { port, + profile_id, pid: std::process::id(), started_at: chrono::Utc::now().to_rfc3339(), version: env!("CARGO_PKG_VERSION").to_string(), @@ -223,7 +227,7 @@ impl RestApiServer { // `/api/v1/health` now requires a credential, so the probe presents the // local-unlock token from *this* project's state directory. let mut request = client.get(&url); - if let Some(token) = crate::auth::local::read(&self.config.state_path()) { + if let Some(token) = crate::auth::local::read(&self.config.auth_state_path()) { request = request.bearer_auth(token); } @@ -270,7 +274,8 @@ impl RestApiServer { let host_ip = self.config.rest_api.host_ip(); let status = Arc::clone(&self.status); let tickets_path = self.tickets_path.clone(); - let state_path = self.config.state_path(); + let state_path = self.config.auth_state_path(); + let profile_id = self.config.profile.id; let api_state_handle = Arc::clone(&self.api_state); *status.lock().unwrap() = RestApiStatus::Starting; @@ -285,7 +290,8 @@ impl RestApiServer { tracing::info!("REST API listening on http://{}", addr); // Write session file for client discovery - if let Err(e) = write_session_file(&tickets_path, &state_path, port) { + if let Err(e) = write_session_file(&tickets_path, &state_path, port, profile_id) + { tracing::warn!(error = %e, "Failed to write API session file"); } @@ -545,7 +551,8 @@ mod tests { let port = 7008u16; let state_dir = temp_dir.path().join("custom-state"); - let result = write_session_file(temp_dir.path(), &state_dir, port); + let profile_id = uuid::Uuid::new_v4(); + let result = write_session_file(temp_dir.path(), &state_dir, port, profile_id); assert!(result.is_ok()); let session_file = temp_dir.path().join("operator").join("api-session.json"); @@ -555,6 +562,7 @@ mod tests { let session: ApiSessionInfo = serde_json::from_str(&content).unwrap(); assert_eq!(session.port, port); + assert_eq!(session.profile_id, profile_id); assert!(!session.version.is_empty()); assert!(session.pid > 0); assert_eq!( @@ -571,7 +579,7 @@ mod tests { let operator_dir = temp_dir.path().join("operator"); assert!(!operator_dir.exists()); - let result = write_session_file(temp_dir.path(), &operator_dir, 7008); + let result = write_session_file(temp_dir.path(), &operator_dir, 7008, uuid::Uuid::nil()); assert!(result.is_ok()); // Should have created the operator directory diff --git a/src/rest/state.rs b/src/rest/state.rs index b480beb9..4356b9ab 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -116,7 +116,7 @@ impl ApiState { /// 2. If empty, initialize default templates from embedded files /// 3. Fallback to embedded builtins if filesystem loading fails pub fn new(config: Config, tickets_path: PathBuf) -> Self { - let state_path = config.state_path(); + let state_path = config.auth_state_path(); let bind_addr = config.rest_api.host_ip(); let auth = AuthContext::initialize(state_path, bind_addr) .expect("auth store must be available; without it nothing can authenticate"); diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index 95678b97..2a3c39e2 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -450,11 +450,16 @@ pub fn set_session_env(req: SetKanbanSessionEnvRequest) -> SetKanbanSessionEnvRe match req.provider { KanbanProviderKind::Jira => { if let Some(body) = req.jira { - // SAFETY: set_var is safe in single-threaded startup contexts; - // the operator REST server runs inside a tokio runtime, but - // the set_var pattern is already established in - // src/app/git_onboarding.rs and src/main.rs. Kanban onboarding - // is a user-driven one-shot and we accept the same tradeoff. + // HAZARD: this writes to the process environment while other + // threads run - the REST server calls it from a tokio worker, + // not from single-threaded startup. That is unsound, not merely + // untidy, and it also means two configurations cannot hold + // different credentials at once. The fix is the credential + // overlay in the configuration-isolation plan: writes go to a + // process-wide store, reads go through a resolver, and each + // launched child gets the store merged into its spawn env. + // Kept as-is for now because a single configuration cannot + // observe the difference. std::env::set_var(&body.api_key_env, &body.api_token); std::env::set_var("OPERATOR_JIRA_DOMAIN", &body.domain); std::env::set_var("OPERATOR_JIRA_EMAIL", &body.email); diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index dd9a86e3..2993c7d7 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -20,6 +20,7 @@ use crate::api::providers::kanban::{ }; use crate::config::{Config, KanbanStatusMapping, ProjectSyncConfig}; use crate::issuetypes::kanban_type::KanbanIssueTypeRef; +use crate::queue::TicketPriority; /// A collection that can be synced from a kanban provider #[derive(Debug, Clone)] @@ -79,7 +80,11 @@ impl KanbanSyncService { } } - /// Get all configured syncable collections + /// Get all configured syncable collections. + /// + /// Only *external* providers appear here. The built-in Operator board is the + /// sync destination - listing it would make every sync re-import operator's + /// own tickets. Pinned by `test_configured_collections_never_include_the_builtin_board`. pub fn configured_collections(&self) -> Vec { let mut collections = Vec::new(); @@ -501,15 +506,16 @@ fn leak_string(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } -/// Map external priority to Operator priority -fn map_priority(priority: &Option) -> &'static str { +/// Map external priority to Operator priority. +/// +/// A provider's "lowest" tier folds into `P3-low`: operator's set is closed at +/// four, and a fifth value would not validate against the metadata schema. +fn map_priority(priority: &Option) -> TicketPriority { match priority.as_deref().map(str::to_lowercase).as_deref() { - Some("highest" | "critical" | "urgent" | "p0") => "P0-critical", - Some("high" | "p1") => "P1-high", - Some("medium" | "normal" | "p2") => "P2-medium", - Some("low" | "p3") => "P3-low", - Some("lowest" | "trivial" | "p4") => "P4-trivial", - _ => "P2-medium", // Default to medium + Some("highest" | "critical" | "urgent" | "p0") => TicketPriority::P0Critical, + Some("high" | "p1") => TicketPriority::P1High, + Some("low" | "p3" | "lowest" | "trivial" | "p4") => TicketPriority::P3Low, + _ => TicketPriority::P2Medium, } } @@ -583,6 +589,16 @@ mod tests { } } + /// The built-in board is the sync destination. If it ever became a + /// syncable collection, every sync would re-import operator's own tickets. + #[test] + fn test_configured_collections_never_include_the_builtin_board() { + let mut config = Config::default(); + config.kanban = crate::config::KanbanConfig::default(); + let collections = KanbanSyncService::new(&config).configured_collections(); + assert!(!collections.iter().any(|c| c.provider == "operator")); + } + #[test] fn test_frontmatter_stamps_sync_collection() { let fm = ticket_frontmatter("FIX", &sample_issue(), "jira", false, Some("devops_kanban")); @@ -669,12 +685,28 @@ mod tests { #[test] fn test_map_priority() { - assert_eq!(map_priority(&Some("Highest".to_string())), "P0-critical"); - assert_eq!(map_priority(&Some("high".to_string())), "P1-high"); - assert_eq!(map_priority(&Some("medium".to_string())), "P2-medium"); - assert_eq!(map_priority(&Some("low".to_string())), "P3-low"); - assert_eq!(map_priority(&Some("lowest".to_string())), "P4-trivial"); - assert_eq!(map_priority(&None), "P2-medium"); + assert_eq!( + map_priority(&Some("Highest".to_string())), + TicketPriority::P0Critical + ); + assert_eq!( + map_priority(&Some("high".to_string())), + TicketPriority::P1High + ); + assert_eq!( + map_priority(&Some("medium".to_string())), + TicketPriority::P2Medium + ); + assert_eq!( + map_priority(&Some("low".to_string())), + TicketPriority::P3Low + ); + // A provider's lowest tier has no operator equivalent; it folds into low. + assert_eq!( + map_priority(&Some("lowest".to_string())), + TicketPriority::P3Low + ); + assert_eq!(map_priority(&None), TicketPriority::P2Medium); } #[test] diff --git a/src/services/mod.rs b/src/services/mod.rs index dcd9f1f1..0d190c44 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod kanban_issuetype_service; pub mod kanban_onboarding; pub mod kanban_sync; pub mod pr_monitor; +pub mod ticket_transitions; pub use kanban_sync::{KanbanSyncService, SyncResult, SyncableCollection}; pub use pr_monitor::{PrMonitorService, PrStatusEvent, TrackedPr}; diff --git a/src/services/ticket_transitions.rs b/src/services/ticket_transitions.rs new file mode 100644 index 00000000..b9a524db --- /dev/null +++ b/src/services/ticket_transitions.rs @@ -0,0 +1,49 @@ +//! The one write path for moving a ticket between board columns. +//! +//! Every surface that moves a ticket - the MCP tools, the REST API, the web +//! board - goes through [`move_ticket`] so a move is always mirrored to the +//! external board the ticket came from. Bypassing this (renaming the file +//! directly) leaves a synced ticket stranded in its old column upstream. + +use crate::queue::{Queue, Ticket, TicketColumn}; +use crate::rest::state::ApiState; + +/// Mirror a completed local move to the upstream kanban board. +/// +/// Fire-and-forget: a provider that is unreachable must not fail the local +/// move. No-op unless bidirectional sync is configured (`state.kanban_sync`). +fn push_kanban_transition(state: &ApiState, ticket: &Ticket, column: TicketColumn) { + let Some(ks) = state.kanban_sync.clone() else { + return; + }; + let ticket = ticket.clone(); + tokio::spawn(async move { + match column { + TicketColumn::InProgress => ks.on_ticket_claimed(&ticket).await, + TicketColumn::Completed => ks.on_ticket_completed(&ticket).await, + TicketColumn::Queue => ks.on_ticket_requeued(&ticket).await, + } + }); +} + +/// Move a ticket into `column` and mirror the move upstream. +/// +/// The local move is authoritative: it completes before the upstream push is +/// spawned, and a push failure never rolls it back. +pub async fn move_ticket( + state: &ApiState, + ticket: &Ticket, + column: TicketColumn, +) -> Result<(), String> { + let config = (*state.config()).clone(); + let moved = ticket.clone(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.move_ticket(&moved, column).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + push_kanban_transition(state, ticket, column); + Ok(()) +} diff --git a/src/startup/mod.rs b/src/startup/mod.rs index e6a4fd29..02df0ced 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -1,11 +1,9 @@ //! Setup wizard step registry and template initialization. //! -//! This module defines the setup wizard steps that appear during first-time -//! initialization when no `.tickets/` directory exists. These definitions -//! serve as the source-of-truth for auto-generated documentation. +//! Defines the setup wizard steps that appear during first-time initialization when no `.tickets/` directory exists. +//! These definitions serve as the source-of-truth for auto-generated documentation. //! -//! It also provides template initialization functions to copy embedded -//! template files to the filesystem. +//! It also provides template initialization functions to copy embedded template files to the filesystem. //! //! ## Usage //! @@ -25,11 +23,26 @@ pub mod recovery; pub mod steps; pub mod templates; +const SETUP_COMPLETE_FILE: &str = "setup-complete"; + /// Whether a workspace has been initialized at `config`'s tickets path. /// -/// 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 +/// One predicate for every surface - the TUI wizard, the REST setup routes and +/// `ProfileSummary.initialized` - so "is this set up?" cannot drift between them. pub fn workspace_initialized(config: &crate::config::Config) -> bool { - config.tickets_path().join("queue").exists() + setup_complete_path(config).is_file() + || (config.profile.id.is_nil() && config.tickets_path().join("queue").exists()) +} + +fn setup_complete_path(config: &crate::config::Config) -> std::path::PathBuf { + config + .tickets_path() + .join("operator") + .join(SETUP_COMPLETE_FILE) +} + +pub fn mark_workspace_initialized(config: &crate::config::Config) -> std::io::Result<()> { + let path = setup_complete_path(config); + std::fs::create_dir_all(path.parent().expect("setup marker has a parent"))?; + std::fs::write(path, env!("CARGO_PKG_VERSION")) } diff --git a/src/startup/steps.rs b/src/startup/steps.rs index 6b9c9183..a7de07d9 100644 --- a/src/startup/steps.rs +++ b/src/startup/steps.rs @@ -33,9 +33,15 @@ pub struct SetupStepInfo { )] #[ts(export)] pub enum SetupStep { - /// Splash screen with discovered projects and detected LLM tools + /// Welcome to Operator. What is this project called? What tools are you bringing? How can we help you? #[serde(rename = "welcome")] Welcome, + /// Operator Premium licence status for this configuration + #[serde(rename = "license")] + License, + /// Where agents run: this machine, or remote targets + #[serde(rename = "execution-mode")] + ExecutionMode, /// Kanban integration overview and provider credential detection #[serde(rename = "kanban-info")] KanbanInfo, @@ -93,8 +99,10 @@ pub enum SetupStep { 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] = [ + pub const ALL: [SetupStep; 20] = [ SetupStep::Welcome, + SetupStep::License, + SetupStep::ExecutionMode, SetupStep::KanbanInfo, SetupStep::ModelServer, SetupStep::GitProvider, @@ -118,6 +126,8 @@ impl SetupStep { pub fn slug(self) -> &'static str { match self { SetupStep::Welcome => "welcome", + SetupStep::License => "license", + SetupStep::ExecutionMode => "execution-mode", SetupStep::KanbanInfo => "kanban-info", SetupStep::ModelServer => "model-server", SetupStep::GitProvider => "git-provider", @@ -143,20 +153,48 @@ impl SetupStep { match self { SetupStep::Welcome => SetupStepInfo { name: "Welcome", - description: "Splash screen showing detected LLM tools and discovered projects", - help_text: "The welcome screen displays:\n\ + description: "Name the configuration and review detected tools and projects", + help_text: "Choose a configuration name containing only lowercase letters, \ + digits, hyphens, and underscores. The name identifies this configuration in \ + the web UI, TUI, CLI, and MCP clients; its UUID remains stable when renamed.\n\n\ + The welcome screen also 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::License => SetupStepInfo { + name: "Operator Premium", + description: "Install or review the Premium licence for this configuration", + help_text: "Multiple local agents and local containers are free. Premium adds \ + remote execution: SSH hosts and Coder workspaces.\n\n\ + A licence is verified offline - Operator never contacts a licensing service. \ + It is bound to this configuration's identifier, shown on this screen, and \ + survives renaming the configuration.\n\n\ + Paste a licence key to install one, or continue without: every local \ + workflow stays available.", + navigation: "Enter to install, Tab to skip, Esc to go back", + }, + SetupStep::ExecutionMode => SetupStepInfo { + name: "Execution Mode", + description: "Run agents on this machine, or on remote targets", + help_text: "Both modes support multiple agents running at once.\n\n\ + - **This machine**: agents and local containers run beside Operator.\n\ + - **Remote targets**: agents run on SSH hosts or Coder workspaces and \ + report back to this Operator server. Requires Premium.\n\n\ + Choosing remote leads to target registration; choosing this machine skips it.", + navigation: "↑/↓ to select, Enter to continue, Esc to go back", + }, SetupStep::KanbanInfo => SetupStepInfo { name: "Kanban Info", - description: "Connect a kanban provider, or skip and connect one later", + description: "Connect an external 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\ + "**Operator** is the board. Tickets worked by agents move through the columns.\n\ + It is always on and needs no setup or credentials.\n\n\ + External providers are optional *sync sources*: their issues are pulled in \ + as tickets on the Operator board, and transitions are pushed back.\n\ + Supported: Jira, Linear, GitHub Projects, OpenSpec.\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 \ @@ -164,7 +202,8 @@ impl SetupStep { 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.", + **Skip for now** moves on with just the Operator board; press `K` from the \ + dashboard at any time.", navigation: "↑/↓ to select, Enter to confirm, Esc to go back", }, SetupStep::ModelServer => SetupStepInfo { @@ -391,6 +430,8 @@ mod tests { slugs, vec![ "welcome", + "license", + "execution-mode", "kanban-info", "model-server", "git-provider", diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index f4018323..4342b777 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -405,12 +405,7 @@ impl ConfirmDialog { // Priority (only if schema has priority field) if show_priority { - let priority_color = match ticket.priority.as_str() { - "P0-critical" => Color::Red, - "P1-high" => Color::Yellow, - "P2-medium" => Color::White, - _ => Color::Gray, - }; + let priority_color = crate::ui::color_for_priority(ticket.priority_level()); let priority_line = Line::from(vec![ Span::styled("Priority: ", Style::default().fg(Color::Gray)), Span::styled(&ticket.priority, Style::default().fg(priority_color)), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a507e743..59090b6c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -33,3 +33,16 @@ pub use projects_dialog::ProjectsDialog; pub use session_preview::SessionPreview; pub use terminal_guard::{install_panic_hook, TerminalGuard}; pub use terminal_suspend::with_suspended_tui; + +use crate::queue::TicketPriority; +use ratatui::style::Color; + +/// The one urgency-to-ANSI mapping for the TUI. +pub fn color_for_priority(priority: TicketPriority) -> Color { + match priority { + TicketPriority::P0Critical => Color::Red, + TicketPriority::P1High => Color::Yellow, + TicketPriority::P2Medium => Color::White, + TicketPriority::P3Low => Color::Gray, + } +} diff --git a/src/ui/panels.rs b/src/ui/panels.rs index be8024ab..c9f68037 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -50,12 +50,7 @@ impl QueuePanel { .map(|t| { let glyph = glyph_for_key(&t.ticket_type); - let priority_color = match t.priority.as_str() { - "P0-critical" => Color::Red, - "P1-high" => Color::Yellow, - "P2-medium" => Color::White, - _ => Color::Gray, - }; + let priority_color = crate::ui::color_for_priority(t.priority_level()); // Get glyph color from template, fall back to priority color let glyph_color = diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs index fcc93760..3aa0cdf7 100644 --- a/src/ui/sections/connections_section.rs +++ b/src/ui/sections/connections_section.rs @@ -331,6 +331,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs index 7b0a1574..a59abca9 100644 --- a/src/ui/sections/delegator_section.rs +++ b/src/ui/sections/delegator_section.rs @@ -135,6 +135,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/src/ui/sections/git_section.rs b/src/ui/sections/git_section.rs index 203a04f3..7ffb3445 100644 --- a/src/ui/sections/git_section.rs +++ b/src/ui/sections/git_section.rs @@ -204,6 +204,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/src/ui/sections/issuetype_section.rs b/src/ui/sections/issuetype_section.rs index 72f5af0a..1ef5fe5a 100644 --- a/src/ui/sections/issuetype_section.rs +++ b/src/ui/sections/issuetype_section.rs @@ -99,6 +99,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs index cbd9d6be..86b03d4d 100644 --- a/src/ui/sections/kanban_section.rs +++ b/src/ui/sections/kanban_section.rs @@ -68,6 +68,11 @@ impl StatusSection for KanbanSection { .collect(); for provider in KanbanProviderType::ALL { + // The built-in board is never offered for configuration: it has no + // credentials and is always on. It appears above as a connected row. + if provider.is_builtin() { + continue; + } let already_connected = snapshot .kanban_providers .iter() @@ -140,6 +145,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } @@ -219,6 +226,39 @@ mod tests { ); } + /// Offering "Configure Operator" would be a dead end - there is nothing to + /// connect. + #[test] + fn test_kanban_children_never_offer_to_configure_the_builtin_board() { + let section = KanbanSection; + let children = section.children(&base_snapshot()); + assert!(!children.iter().any(|r| { + matches!( + &r.actions.primary, + StatusAction::ConfigureKanbanProvider { provider } if provider == "operator" + ) + })); + } + + /// At runtime the snapshot always carries the built-in board, so it renders + /// as connected and the section is never "no provider connected". + #[test] + fn test_kanban_section_is_green_with_only_the_builtin_board() { + let section = KanbanSection; + let mut snap = base_snapshot(); + snap.kanban_providers.push(KanbanProviderInfo { + provider_type: "operator".into(), + domain: ".tickets".into(), + }); + assert_eq!(section.health(&snap), SectionHealth::Green); + assert_eq!(section.description(&snap), "operator"); + + let children = section.children(&snap); + assert_eq!(children[0].label, "operator"); + assert_eq!(children[0].description, ".tickets"); + assert_eq!(children[0].actions.primary, StatusAction::None); + } + #[test] fn test_kanban_children_with_one_provider_still_offers_the_rest() { let section = KanbanSection; diff --git a/src/ui/sections/license_section.rs b/src/ui/sections/license_section.rs new file mode 100644 index 00000000..c5f5a26e --- /dev/null +++ b/src/ui/sections/license_section.rs @@ -0,0 +1,195 @@ +//! The **License** status section: which tier this configuration runs at, and +//! the verified terms behind that answer. +//! +//! Read-only. Installing a licence happens in the setup wizard or the web +//! dashboard; this section exists so the terminal can answer "am I licensed, +//! and until when?" without either. +//! +//! Health maps the licence status to a semantic role rather than to the word: +//! a missing licence is the free tier working as intended, not a fault. + +use crate::licensing::LicenseStatus; +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct LicenseSection; + +fn label_and_health(status: LicenseStatus) -> (&'static str, SectionHealth) { + match status { + LicenseStatus::Valid => ("Premium", SectionHealth::Green), + LicenseStatus::Missing => ("Free", SectionHealth::Gray), + LicenseStatus::Expired => ("Expired", SectionHealth::Yellow), + LicenseStatus::NotYetValid => ("Not yet valid", SectionHealth::Yellow), + LicenseStatus::Invalid => ("Invalid", SectionHealth::Red), + } +} + +fn timestamp(seconds: i64) -> String { + chrono::DateTime::from_timestamp(seconds, 0) + .map_or_else(|| "-".to_string(), |t| t.format("%Y-%m-%d").to_string()) +} + +impl StatusSection for LicenseSection { + fn section_id(&self) -> SectionId { + SectionId::License + } + + fn label(&self) -> &'static str { + "License" + } + + fn prerequisites(&self) -> &[SectionId] { + // Always answerable: the free tier is a valid answer. + &[] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + label_and_health(snapshot.license.status).1 + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let (label, _) = label_and_health(snapshot.license.status); + match &snapshot.license.terms { + Some(terms) => format!("{label} · expires {}", timestamp(terms.exp)), + None => format!("{label} · local execution included"), + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + let health = self.health(snapshot); + let mut rows = vec![row( + "license-configuration", + "Configuration", + &snapshot.license.profile_id.to_string(), + StatusIcon::Key, + health, + )]; + + if let Some(terms) = &snapshot.license.terms { + rows.push(row( + "license-subject", + "Licensed to", + &terms.sub, + StatusIcon::Check, + health, + )); + rows.push(row( + "license-id", + "License ID", + &terms.jti, + StatusIcon::File, + health, + )); + rows.push(row( + "license-validity", + "Valid", + &format!("{} to {}", timestamp(terms.nbf), timestamp(terms.exp)), + StatusIcon::File, + health, + )); + } else { + rows.push(row( + "license-free", + "Included", + "Multiple local agents and local containers", + StatusIcon::Check, + SectionHealth::Gray, + )); + } + + if let Some(url) = purchase_url(snapshot) { + rows.push(TreeRow { + section_id: SectionId::License, + id: "license-purchase".to_string(), + depth: 1, + label: "Operator Premium".to_string(), + description: url.clone(), + icon: StatusIcon::Plug, + brand_icon: None, + is_header: false, + actions: ActionSet { + primary: StatusAction::OpenUrl(url), + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + }, + health: SectionHealth::Gray, + }); + } + rows + } +} + +/// Only an https destination is ever offered as a link. +fn purchase_url(snapshot: &StatusSnapshot) -> Option { + snapshot + .license + .purchase_url + .as_ref() + .filter(|url| url.starts_with("https://")) + .cloned() +} + +fn row( + id: &str, + label: &str, + description: &str, + icon: StatusIcon, + health: SectionHealth, +) -> TreeRow { + TreeRow { + section_id: SectionId::License, + id: id.to_string(), + depth: 1, + label: label.to_string(), + description: description.to_string(), + icon, + brand_icon: None, + is_header: false, + actions: ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + }, + health, + } +} + +#[allow(dead_code)] +fn unused(_: ActionMeta) {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + #[test] + fn a_missing_licence_reads_as_free_not_broken() { + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + let snapshot = StatusSnapshot::from_config(&config, vec![]); + + assert_eq!(LicenseSection.health(&snapshot), SectionHealth::Gray); + assert!(LicenseSection.description(&snapshot).starts_with("Free")); + assert!(LicenseSection.prerequisites().is_empty()); + } + + #[test] + fn the_configuration_binding_is_always_shown() { + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + let snapshot = StatusSnapshot::from_config(&config, vec![]); + + let rows = LicenseSection.children(&snapshot); + assert!(rows.iter().any(|r| r.label == "Configuration")); + } +} diff --git a/src/ui/sections/managed_projects_section.rs b/src/ui/sections/managed_projects_section.rs index 637645d0..a7e81680 100644 --- a/src/ui/sections/managed_projects_section.rs +++ b/src/ui/sections/managed_projects_section.rs @@ -107,6 +107,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/src/ui/sections/mod.rs b/src/ui/sections/mod.rs index 54ad793b..ce66f635 100644 --- a/src/ui/sections/mod.rs +++ b/src/ui/sections/mod.rs @@ -4,9 +4,11 @@ mod delegator_section; mod git_section; mod issuetype_section; mod kanban_section; +mod license_section; mod llm_section; mod managed_projects_section; mod modelserver_section; +mod remote_targets_section; mod workflows_section; pub use config_section::ConfigSection; @@ -15,7 +17,9 @@ pub use delegator_section::DelegatorSection; pub use git_section::GitSection; pub use issuetype_section::IssueTypeSection; pub use kanban_section::KanbanSection; +pub use license_section::LicenseSection; pub use llm_section::LlmSection; pub use managed_projects_section::ManagedProjectsSection; pub use modelserver_section::ModelServerSection; +pub use remote_targets_section::RemoteTargetsSection; pub use workflows_section::WorkflowsSection; diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs index 89bb90e8..10bc2187 100644 --- a/src/ui/sections/modelserver_section.rs +++ b/src/ui/sections/modelserver_section.rs @@ -204,6 +204,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/src/ui/sections/remote_targets_section.rs b/src/ui/sections/remote_targets_section.rs new file mode 100644 index 00000000..76b4cdcd --- /dev/null +++ b/src/ui/sections/remote_targets_section.rs @@ -0,0 +1,248 @@ +//! The **Remote Targets** status section: SSH hosts and Coder workspaces, and +//! whether this configuration may currently launch onto them. +//! +//! Deliberately visible without a licence. Hiding it would make Premium look +//! like a missing feature rather than a locked one, and configured targets stay +//! readable - and removable - whatever the licence says. When the configuration +//! is not entitled, the rows are preceded by the paywall panel, which names the +//! feature and where to get it. + +use crate::ui::status_panel::{ + ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, + StatusSnapshot, TreeRow, +}; + +pub struct RemoteTargetsSection; + +impl StatusSection for RemoteTargetsSection { + fn section_id(&self) -> SectionId { + SectionId::RemoteTargets + } + + fn label(&self) -> &'static str { + "Remote Targets" + } + + fn prerequisites(&self) -> &[SectionId] { + &[] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.remote_targets.is_empty() { + // Nothing configured is not a fault: local execution is the default. + SectionHealth::Gray + } else if snapshot.license.premium { + SectionHealth::Green + } else { + // Configured but unusable is exactly what a warning is for. + SectionHealth::Yellow + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let count = snapshot.remote_targets.len(); + match (count, snapshot.license.premium) { + (0, _) => "Premium · none configured".to_string(), + (n, true) => format!("{n} configured"), + (n, false) => format!("{n} configured · license required"), + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + let mut rows = Vec::new(); + + if !snapshot.license.premium { + rows.extend(paywall_rows(snapshot)); + } + + for target in &snapshot.remote_targets { + rows.push(TreeRow { + section_id: SectionId::RemoteTargets, + id: format!("remote-target-{}", target.name), + depth: 1, + label: target.name.clone(), + description: format!( + "{} · {} · {}", + target.kind, + target.detail, + if snapshot.license.premium { + "available" + } else { + "license required" + } + ), + icon: StatusIcon::Plug, + brand_icon: None, + is_header: false, + actions: manage_action(snapshot), + health: if snapshot.license.premium { + SectionHealth::Green + } else { + SectionHealth::Yellow + }, + }); + } + rows + } +} + +/// The terminal paywall: what the feature is, why it is unavailable, and the +/// two ways forward. A terminal cannot open a browser for the reader, so the +/// destination is rendered as text. +fn paywall_rows(snapshot: &StatusSnapshot) -> Vec { + let mut rows = vec![ + paywall_row( + "remote-targets-paywall", + "Remote targets", + "Premium · available with a license for this configuration", + StatusIcon::Key, + ), + paywall_row( + "remote-targets-free", + "Included", + "Multiple local agents and local containers", + StatusIcon::Check, + ), + ]; + if let Some(url) = snapshot + .license + .purchase_url + .as_ref() + .filter(|url| url.starts_with("https://")) + { + rows.push(TreeRow { + actions: ActionSet { + primary: StatusAction::OpenUrl(url.clone()), + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + }, + ..paywall_row( + "remote-targets-purchase", + "Get Premium", + url, + StatusIcon::Plug, + ) + }); + } + rows +} + +fn paywall_row(id: &str, label: &str, description: &str, icon: StatusIcon) -> TreeRow { + TreeRow { + section_id: SectionId::RemoteTargets, + id: id.to_string(), + depth: 1, + label: label.to_string(), + description: description.to_string(), + icon, + brand_icon: None, + is_header: false, + actions: ActionSet { + primary: StatusAction::None, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + }, + health: SectionHealth::Yellow, + } +} + +/// Managing targets is a dashboard job; the terminal links to it when the API +/// is up. +fn manage_action(snapshot: &StatusSnapshot) -> ActionSet { + let primary = match snapshot.api_port() { + Some(port) => StatusAction::OpenWebUiAt { + port, + route: "/remote-targets".into(), + }, + None => StatusAction::None, + }; + ActionSet { + primary, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + } +} + +#[allow(dead_code)] +fn unused(_: ActionMeta) {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, SshTarget, TargetDef, TargetKind}; + + fn snapshot_with_targets(targets: Vec) -> StatusSnapshot { + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory.path().to_string_lossy().into_owned(); + config.targets = targets; + StatusSnapshot::from_config(&config, vec![]) + } + + fn ssh(name: &str) -> TargetDef { + TargetDef { + name: name.to_string(), + display_name: None, + kind: TargetKind::Ssh(SshTarget { + ssh_alias: "alias".to_string(), + workdir: "/srv".to_string(), + ssh_config_path: None, + }), + } + } + + /// The section stays discoverable without a licence - that is the point. + #[test] + fn the_section_is_visible_and_gated_without_a_licence() { + let snapshot = snapshot_with_targets(vec![ssh("build-host")]); + + assert_eq!( + RemoteTargetsSection.health(&snapshot), + SectionHealth::Yellow + ); + assert!(RemoteTargetsSection + .description(&snapshot) + .contains("license required")); + + let rows = RemoteTargetsSection.children(&snapshot); + assert!( + rows.iter().any(|r| r.id == "remote-targets-paywall"), + "an unentitled configuration renders the paywall" + ); + assert!( + rows.iter().any(|r| r.label == "build-host"), + "configured targets stay readable without Premium" + ); + } + + #[test] + fn no_configured_targets_is_not_a_fault() { + let snapshot = snapshot_with_targets(vec![]); + assert_eq!(RemoteTargetsSection.health(&snapshot), SectionHealth::Gray); + } + + /// Local and docker are built in; this section is about remote execution. + #[test] + fn built_in_targets_are_not_listed() { + let snapshot = snapshot_with_targets(vec![TargetDef { + name: "container".to_string(), + display_name: None, + kind: TargetKind::Docker(crate::config::DockerConfig::default()), + }]); + + assert!(snapshot.remote_targets.is_empty()); + assert!(!RemoteTargetsSection + .children(&snapshot) + .iter() + .any(|r| r.label == "container")); + } +} diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index 7e87e118..06048725 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -21,6 +21,10 @@ 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; +pub(crate) const LOCAL_EXECUTION_OPTION_INDEX: usize = 0; +pub(crate) const REMOTE_EXECUTION_OPTION_INDEX: usize = 1; +const EXECUTION_MODE_OPTION_COUNT: usize = 2; + /// Setup screen shown when .tickets/ directory doesn't exist pub struct SetupScreen { /// Whether the screen is visible @@ -29,6 +33,8 @@ pub struct SetupScreen { pub step: SetupStep, /// Current selection for confirmation: true = Initialize, false = Cancel pub confirm_selected: bool, + pub configuration_name: String, + pub(crate) configuration_name_error: Option, /// Path where tickets directory will be created pub(crate) tickets_path: String, /// Detected LLM tools (from `LlmToolsConfig`) @@ -102,6 +108,19 @@ pub struct SetupScreen { pub selected_wrapper: SessionWrapperType, /// List state for wrapper selection pub(crate) wrapper_state: ListState, + // ─── Licence State ────────────────────────────────────────────────────── + /// Verified licence status for this configuration; the app refreshes it. + pub license: Option, + /// Licence key entry field. + pub(crate) license_input: MaskedInput, + /// Set when the user submitted a key; the app performs the install. + pub(crate) license_install_requested: bool, + /// Inline outcome of the last install attempt. + pub(crate) license_error: Option, + // ─── Execution Mode State ─────────────────────────────────────────────── + pub(crate) execution_mode_state: ListState, + /// Whether agents run on remote targets; gates the execution-target step. + pub remote_execution: bool, // ─── Execution Target State ───────────────────────────────────────────── pub(crate) execution_target_state: ListState, pub(crate) coder_target_name: String, @@ -157,6 +176,9 @@ impl SetupScreen { let mut execution_target_state = ListState::default(); execution_target_state.select(Some(LOCAL_TARGET_OPTION_INDEX)); + let mut execution_mode_state = ListState::default(); + execution_mode_state.select(Some(LOCAL_EXECUTION_OPTION_INDEX)); + let mut hosted_state = ListState::default(); hosted_state.select(Some(0)); @@ -164,6 +186,11 @@ impl SetupScreen { visible: true, step: SetupStep::Welcome, confirm_selected: true, // Default to Initialize + // Empty, not "legacy": the field's validation should be visible + // rather than pre-satisfied. `App` overwrites this with the real + // name when the configuration already has one. + configuration_name: String::new(), + configuration_name_error: None, tickets_path, detected_tools, projects_by_tool, @@ -215,6 +242,12 @@ impl SetupScreen { // Session wrapper state selected_wrapper: SessionWrapperType::Tmux, wrapper_state, + license: None, + license_input: MaskedInput::default(), + license_install_requested: false, + license_error: None, + execution_mode_state, + remote_execution: false, execution_target_state, coder_target_name: crate::config::DEFAULT_CODER_TARGET_NAME.to_string(), coder_template: String::new(), @@ -475,6 +508,16 @@ impl SetupScreen { let i = self.wrapper_state.selected().map_or(0, |i| (i + 1) % len); self.wrapper_state.select(Some(i)); } + SetupStep::ExecutionMode => { + let i = self + .execution_mode_state + .selected() + .map_or(LOCAL_EXECUTION_OPTION_INDEX, |i| { + (i + 1) % EXECUTION_MODE_OPTION_COUNT + }); + self.execution_mode_state.select(Some(i)); + self.license_error = None; + } SetupStep::ExecutionTarget => { let i = self .execution_target_state @@ -573,6 +616,16 @@ impl SetupScreen { .map_or(0, |i| if i == 0 { len - 1 } else { i - 1 }); self.wrapper_state.select(Some(i)); } + SetupStep::ExecutionMode => { + let i = self + .execution_mode_state + .selected() + .map_or(REMOTE_EXECUTION_OPTION_INDEX, |i| { + usize::from(i == LOCAL_EXECUTION_OPTION_INDEX) + }); + self.execution_mode_state.select(Some(i)); + self.license_error = None; + } SetupStep::ExecutionTarget => { let i = self .execution_target_state @@ -811,6 +864,76 @@ impl SetupScreen { .map(|e| e.slug.to_string()) } + /// Whether this configuration currently holds a valid Premium licence. + pub(crate) fn premium_entitled(&self) -> bool { + self.license.as_ref().is_some_and(|l| l.premium) + } + + /// Consume a pending licence key submitted on the licence step. + pub(crate) fn take_license_install_request(&mut self) -> Option { + if std::mem::take(&mut self.license_install_requested) { + return Some(self.license_input.value().to_string()); + } + None + } + + /// Record the outcome of an install attempt made by the app. + pub(crate) fn set_license_outcome( + &mut self, + result: Result, + ) { + match result { + Ok(license) => { + self.license = Some(license); + self.license_error = None; + self.license_input.clear(); + } + // A rejected key returns to the licence screen: the error belongs + // where the field that produced it is. + Err(error) => { + self.license_error = Some(error); + self.step = SetupStep::License; + } + } + } + + /// Editing keys for the licence key field. + pub(crate) fn handle_license_key(&mut self, code: ratatui::crossterm::event::KeyCode) { + use ratatui::crossterm::event::KeyCode; + self.license_error = None; + match code { + KeyCode::Char(c) => self.license_input.handle_char(c), + KeyCode::Backspace => self.license_input.handle_backspace(), + KeyCode::Delete => self.license_input.handle_delete(), + KeyCode::Left => self.license_input.cursor_left(), + KeyCode::Right => self.license_input.cursor_right(), + KeyCode::Home => self.license_input.cursor_home(), + KeyCode::End => self.license_input.cursor_end(), + _ => {} + } + } + + pub(crate) fn handle_configuration_name_key( + &mut self, + code: ratatui::crossterm::event::KeyCode, + ) { + use ratatui::crossterm::event::KeyCode; + + self.configuration_name_error = None; + match code { + KeyCode::Char(c) + if self.configuration_name.len() < crate::profiles::MAX_PROFILE_NAME_LENGTH + && (c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_')) => + { + self.configuration_name.push(c); + } + KeyCode::Backspace => { + self.configuration_name.pop(); + } + _ => {} + } + } + /// Consume a pending request to connect a git provider. pub fn take_git_connect_request(&mut self) -> Option { self.git_connect_requested.take() @@ -829,6 +952,10 @@ impl SetupScreen { pub fn confirm(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => { + if let Err(error) = crate::profiles::validate_name(&self.configuration_name) { + self.configuration_name_error = Some(error.to_string()); + return SetupResult::Continue; + } // Kanban setup runs first so the collection step can offer // "import from a configured provider" options. Detect providers // from environment variables on the way into the kanban step. @@ -837,6 +964,26 @@ impl SetupScreen { crate::api::providers::kanban::detect_kanban_env_vars(); self.kanban_detection_complete = true; } + self.step = SetupStep::License; + SetupResult::Continue + } + SetupStep::License => { + if !self.license_input.is_empty() { + self.license_install_requested = true; + } + self.step = SetupStep::ExecutionMode; + SetupResult::Continue + } + SetupStep::ExecutionMode => { + self.remote_execution = + self.execution_mode_state.selected() == Some(REMOTE_EXECUTION_OPTION_INDEX); + if self.remote_execution && !self.premium_entitled() { + self.license_error = Some( + "Remote targets require a valid Premium licence for this configuration" + .to_string(), + ); + return SetupResult::Continue; + } self.step = SetupStep::KanbanInfo; SetupResult::Continue } @@ -923,7 +1070,11 @@ impl SetupScreen { self.selected_wrapper = options[i].to_wrapper_type(); } } - self.step = SetupStep::ExecutionTarget; + self.step = if self.remote_execution { + SetupStep::ExecutionTarget + } else { + SetupStep::WorktreePreference + }; SetupResult::Continue } SetupStep::ExecutionTarget => { @@ -1032,10 +1183,19 @@ impl SetupScreen { pub fn go_back(&mut self) -> SetupResult { match self.step { SetupStep::Welcome => SetupResult::Cancel, - SetupStep::KanbanInfo => { + SetupStep::License => { self.step = SetupStep::Welcome; SetupResult::Continue } + SetupStep::ExecutionMode => { + self.license_error = None; + self.step = SetupStep::License; + SetupResult::Continue + } + SetupStep::KanbanInfo => { + self.step = SetupStep::ExecutionMode; + SetupResult::Continue + } SetupStep::ModelServer => { self.step = SetupStep::KanbanInfo; SetupResult::Continue @@ -1066,7 +1226,11 @@ impl SetupScreen { SetupResult::Continue } SetupStep::WorktreePreference => { - self.step = SetupStep::ExecutionTarget; + self.step = if self.remote_execution { + SetupStep::ExecutionTarget + } else { + SetupStep::SessionWrapperChoice + }; SetupResult::Continue } SetupStep::ExecutionTarget => { @@ -1138,6 +1302,8 @@ impl SetupScreen { match self.step { SetupStep::Welcome => self.render_welcome_step(frame), + SetupStep::License => self.render_license_step(frame), + SetupStep::ExecutionMode => self.render_execution_mode_step(frame), SetupStep::CollectionSource => self.render_collection_source_step(frame), SetupStep::HostedCollectionFetch => self.render_hosted_collection_step(frame), SetupStep::TaskFieldConfig => self.render_task_field_config_step(frame), diff --git a/src/ui/setup/steps/execution_mode.rs b/src/ui/setup/steps/execution_mode.rs new file mode 100644 index 00000000..15cb1676 --- /dev/null +++ b/src/ui/setup/steps/execution_mode.rs @@ -0,0 +1,141 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::ui::dialogs::centered_rect; +use crate::ui::setup::{SetupScreen, LOCAL_EXECUTION_OPTION_INDEX, REMOTE_EXECUTION_OPTION_INDEX}; + +impl SetupScreen { + pub(crate) fn render_execution_mode_step(&self, frame: &mut Frame) { + let area = centered_rect(72, 70, frame.area()); + frame.render_widget(Clear, area); + let block = Block::default() + .title(" Execution Mode ") + .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(2), + Constraint::Length(5), + Constraint::Min(3), + Constraint::Length(2), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new("Where will agents run?").style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + chunks[0], + ); + + frame.render_widget( + Paragraph::new("Both modes support multiple agents.") + .style(Style::default().fg(Color::Gray)), + chunks[1], + ); + + let selected = self + .execution_mode_state + .selected() + .unwrap_or(LOCAL_EXECUTION_OPTION_INDEX); + frame.render_widget( + Paragraph::new(vec![ + mode_line( + "This machine", + selected == LOCAL_EXECUTION_OPTION_INDEX, + "agents and local containers run beside Operator", + false, + ), + mode_line( + "Remote targets", + selected == REMOTE_EXECUTION_OPTION_INDEX, + "SSH hosts and Coder workspaces report back here", + true, + ), + ]), + chunks[2], + ); + + frame.render_widget(Paragraph::new(self.entitlement_lines()), chunks[3]); + + frame.render_widget( + Paragraph::new("↑/↓ Select Enter Continue Esc Back") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)), + chunks[4], + ); + } + + /// The paywall panel: shown inline whenever remote execution is unavailable. + fn entitlement_lines(&self) -> Vec> { + if self.premium_entitled() { + return vec![Line::from(Span::styled( + "Premium licence active - remote targets available.", + Style::default().fg(Color::Green), + ))]; + } + let mut lines = vec![ + Line::from(vec![ + Span::styled( + "Remote targets", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Premium", Style::default().fg(Color::Yellow)), + ]), + Line::from(Span::styled( + "Available with an Operator Premium licence for this configuration.", + Style::default().fg(Color::Gray), + )), + Line::from(Span::styled( + "Press Esc to go back and add a licence.", + Style::default().fg(Color::DarkGray), + )), + ]; + if let Some(url) = self.license.as_ref().and_then(|l| l.purchase_url.as_ref()) { + lines.push(Line::from(Span::styled( + url.clone(), + Style::default().fg(Color::DarkGray), + ))); + } + if let Some(error) = &self.license_error { + lines.push(Line::from(Span::styled( + error.clone(), + Style::default().fg(Color::Red), + ))); + } + lines + } +} + +fn mode_line(name: &str, selected: bool, description: &str, premium: bool) -> Line<'static> { + let marker = if selected { "(o)" } else { "( )" }; + let color = if selected { Color::Cyan } else { Color::Gray }; + let mut spans = vec![Span::styled( + format!("{marker} {name}"), + Style::default().fg(color), + )]; + if premium { + spans.push(Span::styled( + " · Premium", + Style::default().fg(Color::Yellow), + )); + } + spans.push(Span::raw(format!(" {description}"))); + Line::from(spans) +} diff --git a/src/ui/setup/steps/kanban.rs b/src/ui/setup/steps/kanban.rs index 0ba6206e..b829a723 100644 --- a/src/ui/setup/steps/kanban.rs +++ b/src/ui/setup/steps/kanban.rs @@ -70,6 +70,21 @@ impl SetupScreen { // Supported providers list let supported = Paragraph::new(vec![ + Line::from(vec![ + Span::raw(" • "), + Span::styled( + "Operator", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" ("), + Span::styled( + "built in - your tickets are the board", + Style::default().fg(Color::DarkGray), + ), + Span::raw(")"), + ]), Line::from(vec![ Span::raw(" • "), Span::styled("Jira Cloud", Style::default().fg(Color::White)), @@ -137,6 +152,7 @@ impl SetupScreen { }; let provider_name = match provider.provider_type { + KanbanProviderType::Operator => "Operator", KanbanProviderType::Jira => "Jira", KanbanProviderType::Linear => "Linear", KanbanProviderType::Github => "GitHub", diff --git a/src/ui/setup/steps/license.rs b/src/ui/setup/steps/license.rs new file mode 100644 index 00000000..957b088a --- /dev/null +++ b/src/ui/setup/steps/license.rs @@ -0,0 +1,142 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::licensing::{LicenseResponse, LicenseStatus}; +use crate::ui::dialogs::centered_rect; +use crate::ui::setup::SetupScreen; + +impl SetupScreen { + pub(crate) fn render_license_step(&self, frame: &mut Frame) { + let area = centered_rect(72, 76, frame.area()); + frame.render_widget(Clear, area); + let block = Block::default() + .title(" Operator Premium ") + .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(3), + Constraint::Min(6), + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(2), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new("Premium adds remote execution").style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + chunks[0], + ); + + frame.render_widget( + Paragraph::new( + "Multiple local agents and local containers are free. \ + SSH hosts and Coder workspaces require a licence.", + ) + .style(Style::default().fg(Color::Gray)), + chunks[1], + ); + + frame.render_widget( + Paragraph::new(terms_lines(self.license.as_ref())), + chunks[2], + ); + + self.license_input.render( + frame, + chunks[3], + "paste a licence key, or leave blank", + true, + self.license_error.is_some(), + ); + + if let Some(error) = &self.license_error { + frame.render_widget( + Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), + chunks[4], + ); + } + + frame.render_widget( + Paragraph::new("Enter Continue (installs a pasted key) Esc Back") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)), + chunks[5], + ); + } +} + +/// Status colour follows the semantic role, not the literal word: a missing +/// licence is the free tier working as intended, not a fault. +fn status_style(status: LicenseStatus) -> (&'static str, Color) { + match status { + LicenseStatus::Valid => ("Premium", Color::Green), + LicenseStatus::Missing => ("Free", Color::Gray), + LicenseStatus::Expired => ("Expired", Color::Yellow), + LicenseStatus::NotYetValid => ("Not yet valid", Color::Yellow), + LicenseStatus::Invalid => ("Invalid", Color::Red), + } +} + +fn field(label: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:<18}"), Style::default().fg(Color::DarkGray)), + Span::raw(value), + ]) +} + +fn timestamp(seconds: i64) -> String { + chrono::DateTime::from_timestamp(seconds, 0).map_or_else( + || "-".to_string(), + |t| t.format("%Y-%m-%d %H:%M UTC").to_string(), + ) +} + +fn terms_lines(license: Option<&LicenseResponse>) -> Vec> { + let Some(license) = license else { + return vec![Line::from(Span::styled( + "Reading licence…", + Style::default().fg(Color::DarkGray), + ))]; + }; + let (label, color) = status_style(license.status); + let mut lines = vec![ + Line::from(vec![ + Span::styled( + format!("{:<18}", "Status"), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + ]), + field("Configuration", license.profile_id.to_string()), + ]; + if let Some(terms) = &license.terms { + lines.push(field("Licensed to", terms.sub.clone())); + lines.push(field("Licence ID", terms.jti.clone())); + lines.push(field("Tier", terms.tier.clone())); + lines.push(field("Valid from", timestamp(terms.nbf))); + lines.push(field("Expires", timestamp(terms.exp))); + } + if let Some(url) = &license.purchase_url { + lines.push(field("Premium", url.clone())); + } + lines +} diff --git a/src/ui/setup/steps/mod.rs b/src/ui/setup/steps/mod.rs index 125b7e2e..3c2c4214 100644 --- a/src/ui/setup/steps/mod.rs +++ b/src/ui/setup/steps/mod.rs @@ -4,9 +4,11 @@ mod acceptance; mod admin_password; mod collection; mod confirm; +mod execution_mode; mod git; mod hosted; mod kanban; +mod license; mod model_server; mod startup; mod target; diff --git a/src/ui/setup/steps/welcome.rs b/src/ui/setup/steps/welcome.rs index 2a038a8c..6ebfa14f 100644 --- a/src/ui/setup/steps/welcome.rs +++ b/src/ui/setup/steps/welcome.rs @@ -41,6 +41,9 @@ impl SetupScreen { Constraint::Length(1), // Spacer Constraint::Length(2), // Description Constraint::Length(1), // Spacer + Constraint::Length(1), // Configuration name label + Constraint::Length(3), // Configuration name + Constraint::Length(1), // Validation error Constraint::Length(6), // Detected LLM Tools Constraint::Length(1), // Spacer Constraint::Min(6), // Discovered projects by tool @@ -65,6 +68,37 @@ impl SetupScreen { .alignment(Alignment::Center); frame.render_widget(desc, chunks[2]); + frame.render_widget( + Paragraph::new("Configuration name · lowercase letters, numbers, - and _") + .style(Style::default().fg(Color::Yellow)), + chunks[4], + ); + let name_border = if self.configuration_name_error.is_some() { + Color::Red + } else { + Color::Cyan + }; + frame.render_widget( + Paragraph::new(self.configuration_name.as_str()).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(name_border)), + ), + chunks[5], + ); + if let Some(error) = &self.configuration_name_error { + frame.render_widget( + Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), + chunks[6], + ); + } + let name_inner = Block::default().borders(Borders::ALL).inner(chunks[5]); + let name_width = u16::try_from(self.configuration_name.len()).unwrap_or(u16::MAX); + frame.set_cursor_position(( + name_inner.x + name_width.min(name_inner.width), + name_inner.y, + )); + // Detected LLM Tools let mut tools_text = vec![Line::from(Span::styled( "Detected LLM Tools:", @@ -103,7 +137,7 @@ impl SetupScreen { }; tools_text.push(line); } - frame.render_widget(Paragraph::new(tools_text), chunks[4]); + frame.render_widget(Paragraph::new(tools_text), chunks[7]); // Discovered projects by tool let mut projects_text = vec![Line::from(Span::styled( @@ -133,7 +167,7 @@ impl SetupScreen { Style::default().fg(Color::DarkGray), ))); } - frame.render_widget(Paragraph::new(projects_text), chunks[6]); + frame.render_widget(Paragraph::new(projects_text), chunks[9]); // Path info let path_info = Paragraph::new(Line::from(vec![ @@ -141,7 +175,7 @@ impl SetupScreen { Span::styled(&self.tickets_path, Style::default().fg(Color::White)), ])) .alignment(Alignment::Center); - frame.render_widget(path_info, chunks[8]); + frame.render_widget(path_info, chunks[11]); // Footer let footer = Paragraph::new(Line::from(vec![ @@ -151,6 +185,6 @@ impl SetupScreen { Span::raw(" cancel"), ])) .alignment(Alignment::Center); - frame.render_widget(footer, chunks[9]); + frame.render_widget(footer, chunks[12]); } } diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index 3b59b325..51b894dc 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -4,6 +4,7 @@ use super::types::*; use super::SetupScreen; use crate::api::providers::model_server::ModelServerKind; use crate::config::SessionWrapperType; +use ratatui::crossterm::event::KeyCode; use std::collections::HashMap; #[test] @@ -41,6 +42,22 @@ fn test_setup_screen_with_no_detected_tools() { assert_eq!(screen.step, SetupStep::Welcome); } +#[test] +fn welcome_requires_a_valid_configuration_name() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.configuration_name.clear(); + + assert!(matches!(screen.confirm(), SetupResult::Continue)); + assert_eq!(screen.step, SetupStep::Welcome); + assert!(screen.configuration_name_error.is_some()); + + for character in "team_1".chars() { + screen.handle_configuration_name_key(KeyCode::Char(character)); + } + assert!(matches!(screen.confirm(), SetupResult::Continue)); + assert_eq!(screen.step, SetupStep::License); +} + #[test] fn test_setup_screen_with_multiple_tools() { let tools = vec![ @@ -180,8 +197,9 @@ fn test_setup_navigation_to_worktree_preference() { screen.step = SetupStep::SessionWrapperChoice; screen.selected_wrapper = SessionWrapperType::Tmux; screen.wrapper_state.select(Some(0)); // Select tmux + screen.remote_execution = true; - // SessionWrapperChoice -> ExecutionTarget + // SessionWrapperChoice -> ExecutionTarget, for a remote setup screen.confirm(); assert_eq!(screen.step, SetupStep::ExecutionTarget); } @@ -217,6 +235,7 @@ fn test_setup_navigation_vscode_path() { fn test_setup_worktree_preference_go_back() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); screen.step = SetupStep::WorktreePreference; + screen.remote_execution = true; screen.go_back(); assert_eq!(screen.step, SetupStep::ExecutionTarget); @@ -332,20 +351,21 @@ fn test_tmux_onboarding_proceeds_if_available() { } #[test] -fn test_kanban_info_go_back_returns_to_welcome() { +fn test_kanban_info_go_back_returns_to_execution_mode() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); - // Kanban setup is now the first step after Welcome. + // Kanban setup follows the licence and execution-mode screens. screen.step = SetupStep::KanbanInfo; screen.go_back(); - assert_eq!(screen.step, SetupStep::Welcome); + assert_eq!(screen.step, SetupStep::ExecutionMode); } #[test] -fn test_welcome_advances_to_kanban_info() { +fn test_welcome_advances_to_license_and_detects_kanban_providers() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.configuration_name = "workspace".to_string(); assert_eq!(screen.step, SetupStep::Welcome); screen.confirm(); - assert_eq!(screen.step, SetupStep::KanbanInfo); + assert_eq!(screen.step, SetupStep::License); assert!(screen.kanban_detection_complete); } @@ -734,9 +754,9 @@ fn at_kanban_info() -> SetupScreen { } #[test] -fn test_kanban_info_follows_welcome() { +fn test_kanban_info_follows_execution_mode() { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); - screen.step = SetupStep::Welcome; + screen.step = SetupStep::ExecutionMode; screen.confirm(); assert_eq!(screen.step, SetupStep::KanbanInfo); @@ -798,6 +818,17 @@ fn test_collection_source_goes_back_to_git_provider() { assert_eq!(screen.step, SetupStep::GitProvider); } +/// A licence that verifies as currently valid, for walks that need entitlement. +fn premium_license() -> crate::licensing::LicenseResponse { + crate::licensing::LicenseResponse { + status: crate::licensing::LicenseStatus::Valid, + profile_id: uuid::Uuid::new_v4(), + premium: true, + terms: None, + purchase_url: None, + } +} + /// 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 @@ -810,16 +841,29 @@ fn test_wizard_walk_visits_every_catalog_step() { ]; let mut visited = std::collections::HashSet::new(); - for wrapper in [ - SessionWrapperType::Tmux, - SessionWrapperType::Vscode, - SessionWrapperType::Cmux, - SessionWrapperType::Zellij, + // The remote branch is walked too: execution-target is only reachable from + // it, and exempting it here would hide exactly the defect this test exists + // for. Coder cannot combine with Zellij, so remote is walked on tmux. + for (wrapper, remote) in [ + (SessionWrapperType::Tmux, false), + (SessionWrapperType::Vscode, false), + (SessionWrapperType::Cmux, false), + (SessionWrapperType::Zellij, false), + (SessionWrapperType::Tmux, true), ] { let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.configuration_name = "workspace".to_string(); screen.tmux_status = TmuxDetectionStatus::Available { version: "3.4".to_string(), }; + if remote { + screen.license = Some(premium_license()); + screen + .execution_mode_state + .select(Some(super::REMOTE_EXECUTION_OPTION_INDEX)); + screen.coder_target_name = "coder-agents".to_string(); + screen.coder_template = "rust".to_string(); + } visited.insert(screen.step); for _ in 0..SetupStep::ALL.len() * 2 { @@ -836,6 +880,13 @@ fn test_wizard_walk_visits_every_catalog_step() { screen.select_next(); } } + // The remote branch must pick the Coder target, not fall back to + // local, or execution-target would be visited without exercising it. + if remote && screen.step == SetupStep::ExecutionTarget { + screen + .execution_target_state + .select(Some(super::CODER_TARGET_OPTION_INDEX)); + } // `confirm` commits the highlighted wrapper, so steer the list. if screen.step == SetupStep::SessionWrapperChoice { let i = SessionWrapperOption::all() @@ -1109,3 +1160,86 @@ fn test_git_provider_status_is_recorded_per_provider() { ); assert!(!screen.git_provider_status.contains_key("github")); } + +/// A local-only setup must never stop on the execution-target step: the step +/// exists to configure a remote target, and asking about one twice was the +/// defect that made it unreachable in the web wizard. +#[test] +fn test_execution_target_is_skipped_in_both_directions_for_local_execution() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.remote_execution = false; + + screen.step = SetupStep::SessionWrapperChoice; + screen.confirm(); + assert_eq!(screen.step, SetupStep::WorktreePreference); + + screen.go_back(); + assert_eq!(screen.step, SetupStep::SessionWrapperChoice); +} + +#[test] +fn test_execution_target_is_visited_in_both_directions_for_remote_execution() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.remote_execution = true; + + screen.step = SetupStep::SessionWrapperChoice; + screen.confirm(); + assert_eq!(screen.step, SetupStep::ExecutionTarget); + + screen.step = SetupStep::WorktreePreference; + screen.go_back(); + assert_eq!(screen.step, SetupStep::ExecutionTarget); +} + +/// The first three screens are welcome, licence, execution mode - in that +/// order, on this renderer as well as the web one. +#[test] +fn test_wizard_opens_with_welcome_then_license_then_execution_mode() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.configuration_name = "workspace".to_string(); + assert_eq!(screen.step, SetupStep::Welcome); + screen.confirm(); + assert_eq!(screen.step, SetupStep::License); + screen.confirm(); + assert_eq!(screen.step, SetupStep::ExecutionMode); + screen.confirm(); + assert_eq!(screen.step, SetupStep::KanbanInfo); + + screen.go_back(); + assert_eq!(screen.step, SetupStep::ExecutionMode); + screen.go_back(); + assert_eq!(screen.step, SetupStep::License); + screen.go_back(); + assert_eq!(screen.step, SetupStep::Welcome); +} + +/// Choosing remote execution without a licence must not advance: the paywall +/// is the whole point of the step. +#[test] +fn test_remote_execution_without_entitlement_does_not_advance() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionMode; + screen + .execution_mode_state + .select(Some(super::REMOTE_EXECUTION_OPTION_INDEX)); + + screen.confirm(); + + assert_eq!(screen.step, SetupStep::ExecutionMode); + assert!(screen.license_error.is_some()); + assert!(!screen.remote_execution || !screen.premium_entitled()); +} + +#[test] +fn test_a_rejected_license_key_returns_to_the_license_step() { + let mut screen = SetupScreen::new(".tickets".to_string(), vec![], HashMap::new()); + screen.step = SetupStep::ExecutionMode; + + screen.set_license_outcome(Err("unknown license signing key".to_string())); + + assert_eq!(screen.step, SetupStep::License); + assert_eq!( + screen.license_error.as_deref(), + Some("unknown license signing key") + ); +} diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index 8230c30e..a3f0bf4d 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -141,7 +141,9 @@ fn provider_base_url(p: &DetectedKanbanProvider) -> String { } KanbanProviderType::Linear => "https://linear.app".to_string(), KanbanProviderType::Github => "https://github.com".to_string(), - KanbanProviderType::Openspec => p.provider_type.setup_url().to_string(), + KanbanProviderType::Operator | KanbanProviderType::Openspec => { + p.provider_type.setup_url().to_string() + } } } diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 1f62ead8..da1caa61 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -17,7 +17,8 @@ use crate::rest::RestApiStatus; use super::sections::{ ConfigSection, ConnectionsSection, DelegatorSection, GitSection, IssueTypeSection, - KanbanSection, LlmSection, ManagedProjectsSection, ModelServerSection, WorkflowsSection, + KanbanSection, LicenseSection, LlmSection, ManagedProjectsSection, ModelServerSection, + RemoteTargetsSection, WorkflowsSection, }; // --------------------------------------------------------------------------- @@ -50,6 +51,10 @@ pub enum SectionId { ManagedProjects, #[serde(rename = "workflows")] Workflows, + #[serde(rename = "remote-targets")] + RemoteTargets, + #[serde(rename = "license")] + License, } /// Health state of a section - controls the header color. @@ -101,6 +106,8 @@ impl SectionId { SectionId::Delegators => "delegators", SectionId::ManagedProjects => "projects", SectionId::Workflows => "workflows", + SectionId::RemoteTargets => "remote-targets", + SectionId::License => "license", } } } @@ -643,6 +650,19 @@ pub struct StatusSnapshot { pub acp_active_sessions: usize, /// Whether the embedded SPA (ui/) was compiled into the binary via the `embed-ui` feature. pub embed_ui_available: bool, + /// Premium licence state for this configuration. + pub license: crate::licensing::LicenseResponse, + /// Remote execution targets declared for this configuration. + pub remote_targets: Vec, +} + +/// One remote execution target, as the status panel renders it. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct RemoteTargetInfo { + pub name: String, + pub kind: &'static str, + pub detail: String, } impl StatusSnapshot { @@ -680,6 +700,12 @@ impl StatusSnapshot { // catalog the Kanban section renders against. use crate::api::providers::kanban::KanbanProviderType; let mut kanban_providers: Vec = Vec::new(); + // The built-in board leads and is always present: it is the destination + // every other provider syncs into, and it has no config to enumerate. + kanban_providers.push(KanbanProviderInfo { + provider_type: KanbanProviderType::Operator.slug().to_string(), + domain: tickets_dir.clone(), + }); for domain in config.kanban.jira.keys() { kanban_providers.push(KanbanProviderInfo { provider_type: KanbanProviderType::Jira.slug().to_string(), @@ -837,6 +863,8 @@ impl StatusSnapshot { acp_stdio_advertised: acp_status.is_advertised(), acp_active_sessions: acp_status.active_sessions(), embed_ui_available: cfg!(feature = "embed-ui"), + license: crate::licensing::status(config), + remote_targets: remote_target_infos(config), } } @@ -955,6 +983,8 @@ pub fn all_sections() -> Vec> { Box::new(DelegatorSection), Box::new(ManagedProjectsSection), Box::new(WorkflowsSection), + Box::new(RemoteTargetsSection), + Box::new(LicenseSection), ] } @@ -1427,10 +1457,36 @@ fn web_ui_route_for(section: SectionId) -> Option<&'static str> { SectionId::Kanban => Some("/config"), SectionId::IssueTypes => Some("/issuetypes"), SectionId::ManagedProjects => Some("/config"), + SectionId::RemoteTargets => Some("/remote-targets"), + SectionId::License => Some("/settings/license"), _ => None, } } +/// The remote targets declared for this configuration, in config order. +/// +/// Only ssh/coder: local and docker are built in and are not what the Remote +/// Targets section is about. +fn remote_target_infos(config: &Config) -> Vec { + config + .targets + .iter() + .filter_map(|target| match &target.kind { + crate::config::TargetKind::Ssh(ssh) => Some(RemoteTargetInfo { + name: target.name.clone(), + kind: "SSH", + detail: format!("{}:{}", ssh.ssh_alias, ssh.workdir), + }), + crate::config::TargetKind::Coder(coder) => Some(RemoteTargetInfo { + name: target.name.clone(), + kind: "Coder", + detail: coder.template.clone(), + }), + _ => None, + }) + .collect() +} + // Tests // --------------------------------------------------------------------------- @@ -1456,6 +1512,8 @@ mod tests { "delegators", "projects", "workflows", + "remote-targets", + "license", ] ); } @@ -1598,8 +1656,14 @@ mod tests { .find(|d| d.id == "kanban") .expect("kanban section present"); - // Every supported provider is offered when none are connected. - assert_eq!(kanban.children.len(), 4); + // The built-in board leads as a connected row, then every external + // provider is offered. + assert_eq!(kanban.children.len(), 5); + assert_eq!(kanban.children[0].label, "operator"); + assert!( + !kanban.children[0].id.starts_with("configure-"), + "the built-in board is not something to configure" + ); for (id, expected_url) in [ ("configure-jira", "id.atlassian.com"), @@ -1678,6 +1742,8 @@ mod tests { acp_stdio_advertised: true, acp_active_sessions: 0, embed_ui_available: true, + license: crate::licensing::LicenseResponse::free(uuid::Uuid::nil()), + remote_targets: Vec::new(), } } diff --git a/tests/docs_structure.rs b/tests/docs_structure.rs index b9db493b..9e2cdbb2 100644 --- a/tests/docs_structure.rs +++ b/tests/docs_structure.rs @@ -22,10 +22,7 @@ use operator::integrations::{all_integrations, SupportStatus, Vertical}; const NAV_DEFERRED: &[(&str, &str)] = &[("workflows", "claude"), ("workflows", "agnt")]; /// Leaf URLs under a vertical section that are supporting pages rather than catalog integrations. -const NAV_EXTRA_PAGES: &[&str] = &[ - "/getting-started/git/provider-support/", - "/getting-started/sessions/remote-hosts/", // execution-target concept page, not a session wrapper vertical -]; +const NAV_EXTRA_PAGES: &[&str] = &["/getting-started/git/provider-support/"]; /// Published pages intentionally not linked from the sidebar. const NAV_ORPHAN_ALLOWLIST: &[&str] = &[ @@ -48,7 +45,7 @@ const NAV_TITLE_EXCEPTIONS: &[(&str, &str)] = &[ ("/getting-started/sessions/tmux/", "tmux"), ("/getting-started/sessions/cmux/", "cmux"), ("/getting-started/sessions/zellij/", "Zellij"), - ("/getting-started/sessions/vscode/", "VS Code Extension"), + ("/getting-started/ides/vscode/", "VS Code Extension"), ("/cli/", "CLI"), ("/shortcuts/", "Shortcuts"), ("/schemas/", "Overview"), @@ -195,7 +192,9 @@ fn published_pages() -> Vec { let content = std::fs::read_to_string(&path).expect("page should be readable"); let unpublished = front_matter(&content) .is_some_and(|fm| fm.lines().any(|l| l.trim() == "published: false")); - if !unpublished { + let redirect = front_matter(&content) + .is_some_and(|fm| fm.lines().any(|l| l.trim().starts_with("redirect_to:"))); + if !unpublished && !redirect { let rel = path.strip_prefix(&docs).unwrap(); pages.push(rel.to_string_lossy().to_string()); } @@ -270,6 +269,7 @@ fn test_nav_vertical_leaves_map_to_catalog() { .collect(); let catalog_urls: BTreeSet = all_integrations() .iter() + .filter(|e| e.is_public()) .filter_map(|e| e.docs_path.map(|p| format!("/{p}/"))) .collect(); for (item_url, leaves) in &by_item { @@ -299,7 +299,10 @@ fn test_catalog_icons_exist() { e.slug ); } - if e.status >= SupportStatus::Alpha && e.docs_path.is_some() { + if e.status >= SupportStatus::Alpha + && e.docs_path.is_some() + && !matches!(e.vertical, Vertical::Transport | Vertical::RemoteTargets) + { assert!( e.icon.is_some(), "documented Alpha+ entry '{}/{}' must declare a brand icon", @@ -351,7 +354,7 @@ fn test_nav_icons_match_catalog() { .to_string(); if let Some(stem) = name.strip_suffix(".svg") { assert!( - referenced.contains(stem), + referenced.contains(stem) || all_integrations().iter().any(|e| !e.is_public() && e.icon == Some(stem)), "docs/assets/icons/{name} is referenced by no navigation.yml entry - remove it or wire it up" ); } diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index 1da750bc..373c75c6 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -286,18 +286,6 @@ fn test_tui_has_all_status_sections() { } } -/// Every canonical section id must be referenced by the VS Code status provider. -#[test] -fn test_vscode_has_all_status_sections() { - let status_provider_src = include_str!("../vscode-extension/src/status-provider.ts"); - for id in canonical_section_ids() { - assert!( - status_provider_src.contains(&id), - "VSCode status-provider.ts is missing sectionId '{id}'" - ); - } -} - /// The web UI sidebar (`STATUS_KEYS` in concepts.ts) must list exactly the /// canonical sections, in the same order, as the TUI / VS Code surfaces. #[test] @@ -439,3 +427,32 @@ mod detailed_tests { ); } } + +/// Authentication is server-scoped: every auth store must be opened at +/// `auth_state_path()`, never `state_path()`. The two coincide by default, +/// which is exactly why a drift here would go unnoticed until a server hosting +/// more than one configuration split its admin account in two. +#[test] +fn test_auth_stores_are_opened_at_the_server_auth_path() { + const SOURCES: &[(&str, &str)] = &[ + ("src/main.rs", include_str!("../src/main.rs")), + ("src/app/mod.rs", include_str!("../src/app/mod.rs")), + ("src/app/tickets.rs", include_str!("../src/app/tickets.rs")), + ("src/rest/state.rs", include_str!("../src/rest/state.rs")), + ( + "src/auth/callback.rs", + include_str!("../src/auth/callback.rs"), + ), + ]; + for (name, source) in SOURCES { + for (number, line) in source.lines().enumerate() { + let is_open = + line.contains("AuthStore::open") || line.contains("AuthContext::initialize"); + assert!( + !(is_open && line.contains("state_path()") && !line.contains("auth_state_path()")), + "{name}:{} opens the auth store at state_path(); use auth_state_path()", + number + 1 + ); + } + } +} diff --git a/tests/helm_chart.rs b/tests/helm_chart.rs index dc50969a..4453370b 100644 --- a/tests/helm_chart.rs +++ b/tests/helm_chart.rs @@ -210,8 +210,7 @@ fn test_rendered_image_tag_matches_chart_app_version() { ); } -/// Kept honest against the helper above: a chart path that does not exist must -/// not silently pass as "helm unavailable". +/// A chart path that does not exist must not silently pass as "helm unavailable". #[test] fn test_chart_directory_exists() { assert!( diff --git a/tests/launch_identity.rs b/tests/launch_identity.rs new file mode 100644 index 00000000..76aa3d67 --- /dev/null +++ b/tests/launch_identity.rs @@ -0,0 +1,121 @@ +//! Every launch surface must tell the agent which configuration it belongs to. +//! +//! An agent that reports to the wrong configuration is harder to diagnose than one with no identity at all: +//! the callback succeeds, against the wrong queue. `opr8r` resolves the id from `--profile-id`, then `OPERATOR_PROFILE_ID`, then `api-session.json` +use std::path::PathBuf; + +use operator::agents::{LaunchOptions, Launcher}; +use operator::config::{Config, TargetDef}; +use operator::queue::Ticket; + +const TICKET: &str = r"--- +id: TASK-700 +priority: P2-medium +status: queued +--- + +# Task: Carry the configuration id + +## Context +Launched locally; the assertion is about the environment, not the agent. +"; + +struct Workspace { + config: Config, + tickets: PathBuf, + _directory: tempfile::TempDir, +} + +fn workspace() -> Workspace { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let tickets = root.join("tickets"); + for sub in ["queue", "in-progress", "done", "operator"] { + std::fs::create_dir_all(tickets.join(sub)).unwrap(); + } + std::fs::create_dir_all(root.join("testproject")).unwrap(); + + let mut config = Config::default(); + config.paths.tickets = tickets.to_string_lossy().into_owned(); + config.paths.state = tickets.join("operator").to_string_lossy().into_owned(); + config.paths.projects = root.to_string_lossy().into_owned(); + config.paths.worktrees = root.join("worktrees").to_string_lossy().into_owned(); + config.projects = vec!["testproject".to_string()]; + config.profile.id = uuid::Uuid::new_v4(); + // A stand-in tool: named for a real provider so the permission translator + // resolves, but pointed at a harmless binary. `prepare_launch` builds a + // command without executing it. + config.llm_tools.detected = vec![operator::config::DetectedTool { + name: "claude".to_string(), + path: "/bin/cat".to_string(), + version: "0.0.0-test".to_string(), + min_version: None, + version_ok: true, + model_aliases: vec!["sonnet".to_string()], + command_template: "cat {{prompt_file}}".to_string(), + capabilities: operator::config::ToolCapabilities::default(), + yolo_flags: Vec::new(), + health_ok: true, + }]; + config.llm_tools.detection_complete = true; + config.llm_tools.default_tool = Some("claude".to_string()); + // Built from TOML so serde supplies every defaulted field; the struct gains + // fields often enough that spelling them out here would rot. + config.delegators = vec![toml::from_str( + r#" +name = "test-agent" +llm_tool = "claude" +model = "sonnet" +"#, + ) + .expect("delegator fixture parses")]; + + let filename = format!( + "{}-TASK-testproject-task_700.md", + chrono::Local::now().format("%Y%m%d-%H%M") + ); + std::fs::write(tickets.join("queue").join(&filename), TICKET).unwrap(); + + Workspace { + config, + tickets, + _directory: directory, + } +} + +fn queued_ticket(workspace: &Workspace) -> Ticket { + let entry = std::fs::read_dir(workspace.tickets.join("queue")) + .unwrap() + .filter_map(Result::ok) + .find(|e| e.path().extension().is_some_and(|x| x == "md")) + .expect("the queued ticket"); + Ticket::from_file(&entry.path()).expect("ticket parses") +} + +/// `PreparedLaunch.env_vars` feeds the non-shell launch surfaces. It carried +/// the agent, ticket and API url but not the configuration id, so those agents +/// fell back to whichever configuration answered first. +#[tokio::test] +async fn a_prepared_launch_carries_the_configuration_id() { + let workspace = workspace(); + let expected = workspace.config.profile.id.to_string(); + let launcher = Launcher::new(&workspace.config).expect("launcher"); + let ticket = queued_ticket(&workspace); + + let prepared = launcher + .prepare_launch( + &ticket, + LaunchOptions { + target: TargetDef::local(), + ..LaunchOptions::default() + }, + ) + .await + .expect("a local launch needs no licence"); + + assert_eq!( + prepared.env_vars.get("OPERATOR_PROFILE_ID"), + Some(&expected), + "env_vars must name the configuration this launch belongs to" + ); +} diff --git a/tests/licensing_integration.rs b/tests/licensing_integration.rs new file mode 100644 index 00000000..4fbca3a0 --- /dev/null +++ b/tests/licensing_integration.rs @@ -0,0 +1,329 @@ +//! Premium licence verification, end to end through the public API. +//! +//! Verification is offline and the keys are compiled in, so these tests build a +//! [`Verifier`] over a throwaway Ed25519 key and drive the same +//! `status_with` / `install_with` entry points the bundled path uses. Nothing +//! here depends on how the shipped binary was configured. + +use std::collections::BTreeMap; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use uuid::Uuid; + +use operator::config::Config; +use operator::licensing::{ + install_with, status_with, LicenseStatus, LicenseTerms, Verifier, LICENSE_AUDIENCE, + LICENSE_VERSION, PREMIUM_TIER, +}; + +const KID: &str = "test-key"; +const ISSUER: &str = "operator-licensing-test"; + +/// A configuration rooted in `directory`, bound to `profile`. +fn config_for(directory: &std::path::Path, profile: Uuid) -> Config { + let mut config = Config::default(); + config.paths.state = directory.to_string_lossy().into_owned(); + config.paths.tickets = directory.join("tickets").to_string_lossy().into_owned(); + config.profile.id = profile; + config +} + +struct Issuer { + verifier: Verifier, + encoding: EncodingKey, +} + +impl Issuer { + fn new() -> Self { + let document = Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(document.as_ref()).unwrap(); + let keys = BTreeMap::from([(KID.to_string(), STANDARD.encode(pair.public_key().as_ref()))]); + Self { + verifier: Verifier::from_keys(keys, ISSUER.to_string()), + encoding: EncodingKey::from_ed_der(document.as_ref()), + } + } + + fn sign(&self, terms: &LicenseTerms) -> String { + self.sign_with(terms, Algorithm::EdDSA, Some(KID.to_string())) + } + + fn sign_with(&self, terms: &LicenseTerms, alg: Algorithm, kid: Option) -> String { + let mut header = Header::new(alg); + header.kid = kid; + STANDARD.encode(jsonwebtoken::encode(&header, terms, &self.encoding).unwrap()) + } +} + +fn terms_for(profile: Uuid) -> LicenseTerms { + LicenseTerms { + version: LICENSE_VERSION, + iss: ISSUER.to_string(), + aud: LICENSE_AUDIENCE.to_string(), + sub: "customer@example.test".to_string(), + jti: Uuid::new_v4().to_string(), + profile_id: profile, + tier: PREMIUM_TIER.to_string(), + iat: 1_000, + nbf: 1_000, + exp: 2_000, + } +} + +/// Install `key`, then report the status the configuration ends up with. +fn install_then_status(issuer: &Issuer, key: &str, now: i64) -> (bool, LicenseStatus) { + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let config = config_for(directory.path(), profile); + let accepted = install_with(&config, &issuer.verifier, now, key).is_ok(); + let status = status_with(&config, &issuer.verifier, now).status; + (accepted, status) +} + +#[test] +fn a_valid_licence_installs_and_grants_premium() { + let issuer = Issuer::new(); + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let config = config_for(directory.path(), profile); + let terms = terms_for(profile); + + let installed = install_with(&config, &issuer.verifier, 1_500, &issuer.sign(&terms)).unwrap(); + + assert_eq!(installed.status, LicenseStatus::Valid); + assert!(installed.premium); + let reported = installed.terms.expect("verified terms are reported"); + assert_eq!(reported.sub, terms.sub); + assert_eq!(reported.jti, terms.jti); + assert_eq!(reported.profile_id, profile); +} + +#[test] +fn a_licence_read_never_returns_the_raw_key() { + let issuer = Issuer::new(); + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let config = config_for(directory.path(), profile); + let key = issuer.sign(&terms_for(profile)); + install_with(&config, &issuer.verifier, 1_500, &key).unwrap(); + + let response = status_with(&config, &issuer.verifier, 1_500); + let json = serde_json::to_string(&response).unwrap(); + + assert!( + !json.contains(&key), + "the licence key must never appear in a read response" + ); +} + +#[test] +fn expiry_and_start_dates_are_evaluated_against_the_clock() { + let issuer = Issuer::new(); + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let config = config_for(directory.path(), profile); + let terms = terms_for(profile); + install_with(&config, &issuer.verifier, terms.nbf, &issuer.sign(&terms)).unwrap(); + + for (now, expected) in [ + (terms.nbf - 1, LicenseStatus::NotYetValid), + (terms.nbf, LicenseStatus::Valid), + (terms.exp - 1, LicenseStatus::Valid), + (terms.exp, LicenseStatus::Expired), + ] { + let response = status_with(&config, &issuer.verifier, now); + assert_eq!(response.status, expected, "at now={now}"); + assert_eq!(response.premium, expected == LicenseStatus::Valid); + } +} + +#[test] +fn every_rejected_licence_shape_is_refused() { + let issuer = Issuer::new(); + let profile = Uuid::new_v4(); + let base = terms_for(profile); + + let wrong_issuer = LicenseTerms { + iss: "attacker".to_string(), + ..base.clone() + }; + let wrong_audience = LicenseTerms { + aud: "operator-api".to_string(), + ..base.clone() + }; + let unknown_tier = LicenseTerms { + tier: "enterprise".to_string(), + ..base.clone() + }; + let wrong_version = LicenseTerms { + version: LICENSE_VERSION + 1, + ..base.clone() + }; + let other_configuration = LicenseTerms { + profile_id: Uuid::new_v4(), + ..base.clone() + }; + + let cases: Vec<(&str, String)> = vec![ + ("malformed base64", "not-a-licence".to_string()), + ("not a JWT", STANDARD.encode("plain text")), + ("wrong issuer", issuer.sign(&wrong_issuer)), + ("wrong audience", issuer.sign(&wrong_audience)), + ("unknown tier", issuer.sign(&unknown_tier)), + ("unsupported version", issuer.sign(&wrong_version)), + ("another configuration", issuer.sign(&other_configuration)), + ( + "unknown key id", + issuer.sign_with(&base, Algorithm::EdDSA, Some("nope".to_string())), + ), + ("no key id", issuer.sign_with(&base, Algorithm::EdDSA, None)), + ]; + + for (name, key) in cases { + let (accepted, status) = install_then_status(&issuer, &key, 1_500); + assert!(!accepted, "{name} must not install"); + assert!( + matches!(status, LicenseStatus::Missing | LicenseStatus::Invalid), + "{name} left status {status:?}" + ); + } +} + +/// Algorithm confusion: the verifier pins EdDSA, so a symmetric token whose +/// "signature" the attacker also controls must never be considered. +#[test] +fn a_symmetric_algorithm_is_refused() { + let issuer = Issuer::new(); + let profile = Uuid::new_v4(); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some(KID.to_string()); + let hmac = EncodingKey::from_secret(b"not-the-signing-key"); + let token = jsonwebtoken::encode(&header, &terms_for(profile), &hmac).unwrap(); + + let (accepted, status) = install_then_status(&issuer, &STANDARD.encode(token), 1_500); + + assert!(!accepted, "an HS256 licence must not install"); + assert_eq!(status, LicenseStatus::Missing); +} + +#[test] +fn a_licence_signed_by_another_key_is_refused() { + let issuer = Issuer::new(); + let attacker = Issuer::new(); + let profile = Uuid::new_v4(); + // Signed by the attacker, presented under the real issuer's key id. + let forged = attacker.sign(&terms_for(profile)); + + let (accepted, status) = install_then_status(&issuer, &forged, 1_500); + + assert!(!accepted, "a foreign signature must not install"); + assert_eq!(status, LicenseStatus::Missing); +} + +#[test] +fn a_tampered_payload_is_refused() { + let issuer = Issuer::new(); + let profile = Uuid::new_v4(); + let signed = issuer.sign(&terms_for(profile)); + + // Re-encode the token with one payload byte changed. + let token = String::from_utf8(STANDARD.decode(&signed).unwrap()).unwrap(); + let mut parts: Vec = token.split('.').map(str::to_string).collect(); + let payload = parts[1].clone(); + parts[1] = payload + .chars() + .enumerate() + .map(|(i, c)| { + if i == 4 { + if c == 'A' { + 'B' + } else { + 'A' + } + } else { + c + } + }) + .collect(); + let tampered = STANDARD.encode(parts.join(".")); + + let (accepted, status) = install_then_status(&issuer, &tampered, 1_500); + + assert!(!accepted, "a tampered payload must not install"); + assert_eq!(status, LicenseStatus::Missing); +} + +/// An install validates before it writes, so a bad replacement is not a way to +/// knock out a working licence. +#[test] +fn an_invalid_replacement_preserves_the_installed_licence() { + let issuer = Issuer::new(); + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let config = config_for(directory.path(), profile); + let good = issuer.sign(&terms_for(profile)); + install_with(&config, &issuer.verifier, 1_500, &good).unwrap(); + + for bad in [ + "garbage".to_string(), + issuer.sign(&LicenseTerms { + tier: "enterprise".to_string(), + ..terms_for(profile) + }), + Issuer::new().sign(&terms_for(profile)), + ] { + assert!(install_with(&config, &issuer.verifier, 1_500, &bad).is_err()); + let response = status_with(&config, &issuer.verifier, 1_500); + assert_eq!(response.status, LicenseStatus::Valid); + assert!(response.premium, "the working licence must survive"); + } +} + +/// Renaming a configuration keeps its id, so the licence must keep verifying. +#[test] +fn a_licence_survives_renaming_its_configuration() { + let issuer = Issuer::new(); + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let mut config = config_for(directory.path(), profile); + install_with( + &config, + &issuer.verifier, + 1_500, + &issuer.sign(&terms_for(profile)), + ) + .unwrap(); + + config.profile.name = "renamed-workspace".to_string(); + + assert!(status_with(&config, &issuer.verifier, 1_500).premium); +} + +/// The licence is stored with owner-only permissions: it is a credential, not +/// ordinary configuration. +#[cfg(unix)] +#[test] +fn the_stored_licence_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let issuer = Issuer::new(); + let directory = tempfile::tempdir().unwrap(); + let profile = Uuid::new_v4(); + let config = config_for(directory.path(), profile); + install_with( + &config, + &issuer.verifier, + 1_500, + &issuer.sign(&terms_for(profile)), + ) + .unwrap(); + + let mode = std::fs::metadata(directory.path().join("license.key")) + .unwrap() + .permissions() + .mode(); + + assert_eq!(mode & 0o077, 0, "group and other must have no access"); +} diff --git a/tests/premium_enforcement.rs b/tests/premium_enforcement.rs new file mode 100644 index 00000000..9e2083b6 --- /dev/null +++ b/tests/premium_enforcement.rs @@ -0,0 +1,249 @@ +//! Remote execution is gated *before* anything is provisioned. +//! +//! The gate matters less for what it returns than for what it prevents: a +//! refused launch must leave no claimed ticket, no agent row, no worktree and +//! no session behind. These tests drive the real `Launcher` against a temporary +//! workspace and assert on the filesystem and state store afterwards. + +use std::path::{Path, PathBuf}; + +use operator::agents::{LaunchOptions, Launcher, RelaunchOptions}; +use operator::config::{Config, SshTarget, TargetDef, TargetKind}; +use operator::queue::Ticket; + +const TICKET: &str = r"--- +id: TASK-001 +priority: P2-medium +status: queued +--- + +# Task: Verify the entitlement gate + +## Context +Launched only if the configuration is entitled to the resolved target. +"; + +struct Workspace { + config: Config, + tickets: PathBuf, + _directory: tempfile::TempDir, +} + +impl Workspace { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let tickets = root.join("tickets"); + for sub in ["queue", "in-progress", "done", "operator"] { + std::fs::create_dir_all(tickets.join(sub)).unwrap(); + } + let project = root.join("testproject"); + std::fs::create_dir_all(&project).unwrap(); + + let mut config = Config::default(); + config.paths.tickets = tickets.to_string_lossy().into_owned(); + config.paths.state = tickets.join("operator").to_string_lossy().into_owned(); + config.paths.projects = root.to_string_lossy().into_owned(); + config.paths.worktrees = root.join("worktrees").to_string_lossy().into_owned(); + config.projects = vec!["testproject".to_string()]; + config.profile.id = uuid::Uuid::new_v4(); + + let filename = format!( + "{}-TASK-testproject-task_001.md", + chrono::Local::now().format("%Y%m%d-%H%M") + ); + std::fs::write(tickets.join("queue").join(&filename), TICKET).unwrap(); + + Self { + config, + tickets, + _directory: directory, + } + } + + fn ticket(&self) -> Ticket { + let queue = self.tickets.join("queue"); + let entry = std::fs::read_dir(&queue) + .unwrap() + .filter_map(Result::ok) + .find(|e| e.path().extension().is_some_and(|x| x == "md")) + .expect("the queued ticket"); + Ticket::from_file(&entry.path()).expect("ticket parses") + } + + fn queued(&self) -> usize { + count_markdown(&self.tickets.join("queue")) + } + + fn in_progress(&self) -> usize { + count_markdown(&self.tickets.join("in-progress")) + } + + fn agents(&self) -> usize { + operator::state::State::load(&self.config) + .map(|state| state.agents.len()) + .unwrap_or(0) + } + + fn worktrees_exist(&self) -> bool { + Path::new(&self.config.worktrees_path()) + .read_dir() + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false) + } +} + +fn count_markdown(dir: &Path) -> usize { + std::fs::read_dir(dir) + .map(|entries| { + entries + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|x| x == "md")) + .count() + }) + .unwrap_or(0) +} + +fn ssh_target() -> TargetDef { + TargetDef { + name: "build-host".to_string(), + display_name: None, + kind: TargetKind::Ssh(SshTarget { + ssh_alias: "build".to_string(), + workdir: "/srv/work".to_string(), + ssh_config_path: None, + }), + } +} + +fn options_for(target: TargetDef) -> LaunchOptions { + LaunchOptions { + target, + ..LaunchOptions::default() + } +} + +/// The headline claim: refused, and nothing happened. +#[tokio::test] +async fn an_unlicensed_ssh_launch_is_blocked_before_any_side_effect() { + let workspace = Workspace::new(); + let launcher = Launcher::new(&workspace.config).expect("launcher"); + let ticket = workspace.ticket(); + + let error = launcher + .prepare_launch(&ticket, options_for(ssh_target())) + .await + .expect_err("an unlicensed SSH launch must be refused"); + + assert!( + error.to_string().contains("Premium"), + "unexpected refusal: {error}" + ); + assert_eq!(workspace.queued(), 1, "the ticket must stay in the queue"); + assert_eq!(workspace.in_progress(), 0, "the ticket must not be claimed"); + assert_eq!(workspace.agents(), 0, "no agent may be recorded"); + assert!(!workspace.worktrees_exist(), "no worktree may be created"); +} + +/// Coder is the other premium target, and the one that provisions a remote +/// workspace - so the gate has to fire before the provisioning call, not after. +#[tokio::test] +async fn an_unlicensed_coder_launch_is_blocked_before_provisioning() { + let workspace = Workspace::new(); + let launcher = Launcher::new(&workspace.config).expect("launcher"); + let ticket = workspace.ticket(); + let target = TargetDef { + name: "coder-agents".to_string(), + display_name: None, + kind: TargetKind::Coder(operator::config::CoderConfig::default()), + }; + + let error = launcher + .prepare_launch(&ticket, options_for(target)) + .await + .expect_err("an unlicensed Coder launch must be refused"); + + assert!(error.to_string().contains("Premium"), "{error}"); + assert_eq!(workspace.queued(), 1); + assert_eq!(workspace.agents(), 0); +} + +/// `relaunch` recovers a dead session, and is a second way into remote +/// execution. It is gated identically. +#[tokio::test] +async fn an_unlicensed_remote_relaunch_is_refused() { + let workspace = Workspace::new(); + let launcher = Launcher::new(&workspace.config).expect("launcher"); + let ticket = workspace.ticket(); + let options = RelaunchOptions { + launch_options: options_for(ssh_target()), + ..RelaunchOptions::default() + }; + + let error = launcher + .prepare_relaunch(&ticket, options) + .await + .expect_err("an unlicensed relaunch must be refused"); + + assert!(error.to_string().contains("Premium"), "{error}"); + assert_eq!(workspace.queued(), 1); + assert_eq!(workspace.agents(), 0); +} + +/// The deprecated `[[hosts]]` path synthesises an SSH target, so it must be +/// gated exactly like a declared one - a legacy config is not a bypass. +#[test] +fn a_legacy_hosts_entry_resolves_to_a_gated_target() { + let workspace = Workspace::new(); + let target = operator::agents::delegator_resolution::resolve_named_target( + &workspace.config, + "build-host", + ); + + // Unknown until declared; once declared as a host it is an SSH target. + assert!(target.is_err(), "an undeclared target must not resolve"); + + let mut config = workspace.config; + config.hosts.push(operator::config::RemoteHost { + name: "legacy-box".to_string(), + ssh_alias: "legacy".to_string(), + workdir: "/srv/legacy".to_string(), + display_name: None, + ssh_config_path: None, + }); + + let resolved = + operator::agents::delegator_resolution::resolve_named_target(&config, "legacy-box") + .expect("a declared host resolves"); + + assert!( + matches!(resolved.kind, TargetKind::Ssh(_)), + "a legacy host is an SSH target" + ); + assert!( + operator::licensing::require_target(&config, &resolved).is_err(), + "the legacy path must be gated like any other remote target" + ); +} + +/// Local and container execution stay free. This is the other half of the +/// contract and the one a regression would quietly break. +#[test] +fn local_and_container_execution_need_no_licence() { + let workspace = Workspace::new(); + + for target in [ + TargetDef::local(), + TargetDef { + name: "docker".to_string(), + display_name: None, + kind: TargetKind::Docker(operator::config::DockerConfig::default()), + }, + ] { + assert!( + operator::licensing::require_target(&workspace.config, &target).is_ok(), + "{} must not require a licence", + target.name + ); + } +} diff --git a/tests/premium_http_contract.rs b/tests/premium_http_contract.rs new file mode 100644 index 00000000..eb11a494 --- /dev/null +++ b/tests/premium_http_contract.rs @@ -0,0 +1,282 @@ +//! The HTTP contract for entitlement: 401, 403 and 402 are three different +//! answers, and callers act on the difference. +//! +//! Driven in-process against `build_router`, so these assert the real router, +//! middleware and scope table rather than a hand-rolled stand-in. + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use tower::ServiceExt; + +use operator::auth::tokens::api_claims; +use operator::config::Config; +use operator::rest::state::ApiState; +use operator::rest::{build_router, dto::auth::Scope}; + +const SSH_TARGET: &str = + r#"{"name":"build-host","kind":"ssh","ssh_alias":"build","workdir":"/srv/work"}"#; + +struct Server { + state: ApiState, + _directory: tempfile::TempDir, +} + +impl Server { + fn start() -> Self { + let directory = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = directory + .path() + .join("state") + .to_string_lossy() + .into_owned(); + config.paths.tickets = directory + .path() + .join("tickets") + .to_string_lossy() + .into_owned(); + config.paths.projects = directory.path().to_string_lossy().into_owned(); + config.profile.id = uuid::Uuid::new_v4(); + config.profile_registry = Some(directory.path().join("profiles.sqlite")); + let tickets = config.tickets_path(); + Self { + state: ApiState::new(config, tickets), + _directory: directory, + } + } + + /// The loopback local-unlock credential, which carries every scope. + fn admin_token(&self) -> String { + self.state + .auth + .local_token + .clone() + .expect("a loopback bind issues a local token") + } + + /// An access token carrying exactly `scopes`. + fn token_with(&self, scopes: &[Scope]) -> String { + let claims = api_claims( + "admin", + scopes, + chrono::Utc::now(), + uuid::Uuid::new_v4().to_string(), + ); + self.state.auth.signing_key.sign(&claims).unwrap() + } + + async fn send(&self, request: Request) -> axum::response::Response { + build_router(self.state.clone()) + .oneshot(request) + .await + .unwrap() + } + + async fn get(&self, path: &str, token: Option<&str>) -> axum::response::Response { + let mut builder = Request::builder().method("GET").uri(path); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + self.send(builder.body(Body::empty()).unwrap()).await + } + + async fn post(&self, path: &str, token: Option<&str>, body: &str) -> axum::response::Response { + let mut builder = Request::builder() + .method("POST") + .uri(path) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + self.send(builder.body(Body::from(body.to_string())).unwrap()) + .await + } +} + +async fn body_json(response: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) +} + +#[tokio::test] +async fn an_unauthenticated_request_is_401_with_a_challenge() { + let server = Server::start(); + + let response = server.get("/api/v1/license", None).await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + response.headers().contains_key(header::WWW_AUTHENTICATE), + "a 401 tells the caller how to authenticate" + ); +} + +#[tokio::test] +async fn an_authenticated_caller_without_the_scope_is_403_not_401() { + let server = Server::start(); + // Read-only: enough to be recognised, not enough to register a target. + let token = server.token_with(&[Scope::Read]); + + let response = server + .post("/api/v1/targets", Some(&token), SSH_TARGET) + .await; + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "a recognised credential missing a scope is forbidden, not unauthenticated" + ); + assert!( + !response.headers().contains_key(header::WWW_AUTHENTICATE), + "a 403 must not invite the caller to retry with a credential they already have" + ); +} + +#[tokio::test] +async fn an_authorized_caller_without_a_licence_is_402_with_the_feature_named() { + let server = Server::start(); + let token = server.admin_token(); + + let response = server + .post("/api/v1/targets", Some(&token), SSH_TARGET) + .await; + + assert_eq!( + response.status(), + StatusCode::PAYMENT_REQUIRED, + "authorized but unentitled is 402, distinct from 401 and 403" + ); + let body = body_json(response).await; + assert_eq!(body["error"], "not_entitled"); + assert_eq!(body["feature"], "remote_targets"); + assert_eq!(body["required_tier"], "premium"); +} + +#[tokio::test] +async fn reading_the_licence_and_targets_needs_no_entitlement() { + let server = Server::start(); + let token = server.admin_token(); + + let license = server.get("/api/v1/license", Some(&token)).await; + assert_eq!(license.status(), StatusCode::OK); + let body = body_json(license).await; + assert_eq!(body["status"], "missing"); + assert_eq!(body["premium"], false); + assert!( + body.get("license_key").is_none(), + "a read must never carry the raw key" + ); + + let targets = server.get("/api/v1/targets", Some(&token)).await; + assert_eq!( + targets.status(), + StatusCode::OK, + "configured targets stay readable without Premium" + ); +} + +#[tokio::test] +async fn a_licence_token_cannot_authenticate_the_api() { + let server = Server::start(); + // A licence is a signed token too. Even one signed by this server's own key, + // naming the admin subject and every scope, must not authenticate: the + // audience is the boundary. + let mut claims = api_claims( + "admin", + &[Scope::Read, Scope::Write, Scope::Execute], + chrono::Utc::now(), + uuid::Uuid::new_v4().to_string(), + ); + claims.aud = operator::licensing::LICENSE_AUDIENCE.to_string(); + let forged = server.state.auth.signing_key.sign(&claims).unwrap(); + + let response = server.get("/api/v1/license", Some(&forged)).await; + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "licence audience and API audience are disjoint" + ); +} + +#[tokio::test] +async fn an_invalid_licence_is_rejected_without_disturbing_the_installed_one() { + let server = Server::start(); + let token = server.admin_token(); + + let response = server + .send( + Request::builder() + .method("PUT") + .uri("/api/v1/license") + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from(r#"{"license_key":"not-a-licence"}"#)) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let after = body_json(server.get("/api/v1/license", Some(&token)).await).await; + assert_eq!(after["status"], "missing", "nothing was written"); +} + +/// The configuration-qualified routes are mounted by a dispatcher that sits +/// outside the authorize layer and re-dispatches into a router that has it. +/// That indirection is exactly the kind of thing that silently stops enforcing, +/// and `tests/route_scope_parity.rs` cannot see the wildcard route. +#[tokio::test] +async fn a_configuration_qualified_route_still_requires_authentication() { + let server = Server::start(); + let id = server.state.config().profile.id; + + let response = server + .get(&format!("/api/v1/profiles/{id}/license"), None) + .await; + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "tenant dispatch must not bypass the authorize layer" + ); +} + +#[tokio::test] +async fn a_configuration_qualified_route_enforces_entitlement() { + let server = Server::start(); + let id = server.state.config().profile.id; + let token = server.admin_token(); + + let response = server + .post( + &format!("/api/v1/profiles/{id}/targets"), + Some(&token), + SSH_TARGET, + ) + .await; + + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let body = body_json(response).await; + assert_eq!(body["error"], "not_entitled"); +} + +/// A request for a configuration this server does not host must not fall +/// through to the default one. +#[tokio::test] +async fn an_unknown_configuration_is_not_served_by_the_default() { + let server = Server::start(); + let token = server.admin_token(); + let stranger = uuid::Uuid::new_v4(); + + let response = server + .get( + &format!("/api/v1/profiles/{stranger}/license"), + Some(&token), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/tests/route_scope_parity.rs b/tests/route_scope_parity.rs index ead40c43..419ac951 100644 --- a/tests/route_scope_parity.rs +++ b/tests/route_scope_parity.rs @@ -17,6 +17,18 @@ use operator::auth::scope::{Access, ROUTE_RULES}; const UNDOCUMENTED_MOUNTED_ROUTES: &[(&str, &str)] = &[("GET", "/api/v1/mcp/sse"), ("POST", "/api/v1/mcp/message")]; +// `/api/v1/profiles/{profile_id}/{*path}` is deliberately absent from both +// lists. It is a dispatcher, not an endpoint: it rewrites the URI and +// re-dispatches into the same router these rules already cover, so the request +// is authorized against the rule for the route it actually resolves to. A +// `ROUTE_RULES` entry for the wildcard would never be consulted, and an +// unconsulted rule is a lie about the surface. +// +// What that indirection *could* do is stop enforcing, so it is covered +// behaviourally instead - see `tests/premium_http_contract.rs`: +// `a_configuration_qualified_route_still_requires_authentication` and +// `a_configuration_qualified_route_enforces_entitlement`. + /// The complete set of routes reachable without a credential. const EXPECTED_PUBLIC: &[(&str, &str)] = &[ // Kubernetes probes - no workspace metadata. diff --git a/tests/ticket_write_path.rs b/tests/ticket_write_path.rs new file mode 100644 index 00000000..1dbf2892 --- /dev/null +++ b/tests/ticket_write_path.rs @@ -0,0 +1,120 @@ +//! One write path for ticket column moves, enforced. +//! +//! A ticket's board column is which of `.tickets/{queue,in-progress,completed}` +//! it lives in, so *moving the file is the state change*. Every surface must do +//! it through `services::ticket_transitions::move_ticket`, which also mirrors +//! the move to the board the ticket was synced from. +//! +//! Renaming a ticket file directly still moves it locally, and still looks +//! correct on operator's own board - it just silently strands the ticket in its +//! old column on Jira/Linear/GitHub. That is exactly the bug this test prevents +//! from coming back, and it is invisible without a configured provider. + +use std::path::{Path, PathBuf}; + +/// Modules that own the move and are allowed to rename a ticket file. +const MOVE_OWNERS: &[&str] = &["src/queue/mod.rs"]; + +/// Surfaces that act on tickets and must delegate the move. +const TICKET_SURFACES: &[&str] = &["src/rest", "src/mcp", "src/acp", "src/app", "src/agents"]; + +/// Renames that move something other than a ticket file, with the reason. +/// Keep this list short and justified. +const TEST_EXEMPT: &[(&str, &str)] = &[( + "src/agents/launcher/coder.rs", + "atomic install of the downloaded Coder CLI binary, not a ticket file", +)]; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn rust_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +/// 1-based lines calling `fs::rename`. Doc comments may name the call while +/// explaining why it is avoided, so comment lines do not count. +fn rename_lines(source: &str) -> Vec { + source + .lines() + .enumerate() + .filter(|(_, line)| { + let code = line.trim_start(); + !code.starts_with("//") && code.contains("fs::rename") + }) + .map(|(n, _)| n + 1) + .collect() +} + +fn exempt_paths(root: &Path) -> Vec { + TEST_EXEMPT.iter().map(|(p, _)| root.join(p)).collect() +} + +#[test] +fn test_ticket_surfaces_do_not_rename_ticket_files_directly() { + let root = repo_root(); + let owners: Vec = MOVE_OWNERS.iter().map(|p| root.join(p)).collect(); + let exempt = exempt_paths(&root); + + let mut files = Vec::new(); + for surface in TICKET_SURFACES { + rust_files(&root.join(surface), &mut files); + } + assert!(!files.is_empty(), "no source files found to check"); + + let mut offenders = Vec::new(); + for file in files { + if owners.contains(&file) || exempt.contains(&file) { + continue; + } + let Ok(source) = std::fs::read_to_string(&file) else { + continue; + }; + for line in rename_lines(&source) { + offenders.push(format!( + "{}:{line}", + file.strip_prefix(&root).unwrap_or(&file).display() + )); + } + } + + assert!( + offenders.is_empty(), + "these ticket surfaces rename files directly instead of calling \ + services::ticket_transitions::move_ticket, so a synced ticket will not \ + move on its external board:\n {}", + offenders.join("\n ") + ); +} + +#[test] +fn test_exemptions_are_real_and_still_needed() { + let root = repo_root(); + for (path, reason) in TEST_EXEMPT { + assert!( + TICKET_SURFACES.iter().any(|s| path.starts_with(s)), + "exempt path '{path}' is not under a ticket surface, so the exemption does nothing" + ); + assert!( + !MOVE_OWNERS.contains(path), + "exempt path '{path}' already owns the move - drop one of the two lists" + ); + let source = std::fs::read_to_string(root.join(path)) + .unwrap_or_else(|e| panic!("exempt path '{path}' is unreadable ({e}) - remove it")); + assert!( + !rename_lines(&source).is_empty(), + "exempt path '{path}' no longer renames anything ({reason}) - remove the exemption" + ); + } +} diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index deabc852..e375d958 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -17,6 +17,7 @@ import { RightPanelProvider, useRightPanel } from "./right-panel"; import type { SectionDto } from "./api-client"; import { OperatorApi, setCsrfToken } from "./api-client"; import { useHost } from "./host"; +import { ProfileSelector } from "./profiles-context"; // 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 @@ -117,6 +118,7 @@ export function Layout() { } groups={ <> + diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index cbbc2d76..8f82fc78 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -1,4 +1,10 @@ import type { Host } from "./host"; +import type { TargetDef } from "@operator/bindings/TargetDef"; +import type { ProfileSummary } from "@operator/bindings/ProfileSummary"; +import type { LicenseResponse } from "@operator/bindings/LicenseResponse"; +import type { TargetResponse } from "@operator/bindings/TargetResponse"; +import type { TargetsResponse } from "@operator/bindings/TargetsResponse"; +import type { TargetProbeResponse } from "@operator/bindings/TargetProbeResponse"; import type { HealthResponse } from "@operator/bindings/HealthResponse"; import type { StatusResponse } from "@operator/bindings/StatusResponse"; import type { SectionDto } from "@operator/bindings/SectionDto"; @@ -7,6 +13,8 @@ import type { QueueStatusResponse } from "@operator/bindings/QueueStatusResponse import type { KanbanBoardResponse } from "@operator/bindings/KanbanBoardResponse"; import type { KanbanTicketCard } from "@operator/bindings/KanbanTicketCard"; import type { ActiveAgentsResponse } from "@operator/bindings/ActiveAgentsResponse"; +import type { CreateTicketRequest } from "@operator/bindings/CreateTicketRequest"; +import type { CreateTicketResponse } from "@operator/bindings/CreateTicketResponse"; import type { IssueTypeSummary } from "@operator/bindings/IssueTypeSummary"; import type { IssueTypeResponse } from "@operator/bindings/IssueTypeResponse"; import type { CollectionResponse } from "@operator/bindings/CollectionResponse"; @@ -78,6 +86,8 @@ import type { ResetPasswordResponse } from "@operator/bindings/ResetPasswordResp import type { RevokeAccessKeyResponse } from "@operator/bindings/RevokeAccessKeyResponse"; import type { SessionListResponse } from "@operator/bindings/SessionListResponse"; +export type { ProfileSummary, LicenseResponse, TargetResponse, TargetsResponse }; + export type { AccessKeyListResponse, BootstrapStatusResponse, @@ -93,6 +103,8 @@ export type { KanbanBoardResponse, KanbanTicketCard, ActiveAgentsResponse, + CreateTicketRequest, + CreateTicketResponse, IssueTypeSummary, IssueTypeResponse, CollectionResponse, @@ -138,9 +150,19 @@ export type { export class ApiError extends Error { status: number; - constructor(status: number, message: string) { + code?: string; + feature?: string; + requiredTier?: string; + constructor( + status: number, + message: string, + details?: { code?: string; feature?: string; required_tier?: string }, + ) { super(message); this.status = status; + this.code = details?.code; + this.feature = details?.feature; + this.requiredTier = details?.required_tier; } } @@ -215,32 +237,132 @@ function authInit(init?: RequestInit): RequestInit { return { ...init, headers, credentials: "same-origin" }; } -async function send(base: string, path: string, init?: RequestInit): Promise { - const res = await fetch(`${base}${path}`, authInit(init)); +type ApiConnection = { origin: string; profileId?: string; signal?: AbortSignal }; + +export function profileApiPath(path: string, profileId?: string): string { + if (!profileId || /^\/api\/v1\/(auth|health|integrations|profiles)(\/|$)/.test(path)) { + return path; + } + return path.replace("/api/v1/", `/api/v1/profiles/${encodeURIComponent(profileId)}/`); +} + +async function send( + connection: string | ApiConnection, + path: string, + init?: RequestInit, +): Promise { + const base = typeof connection === "string" ? connection : connection.origin; + const signal = typeof connection === "string" ? undefined : connection.signal; + const scopedPath = profileApiPath( + path, + typeof connection === "string" ? undefined : connection.profileId, + ); + signal?.throwIfAborted(); + const res = await fetch( + `${base}${scopedPath}`, + authInit({ ...init, signal: signal ?? init?.signal }), + ); + signal?.throwIfAborted(); if (res.status === 401) { await redirectToAuth(base); } if (!res.ok) { const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); - throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); + throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`, body); } return res; } -async function request(base: string, path: string, init?: RequestInit): Promise { +async function request( + base: string | ApiConnection, + path: string, + init?: RequestInit, +): Promise { const res = await send(base, path, init); - return res.json() as Promise; + const result = (await res.json()) as T; + if (typeof base !== "string") { + base.signal?.throwIfAborted(); + } + return result; } -async function requestVoid(base: string, path: string, init?: RequestInit): Promise { +async function requestVoid( + base: string | ApiConnection, + path: string, + init?: RequestInit, +): Promise { await send(base, path, init); } export class OperatorApi { - private base: string; + private readonly base: ApiConnection; constructor(host: Host) { - this.base = host.baseUrl(); + this.base = { origin: host.baseUrl(), profileId: host.profileId, signal: host.signal }; + } + + profiles(): Promise { + return request(this.base, "/api/v1/profiles"); + } + + license(): Promise { + return request(this.base, "/api/v1/license"); + } + + installLicense(license_key: string): Promise { + return request(this.base, "/api/v1/license", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: toJson({ license_key }), + }); + } + + removeLicense(): Promise { + return request(this.base, "/api/v1/license", { method: "DELETE" }); + } + + targets(): Promise { + return request(this.base, "/api/v1/targets"); + } + + saveTarget(target: TargetDef, existingName?: string): Promise { + return request( + this.base, + existingName ? `/api/v1/targets/${encodeURIComponent(existingName)}` : "/api/v1/targets", + { + method: existingName ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: toJson(target), + }, + ); + } + + removeTarget(name: string): Promise { + return requestVoid(this.base, `/api/v1/targets/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + } + + probeTarget(name: string): Promise { + return request(this.base, `/api/v1/targets/${encodeURIComponent(name)}/probe`, { + method: "POST", + }); + } + + createProfile(name: string): Promise { + return request(this.base, "/api/v1/profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: toJson({ name }), + }); + } + + renameProfile(id: string, name: string): Promise { + return request(this.base, `/api/v1/profiles/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: toJson({ name }), + }); } // --- Auth --- @@ -526,6 +648,14 @@ export class OperatorApi { }); } + createTicket(req: CreateTicketRequest): Promise { + return request(this.base, "/api/v1/tickets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: toJson(req), + }); + } + // --- Projects --- listProjects(): Promise { diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 679cf8d8..933af5c3 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -8,8 +8,7 @@ import { TicketDetailPanel } from "./TicketDetailPanel"; /** * Three-column kanban board mirroring the operator TUI's ticket columns: * TODO QUEUE / IN PROGRESS / DONE. The API's `awaiting` tickets are folded - * into IN PROGRESS (with a distinct paused indicator), matching the TUI which - * keeps awaiting tickets in the in-progress panel. + * into IN PROGRESS (with a distinct paused indicator), matching the TUI. * * Cards in the TODO and IN PROGRESS columns are clickable: they open the * right-hand detail sidepanel with that ticket's detail, launch form, and diff --git a/ui/src/components/LicensePanel.module.css b/ui/src/components/LicensePanel.module.css new file mode 100644 index 00000000..c49df4f5 --- /dev/null +++ b/ui/src/components/LicensePanel.module.css @@ -0,0 +1,59 @@ +.panel { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.panel h2 { + margin: 0; + font-size: 1.1rem; +} + +.terms { + display: grid; + grid-template-columns: minmax(8rem, max-content) 1fr; + gap: 0.35rem 1rem; + margin: 0; +} + +.terms dt { + color: var(--text-muted); +} + +.terms dd { + margin: 0; + overflow-wrap: anywhere; +} + +.form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form label { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.form input { + min-height: 2rem; +} + +.error { + color: var(--danger); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.actions button { + min-height: 2rem; +} diff --git a/ui/src/components/LicensePanel.tsx b/ui/src/components/LicensePanel.tsx new file mode 100644 index 00000000..5c6a0a58 --- /dev/null +++ b/ui/src/components/LicensePanel.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useState } from "react"; +import type { LicenseResponse, OperatorApi } from "../api-client"; +import styles from "./LicensePanel.module.css"; + +const STATUS_LABELS: Record = { + missing: "Free", + valid: "Premium", + expired: "Expired", + not_yet_valid: "Not yet valid", + invalid: "Invalid", +}; +/// Licence timestamps are i64 seconds, which cross the wire as bigint. +const date = (seconds: bigint) => new Date(Number(seconds) * 1000).toLocaleString(); + +export function LicensePanel({ + api, + onChange, +}: { + api: OperatorApi; + onChange?: (license: LicenseResponse) => void; +}) { + const [license, setLicense] = useState(null); + const [key, setKey] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let active = true; + api + .license() + .then((value) => { + if (active) { + setLicense(value); + onChange?.(value); + } + return undefined; + }) + .catch((cause: unknown) => { + if (active) { + setError(cause instanceof Error ? cause.message : "Could not load license"); + } + }); + return () => { + active = false; + }; + }, [api, onChange]); + + const update = useCallback( + async (remove: boolean) => { + setBusy(true); + setError(null); + try { + await api.refreshCsrf(); + const value = remove ? await api.removeLicense() : await api.installLicense(key.trim()); + setLicense(value); + setKey(""); + onChange?.(value); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not update license"); + } finally { + setBusy(false); + } + }, + [api, key, onChange], + ); + + const onRemove = useCallback(() => { + void update(true); + }, [update]); + + const onSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + void update(false); + }, + [update], + ); + + return ( +
+

Operator Premium

+

Premium enables remote targets. Multiple agents on this machine are available free.

+ {error && ( +

+ {error} +

+ )} + {license ? ( + <> +
+
Status
+
{STATUS_LABELS[license.status]}
+
Configuration ID
+
+ {license.profile_id} +
+ {license.terms && ( + <> +
Licensed to
+
{license.terms.sub}
+
License ID
+
{license.terms.jti}
+
Tier
+
{license.terms.tier}
+
Issued
+
{date(license.terms.iat)}
+
Valid from
+
{date(license.terms.nbf)}
+
Expires
+
{date(license.terms.exp)}
+ + )} +
+ {license.purchase_url && /^https:\/\//i.test(license.purchase_url) && ( +

+ + View Operator Premium + +

+ )} + + ) : ( +

Loading license…

+ )} +
+ + +
+ {license && license.status !== "missing" && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/src/components/TicketCreatePanel.module.css b/ui/src/components/TicketCreatePanel.module.css new file mode 100644 index 00000000..e275a6ee --- /dev/null +++ b/ui/src/components/TicketCreatePanel.module.css @@ -0,0 +1,16 @@ +.panel { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.title { + margin: 0; + font-size: 0.95rem; +} + +.hint { + margin: 0; + color: var(--text-muted); + font-size: 0.8rem; +} diff --git a/ui/src/components/TicketCreatePanel.tsx b/ui/src/components/TicketCreatePanel.tsx new file mode 100644 index 00000000..33254f9a --- /dev/null +++ b/ui/src/components/TicketCreatePanel.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { TicketCreateForm } from "@operator/webcomponents"; +import type { TicketCreateFormValue } from "@operator/webcomponents"; +import type { IssueTypeSummary } from "@operator/bindings/IssueTypeSummary"; +import type { ProjectSummary } from "@operator/bindings/ProjectSummary"; +import { OperatorApi } from "../api-client"; +import { useHost } from "../host"; +import { useRightPanel } from "../right-panel"; +import styles from "./TicketCreatePanel.module.css"; + +const EMPTY: TicketCreateFormValue = { issueType: "", project: "", summary: "" }; + +/** + * Right-panel contents for adding a card to the board. Writes through the same + * `POST /api/v1/tickets` the CLI and MCP use, so a ticket created here is + * identical to one created anywhere else. + */ +export function TicketCreatePanel({ onCreated }: { onCreated: () => void }) { + const host = useHost(); + const { close } = useRightPanel(); + const [api] = useState(() => new OperatorApi(host)); + + const [value, setValue] = useState(EMPTY); + const [issueTypes, setIssueTypes] = useState([]); + const [projects, setProjects] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const createRequest = useRef(0); + + useEffect( + () => () => { + createRequest.current += 1; + }, + [], + ); + + useEffect(() => { + let cancelled = false; + Promise.all([api.listIssueTypes(), api.listProjects()]) + .then(([types, projectList]) => { + if (!cancelled) { + setIssueTypes(types); + setProjects(projectList.filter((project) => project.exists)); + } + return undefined; + }) + .catch((e: Error) => !cancelled && setError(e.message)); + return () => { + cancelled = true; + }; + }, [api]); + + const onSubmit = useCallback(() => { + const request = ++createRequest.current; + setBusy(true); + setError(null); + api + .createTicket({ + template: value.issueType, + project: value.project, + summary: value.summary, + values: {}, + }) + .then(() => { + if (request === createRequest.current) { + onCreated(); + close(); + } + return undefined; + }) + .catch((e: Error) => { + if (request === createRequest.current) { + setError(e.message); + } + }) + .finally(() => { + if (request === createRequest.current) { + setBusy(false); + } + }); + }, [api, close, onCreated, value]); + + return ( +
+

New ticket

+

Lands in the TODO queue, ready to launch.

+ +
+ ); +} diff --git a/ui/src/components/TicketDetailPanel.tsx b/ui/src/components/TicketDetailPanel.tsx index eabaa70b..b2ed6f69 100644 --- a/ui/src/components/TicketDetailPanel.tsx +++ b/ui/src/components/TicketDetailPanel.tsx @@ -182,6 +182,9 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { onFocus(result.agent_id); } }, [onFocus, result]); + // A completed ticket opens read-only: there is nothing left to launch. + const isFinished = ticket.status === "completed"; + const formValue: LaunchFormValue = { delegator, wrapper, target, yolo }; const launchActions = result ? ( <> @@ -219,7 +222,7 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { = { docsUrl: `${DOCS_BASE}/getting-started/workflows/`, summary: "Export formats a ticket + issue type can be rendered into for other tools.", }, + "remote-targets": { + key: "remote-targets", + icon: "server-environment", + label: "Remote Targets", + route: "/remote-targets", + docsUrl: `${DOCS_BASE}/getting-started/remote-targets/`, + summary: "SSH hosts and Coder workspaces agents can run on. Requires Premium.", + }, + license: { + key: "license", + icon: "key", + label: "License", + route: "/settings/license", + docsUrl: `${DOCS_BASE}/getting-started/premium/`, + summary: "The Premium license for this configuration, and what it covers.", + }, }; /** Sidebar order for the status sections (matches the TUI / VS Code ordering). */ @@ -140,6 +156,8 @@ export const STATUS_KEYS = [ "delegators", "projects", "workflows", + "remote-targets", + "license", ] as const; /** Sidebar order for the web-only pages. */ diff --git a/ui/src/host.ts b/ui/src/host.ts index f7111c94..8a6bfbde 100644 --- a/ui/src/host.ts +++ b/ui/src/host.ts @@ -1,6 +1,8 @@ import { createContext, useContext } from "react"; export interface Host { + readonly profileId?: string; + readonly signal?: AbortSignal; baseUrl(): string; openExternal(url: string): void; browseFolder(): Promise; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index ed5ccb35..f8a5fd7b 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -21,6 +21,9 @@ import { DevicePage } from "./routes/DevicePage"; import { SecurityPage } from "./routes/SecurityPage"; import { OnboardingPage } from "./routes/onboarding/OnboardingPage"; import { WorkspaceGate } from "./WorkspaceGate"; +import { ProfilesProvider } from "./profiles-context"; +import { LicensePage } from "./routes/LicensePage"; +import { RemoteTargetsPage } from "./routes/RemoteTargetsPage"; const host = createBrowserHost(); @@ -35,25 +38,29 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> - } /> - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + diff --git a/ui/src/profiles-context.module.css b/ui/src/profiles-context.module.css new file mode 100644 index 00000000..c6adb00d --- /dev/null +++ b/ui/src/profiles-context.module.css @@ -0,0 +1,23 @@ +.selector { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.selector label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.selector select, +.selector button { + width: 100%; + min-height: 2rem; +} + +.gate { + padding: 1.5rem; +} diff --git a/ui/src/profiles-context.tsx b/ui/src/profiles-context.tsx new file mode 100644 index 00000000..5ec0e412 --- /dev/null +++ b/ui/src/profiles-context.tsx @@ -0,0 +1,166 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; +import { OperatorApi, type ProfileSummary } from "./api-client"; +import { AsyncState } from "@operator/webcomponents"; +import { HostContext, useHost, type Host } from "./host"; +import styles from "./profiles-context.module.css"; + +const SELECTED_PROFILE_KEY = "operator.selected-profile"; + +type ProfilesContextValue = { + profiles: ProfileSummary[]; + selected: ProfileSummary | null; + select: (id: string) => void; + create: (name: string) => Promise; + refresh: () => Promise; +}; + +const ProfilesContext = createContext(null); + +export function useProfiles(): ProfilesContextValue { + const value = useContext(ProfilesContext); + if (!value) { + throw new Error("Configuration registry is unavailable"); + } + return value; +} + +// Remounted by profile ID so responses cannot update another configuration's views. +function ProfileScope({ profileId, children }: { profileId?: string; children: ReactNode }) { + const serverHost = useHost(); + const host = useMemo( + () => ({ + profileId, + baseUrl: () => serverHost.baseUrl(), + openExternal: (url) => serverHost.openExternal(url), + browseFolder: () => serverHost.browseFolder(), + openFile: (path) => serverHost.openFile(path), + }), + [profileId, serverHost], + ); + return {children}; +} + +export function ProfilesProvider() { + const host = useHost(); + const api = useMemo(() => new OperatorApi(host), [host]); + const [profiles, setProfiles] = useState([]); + const [selectedId, setSelectedId] = useState(() => localStorage.getItem(SELECTED_PROFILE_KEY)); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setProfiles(await api.profiles()); + setLoaded(true); + }, [api]); + + useEffect(() => { + let cancelled = false; + api + .profiles() + .then((items) => { + if (!cancelled) { + setProfiles(items); + setLoaded(true); + } + return undefined; + }) + .catch((cause: unknown) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : "Could not load configurations"); + } + }); + return () => { + cancelled = true; + }; + }, [api]); + + const selected = + profiles.find((profile) => profile.id === selectedId) ?? + profiles.find((profile) => profile.is_default) ?? + profiles[0] ?? + null; + + const select = useCallback((id: string) => { + localStorage.setItem(SELECTED_PROFILE_KEY, id); + setSelectedId(id); + }, []); + + const create = useCallback( + async (name: string) => { + await api.refreshCsrf(); + const profile = await api.createProfile(name); + setProfiles((items) => [...items, profile]); + select(profile.id); + return profile; + }, + [api, select], + ); + + const value = useMemo( + () => ({ profiles, selected, select, create, refresh }), + [profiles, selected, select, create, refresh], + ); + + if (error || !loaded) { + return ( +
+ + value={ + error + ? { status: "error", message: error } + : { status: "loading", message: "Loading configurations…" } + } + > + {() => null} + +
+ ); + } + return ( + + + + + + ); +} + +export function ProfileSelector() { + const { profiles, selected, select } = useProfiles(); + const navigate = useNavigate(); + + const onChange = useCallback( + (event: React.ChangeEvent) => { + const profile = profiles.find((item) => item.id === event.target.value); + if (!profile) { + return; + } + select(profile.id); + void navigate(profile.initialized ? "/" : "/onboarding"); + }, + [profiles, select, navigate], + ); + + const onCreate = useCallback(() => { + void navigate("/onboarding?new=1"); + }, [navigate]); + + return ( +
+ + + +
+ ); +} diff --git a/ui/src/routes/LicensePage.module.css b/ui/src/routes/LicensePage.module.css new file mode 100644 index 00000000..c40a6df4 --- /dev/null +++ b/ui/src/routes/LicensePage.module.css @@ -0,0 +1,4 @@ +.page { + padding: 1.5rem; + max-width: 50rem; +} diff --git a/ui/src/routes/LicensePage.tsx b/ui/src/routes/LicensePage.tsx new file mode 100644 index 00000000..3c0900b5 --- /dev/null +++ b/ui/src/routes/LicensePage.tsx @@ -0,0 +1,16 @@ +import { useMemo } from "react"; +import { OperatorApi } from "../api-client"; +import { useHost } from "../host"; +import { LicensePanel } from "../components/LicensePanel"; +import styles from "./LicensePage.module.css"; + +export function LicensePage() { + const host = useHost(); + const api = useMemo(() => new OperatorApi(host), [host]); + return ( +
+

License

+ +
+ ); +} diff --git a/ui/src/routes/QueuePage.tsx b/ui/src/routes/QueuePage.tsx index 8efb21ee..96b155cc 100644 --- a/ui/src/routes/QueuePage.tsx +++ b/ui/src/routes/QueuePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { QueueView } from "@operator/webcomponents"; import { OperatorApi } from "../api-client"; import type { KanbanBoardResponse } from "../api-client"; @@ -7,6 +7,7 @@ import { useHost } from "../host"; import { useRightPanel } from "../right-panel"; import { CONCEPTS } from "../concepts"; import { TicketDetailPanel } from "../components/TicketDetailPanel"; +import { TicketCreatePanel } from "../components/TicketCreatePanel"; const QUEUE = CONCEPTS.queue; @@ -19,44 +20,57 @@ export function QueuePage() { const [board, setBoard] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const openTicket = useCallback( - (ticket: KanbanTicketCard) => - open(, ticket.id), - [open], - ); + // Guards every state write, so a poll or a post-write refresh that lands + // after unmount is dropped instead of setting state on a dead component. + const mounted = useRef(true); useEffect(() => { - let cancelled = false; - - const refresh = () => { - api - .kanban() - .then((b) => { - if (!cancelled) { - setBoard(b); - setError(null); - } - return undefined; - }) - .catch((e) => { - if (!cancelled) { - setError(e.message); - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } - }); + mounted.current = true; + return () => { + mounted.current = false; }; + }, []); + + // Stable, so both the poll and a write can pull the board. + const refresh = useCallback(() => { + api + .kanban() + .then((b) => { + if (mounted.current) { + setBoard(b); + setError(null); + } + return undefined; + }) + .catch((e) => { + if (mounted.current) { + setError(e.message); + } + }) + .finally(() => { + if (mounted.current) { + setLoading(false); + } + }); + }, [api]); + useEffect(() => { refresh(); const timer = setInterval(refresh, POLL_INTERVAL_MS); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [api]); + return () => clearInterval(timer); + }, [refresh]); + + const openTicket = useCallback( + (ticket: KanbanTicketCard) => + open(, ticket.id), + [open], + ); + + // A created ticket shows up immediately rather than on the next poll. + const createTicket = useCallback( + () => open(, "new-ticket"), + [open, refresh], + ); return ( ); } diff --git a/ui/src/routes/RemoteTargetsPage.module.css b/ui/src/routes/RemoteTargetsPage.module.css new file mode 100644 index 00000000..ec386d40 --- /dev/null +++ b/ui/src/routes/RemoteTargetsPage.module.css @@ -0,0 +1,54 @@ +.page { + padding: 1.5rem; + max-width: 60rem; +} + +.heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; +} + +.badge { + font-size: 0.75rem; + border: 1px solid currentColor; + border-radius: 1rem; + padding: 0.1rem 0.5rem; + color: var(--text-muted); +} + +.targets { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.target { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem; +} + +.meta { + color: var(--text-muted); + font-size: 0.875rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.actions button { + min-height: 2rem; +} + +.status { + display: block; + margin-block: 0.75rem; +} diff --git a/ui/src/routes/RemoteTargetsPage.tsx b/ui/src/routes/RemoteTargetsPage.tsx new file mode 100644 index 00000000..b2890d03 --- /dev/null +++ b/ui/src/routes/RemoteTargetsPage.tsx @@ -0,0 +1,319 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PremiumPaywall } from "@operator/webcomponents"; +import type { TargetDef } from "@operator/bindings/TargetDef"; +import { OperatorApi, type LicenseResponse, type TargetResponse } from "../api-client"; +import { useHost } from "../host"; +import form from "./onboarding/OnboardingPage.module.css"; +import styles from "./RemoteTargetsPage.module.css"; + +const DEFAULT_CODER = { + template: "", + url_env: "CODER_URL", + token_env: "CODER_SESSION_TOKEN", + name_prefix: "op", + stop_on_complete: true, + create_timeout_secs: 300n, +}; +const emptyTarget = (): TargetDef => ({ name: "", kind: "ssh", ssh_alias: "", workdir: "" }); + +/// This page manages remote targets only; local and docker are built in. +const remoteOnly = (targets: TargetResponse[]): TargetResponse[] => + targets.filter((target) => target.kind === "ssh" || target.kind === "coder"); + +type TargetRowProps = { + target: TargetResponse; + busy: boolean; + onProbe: (target: TargetResponse) => void; + onEdit: (target: TargetResponse) => void; + onRemove: (target: TargetResponse) => void; +}; + +/// One row, so the per-target handlers are created in this component's scope +/// rather than inside the parent's `map`. +function TargetRow({ target, busy, onProbe, onEdit, onRemove }: TargetRowProps) { + const probe = useCallback(() => onProbe(target), [onProbe, target]); + const edit = useCallback(() => onEdit(target), [onEdit, target]); + const remove = useCallback(() => onRemove(target), [onRemove, target]); + return ( +
  • + {target.display_name ?? target.name} + + {" "} + · {target.kind} · {target.entitled ? "Available" : "License required"} + +
    + + {target.user_declared && ( + <> + + + + )} +
    +
  • + ); +} + +export function RemoteTargetsPage() { + const host = useHost(); + const api = useMemo(() => new OperatorApi(host), [host]); + const navigate = useNavigate(); + const [targets, setTargets] = useState([]); + const [license, setLicense] = useState(null); + const [draft, setDraft] = useState(emptyTarget); + const [editing, setEditing] = useState(); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const refresh = useCallback(async () => { + const [targetResult, licenseResult] = await Promise.all([api.targets(), api.license()]); + setTargets(remoteOnly(targetResult.targets)); + setLicense(licenseResult); + }, [api]); + + useEffect(() => { + let cancelled = false; + Promise.all([api.targets(), api.license()]) + .then(([targetResult, licenseResult]) => { + if (!cancelled) { + setTargets(remoteOnly(targetResult.targets)); + setLicense(licenseResult); + } + return undefined; + }) + .catch((cause: unknown) => { + if (!cancelled) { + setMessage(cause instanceof Error ? cause.message : "Could not load targets"); + } + }); + return () => { + cancelled = true; + }; + }, [api]); + + const act = useCallback( + async (operation: () => Promise, success: string) => { + setBusy(true); + setMessage(null); + try { + await api.refreshCsrf(); + await operation(); + await refresh(); + setMessage(success); + } catch (cause) { + setMessage(cause instanceof Error ? cause.message : "Target operation failed"); + } finally { + setBusy(false); + } + }, + [api, refresh], + ); + + const onProbe = useCallback( + (target: TargetResponse) => { + void act(async () => { + const result = await api.probeTarget(target.name); + if (!result.reachable) { + throw new Error(result.message ?? "Target is unreachable"); + } + }, "Target is reachable."); + }, + [act, api], + ); + + const onEdit = useCallback((target: TargetResponse) => { + const { + premium: _premium, + entitled: _entitled, + user_declared: _declared, + ...definition + } = target; + setDraft(definition); + setEditing(target.name); + }, []); + + const onRemove = useCallback( + (target: TargetResponse) => { + void act(() => api.removeTarget(target.name), "Target removed."); + }, + [act, api], + ); + + const onAddLicense = useCallback(() => { + void navigate("/settings/license"); + }, [navigate]); + + const onSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + void act(async () => { + await api.saveTarget(draft, editing); + setDraft(emptyTarget()); + setEditing(undefined); + }, "Target saved."); + }, + [act, api, draft, editing], + ); + + const onCancelEdit = useCallback(() => { + setEditing(undefined); + setDraft(emptyTarget()); + }, []); + + return ( +
    +

    + Remote targets Premium +

    +

    Register SSH hosts and Coder workspaces for delegators to run agents remotely.

    + {message && {message}} + {license && !license.premium && ( + + )} +
      + {targets.map((target) => ( + + ))} +
    + {license?.premium && ( +
    +

    {editing ? "Edit target" : "Register target"}

    + + + + {draft.kind === "ssh" && ( + <> + + + + + )} + {draft.kind === "coder" && ( + <> + + + + + + )} + + {editing && ( + + )} +
    + )} +
    + ); +} diff --git a/ui/src/routes/onboarding/OnboardingPage.tsx b/ui/src/routes/onboarding/OnboardingPage.tsx index d211b5ad..afa2f9b4 100644 --- a/ui/src/routes/onboarding/OnboardingPage.tsx +++ b/ui/src/routes/onboarding/OnboardingPage.tsx @@ -1,15 +1,112 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import type { SetupStep } from "@operator/bindings/SetupStep"; +import { useNavigate, useSearchParams } from "react-router-dom"; import type { SetupStatusResponse } from "../../api-client"; import { ApiError, OperatorApi } from "../../api-client"; import { useHost } from "../../host"; import { STEP_COMPONENTS, visibleSteps } from "./steps"; +import type { SetupStep } from "@operator/bindings/SetupStep"; import type { WizardDraft } from "./types"; +import { useProfiles } from "../../profiles-context"; import styles from "./OnboardingPage.module.css"; +const WIZARD_DRAFT_VERSION = 1; +const WIZARD_DRAFT_PREFIX = "operator.onboarding-draft"; + +function emptyDraft(configurationName: string, acceptanceCriteria = ""): WizardDraft { + return { + configurationName, + executionMode: "local", + premium: false, + preset: "devops_kanban", + taskFields: ["priority", "points", "user_story"], + wrapper: "tmux", + executionTarget: { kind: "local" }, + coderParameters: [], + useWorktrees: false, + acceptanceCriteria, + modelServers: [], + hostedCollectionIds: [], + }; +} + +function draftStorageKey(profileId: string): string { + return `${WIZARD_DRAFT_PREFIX}.${profileId}`; +} + export function OnboardingPage() { + const { selected } = useProfiles(); + const [params] = useSearchParams(); + return !selected || params.get("new") === "1" ? : ; +} + +function NewConfiguration() { + const { create, selected } = useProfiles(); + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const onCancel = () => { + void navigate(selected?.initialized ? "/" : "/onboarding"); + }; + return ( +
    + +
    +
    +

    Step 1

    +

    Welcome to Operator

    +

    Name this configuration.

    +
    +
    { + event.preventDefault(); + setBusy(true); + setError(null); + void create(name) + .then(() => navigate("/onboarding?step=license", { replace: true })) + .catch((cause: unknown) => { + setError(cause instanceof Error ? cause.message : "Could not create configuration"); + setBusy(false); + }); + }} + > + + {error &&

    {error}

    } + + {selected && ( + + )} +
    +
    +
    + ); +} + +function OnboardingWizard() { const host = useHost(); + const { selected, refresh } = useProfiles(); + const [params] = useSearchParams(); const navigate = useNavigate(); const [api] = useState(() => new OperatorApi(host)); const [status, setStatus] = useState(null); @@ -20,18 +117,10 @@ export function OnboardingPage() { const [collections, setCollections] = useState>>( [], ); - const [draft, setDraft] = useState({ - preset: "devops_kanban", - taskFields: ["priority", "points", "user_story"], - wrapper: "tmux", - executionTarget: { kind: "local" }, - coderParameters: [], - useWorktrees: false, - acceptanceCriteria: "", - modelServers: [], - hostedCollectionIds: [], - }); - const [currentSlug, setCurrentSlug] = useState("welcome"); + const [draft, setDraft] = useState(() => emptyDraft(selected?.name ?? "")); + const [currentSlug, setCurrentSlug] = useState( + params.get("step") === "license" ? "license" : "welcome", + ); const [exports, setExports] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -57,17 +146,31 @@ export function OnboardingPage() { setSteps(nextSteps); setIntegrations(nextIntegrations); setCollections(nextCollections); - setDraft({ - preset: "devops_kanban", - taskFields: ["priority", "points", "user_story"], - wrapper: "tmux", - executionTarget: { kind: "local" }, - coderParameters: [], - useWorktrees: false, - acceptanceCriteria: nextStatus.default_acceptance_criteria, - modelServers: [], - hostedCollectionIds: [], - }); + const initial = emptyDraft(selected?.name ?? "", nextStatus.default_acceptance_criteria); + if (selected) { + try { + const saved = JSON.parse( + sessionStorage.getItem(draftStorageKey(selected.id)) ?? "null", + ) as { + version?: number; + draft?: Partial; + currentSlug?: SetupStep; + } | null; + if (saved?.version === WIZARD_DRAFT_VERSION && saved.draft) { + setDraft({ ...initial, ...saved.draft }); + if (saved.currentSlug) { + setCurrentSlug(saved.currentSlug); + } + } else { + setDraft(initial); + } + } catch { + sessionStorage.removeItem(draftStorageKey(selected.id)); + setDraft(initial); + } + } else { + setDraft(initial); + } return undefined; }) .catch( @@ -77,7 +180,17 @@ export function OnboardingPage() { return () => { active = false; }; - }, [api, navigate]); + }, [api, navigate, selected]); + + useEffect(() => { + if (!selected || !status || status.initialized) { + return; + } + sessionStorage.setItem( + draftStorageKey(selected.id), + JSON.stringify({ version: WIZARD_DRAFT_VERSION, draft, currentSlug }), + ); + }, [currentSlug, draft, selected, status]); const walk = useMemo( () => @@ -100,10 +213,40 @@ export function OnboardingPage() { [setExports], ); - function next() { + async function next() { if (!current) { return; } + if (current.slug === "welcome") { + if (!/^[a-z0-9_-]{1,64}$/.test(draft.configurationName)) { + setError("Use 1-64 lowercase letters, numbers, hyphens, or underscores."); + return; + } + if (selected && selected.name !== draft.configurationName) { + setBusy(true); + try { + await api.renameProfile(selected.id, draft.configurationName); + await refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not rename configuration"); + return; + } finally { + setBusy(false); + } + } + } + if (current.slug === "execution-mode" && draft.executionMode === "remote") { + try { + const license = await api.license(); + if (!license.premium) { + setError("A valid Premium license is required for remote targets."); + return; + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not verify license"); + return; + } + } if (current.slug === "hosted-collections" && draft.hostedCollectionIds.length === 0) { setError("Select at least one collection."); return; @@ -160,6 +303,10 @@ export function OnboardingPage() { return { id, checksum: collection.checksum }; }), }); + if (selected) { + sessionStorage.removeItem(draftStorageKey(selected.id)); + } + await refresh(); void navigate("/", { replace: true }); } catch (cause) { setError( @@ -174,6 +321,10 @@ export function OnboardingPage() { } } + const onNext = () => { + void next(); + }; + if (!status || !current || !Step) { return
    {error ?? "Loading workspace setup…"}
    ; } @@ -240,7 +391,7 @@ export function OnboardingPage() { {busy ? "Initializing…" : "Initialize workspace"} ) : ( - )} diff --git a/ui/src/routes/onboarding/steps.tsx b/ui/src/routes/onboarding/steps.tsx index c02137f1..06c21aa7 100644 --- a/ui/src/routes/onboarding/steps.tsx +++ b/ui/src/routes/onboarding/steps.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { KanbanProviderKind } from "@operator/bindings/KanbanProviderKind"; import type { SetupStep } from "@operator/bindings/SetupStep"; import type { StepComponent, StepProps } from "./types"; -import { Choice, ChoiceGroup } from "@operator/webcomponents"; +import { Choice, ChoiceGroup, PremiumPaywall } from "@operator/webcomponents"; +import { LicensePanel } from "../../components/LicensePanel"; +import type { LicenseResponse } from "../../api-client"; import styles from "./OnboardingPage.module.css"; const TASK_FIELDS = ["priority", "points", "user_story"] as const; @@ -31,10 +33,23 @@ function ExportBlock({ value }: { value: string }) { ); } -const Welcome: StepComponent = ({ status }) => ( +const Welcome: StepComponent = ({ status, draft, setDraft }) => (

    Welcome to Operator

    We’ll configure this workspace for both the terminal and browser.

    +
    Configuration
    {status.config_path}
    @@ -49,6 +64,93 @@ const Welcome: StepComponent = ({ status }) => ( ); +const License: StepComponent = ({ api, setDraft }) => { + const onChange = useCallback( + (license: LicenseResponse) => setDraft((current) => ({ ...current, premium: license.premium })), + [setDraft], + ); + return ; +}; + +const ExecutionMode: StepComponent = ({ api, draft, setDraft }) => { + const [license, setLicense] = useState(null); + const [showLicense, setShowLicense] = useState(false); + const changed = useCallback( + (value: LicenseResponse) => { + setLicense(value); + setDraft((current) => ({ ...current, premium: value.premium })); + }, + [setDraft], + ); + useEffect(() => { + let active = true; + api + .license() + .then((value) => { + if (active) { + changed(value); + } + return undefined; + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [api, changed]); + + const selectLocal = useCallback(() => { + setDraft((current) => ({ + ...current, + executionMode: "local", + executionTarget: { kind: "local" }, + })); + }, [setDraft]); + + const selectRemote = useCallback(() => { + if (!license?.premium) { + setShowLicense(true); + return; + } + setDraft((current) => ({ + ...current, + executionMode: "remote", + useWorktrees: false, + executionTarget: + current.executionTarget.kind === "coder" + ? current.executionTarget + : { kind: "coder", name: "coder-agents", template: "", parameters: {} }, + })); + }, [license?.premium, setDraft]); + + const addLicense = useCallback(() => setShowLicense(true), []); + + return ( + +

    Where will agents run?

    +

    Both choices support multiple agents. Remote targets require Premium.

    + + + This machine + Run agents and local containers beside Operator. + + + Remote targets · Premium + Launch remotely and report work back to this Operator server. + + + {!license?.premium && ( + + )} + {showLicense && } +
    + ); +}; + function KanbanInfo({ api, addExport }: StepProps) { const [providers, setProviders] = useState>>([]); const [provider, setProvider] = useState(""); @@ -78,6 +180,10 @@ function KanbanInfo({ api, addExport }: StepProps) { [setProvider], ); + // The catalog carries one more provider than `KANBAN_KINDS`: the built-in board + const builtInBoard = providers.find((item) => !KANBAN_KINDS.some((kind) => kind === item.slug)); + const connectable = providers.filter((item) => KANBAN_KINDS.some((kind) => kind === item.slug)); + const credentials = () => ({ provider: provider as KanbanProviderKind, jira: provider === "jira" ? { domain, email, api_token: token } : null, @@ -236,9 +342,26 @@ function KanbanInfo({ api, addExport }: StepProps) { return (

    Kanban

    -

    Connect a board now, or continue and connect one later.

    +

    + Connect an external Kanban provider to sync its issues, or continue and connect one later. +

    - {providers.map((item) => ( + {builtInBoard && ( + + {builtInBoard.display_name} + + Built in and already active - your tickets in .tickets/ are the board. + + + )} + {connectable.map((item) => ( ( export const STEP_COMPONENTS = { welcome: Welcome, + license: License, + "execution-mode": ExecutionMode, "kanban-info": KanbanInfo, "model-server": ModelServer, "git-provider": GitProvider, @@ -930,6 +1055,8 @@ export function visibleSteps(steps: SetupStep[], draft: StepProps["draft"]): Set const wrapperSteps = new Set(Object.values(wrapperStep)); return steps.filter( (step) => + step !== "admin-password" && + (step !== "execution-target" || draft.executionMode === "remote") && (step !== "hosted-collections" || draft.preset === "custom") && (!wrapperSteps.has(step) || step === wrapperStep[draft.wrapper]), ); diff --git a/ui/src/routes/onboarding/types.ts b/ui/src/routes/onboarding/types.ts index 8f12447b..39e6c218 100644 --- a/ui/src/routes/onboarding/types.ts +++ b/ui/src/routes/onboarding/types.ts @@ -10,6 +10,9 @@ import type { } from "../../api-client"; export type WizardDraft = { + configurationName: string; + executionMode: "local" | "remote"; + premium: boolean; preset: CollectionPreset; taskFields: string[]; wrapper: SessionWrapperType; diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 56d0b80b..c13a8546 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -294,6 +294,14 @@ "command": "operator.openWorkflows", "title": "Operator: Open Workflows in Operator UI" }, + { + "command": "operator.openRemoteTargets", + "title": "Operator: Open Remote Targets in Operator UI" + }, + { + "command": "operator.openLicense", + "title": "Operator: Open License in Operator UI" + }, { "command": "operator.syncKanbanCollection", "title": "Operator: Sync Collection" diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index cb82e97a..ca48fed5 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -52,6 +52,8 @@ import type { LlmToolsResponse, ExecutionTargetsResponse, McpDescriptorResponse, + LicenseResponse, + TargetsResponse, } from "./generated"; // Re-export generated types for consumers @@ -167,6 +169,26 @@ export interface ApiSessionInfo { version: string; /** State directory holding `local-token`; absent from files written by older daemons. */ state_dir?: string; + /** Configuration the daemon serves; absent from files written by older daemons. */ + profile_id?: string; +} + +/** + * Configuration id per daemon URL, learned from `api-session.json`. + * + * Keyed by URL rather than threaded through the ~40 `OperatorApiClient` + * construction sites, because that is what it is: a property of the server + * listening there, discovered from the same file the port came from. The + * credential provider is already keyed the same way. + */ +const PROFILE_BY_URL = new Map(); + +/** Routes a request at the configuration the daemon serves. */ +export function profileApiPath(apiPath: string, profileId: string | undefined): string { + if (!profileId || /^\/api\/v1\/(auth|health|integrations|profiles)(\/|$)/.test(apiPath)) { + return apiPath; + } + return apiPath.replace("/api/v1/", `/api/v1/profiles/${encodeURIComponent(profileId)}/`); } export const DEFAULT_API_URL = "http://localhost:7008"; @@ -204,7 +226,11 @@ export async function discoverApiUrl(ticketsDir: string | undefined): Promise { const provider = credentialProvider(); - const url = `${this.baseUrl}${apiPath}`; + const url = `${this.baseUrl}${profileApiPath(apiPath, PROFILE_BY_URL.get(this.baseUrl))}`; const token = await provider.bearer(this.baseUrl); let response = await fetch(url, withBearer(init, token)); @@ -646,4 +672,16 @@ export class OperatorApiClient { async mcpDescriptor(): Promise { return this.request("/api/v1/mcp/descriptor"); } + + // --- Premium --- + + /** Verified licence terms for the configuration. Never carries the raw key. */ + async license(): Promise { + return this.request("/api/v1/license"); + } + + /** Remote targets with their entitlement state. */ + async listTargets(): Promise { + return this.request("/api/v1/targets"); + } } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index f99bc82f..22d5c65d 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1158,6 +1158,12 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.registerCommand("operator.openWorkflows", () => openOperatorUi(ctx.getCurrentTicketsDir(), "workflows"), ), + vscode.commands.registerCommand("operator.openRemoteTargets", () => + openOperatorUi(ctx.getCurrentTicketsDir(), "remote-targets"), + ), + vscode.commands.registerCommand("operator.openLicense", () => + openOperatorUi(ctx.getCurrentTicketsDir(), "license"), + ), vscode.commands.registerCommand("operator.syncKanbanCollection", (item: StatusItem) => syncKanbanCollectionCommand(ctx, item), ), diff --git a/vscode-extension/src/open-operator-ui.ts b/vscode-extension/src/open-operator-ui.ts index dba0c13f..784443fd 100644 --- a/vscode-extension/src/open-operator-ui.ts +++ b/vscode-extension/src/open-operator-ui.ts @@ -22,7 +22,9 @@ export type OperatorUiRoute = | "kanban" | "queue" | "config" - | "workflows"; + | "workflows" + | "remote-targets" + | "license"; const ROUTE_HASH: Record = { dashboard: "#/", @@ -32,6 +34,8 @@ const ROUTE_HASH: Record = { queue: "#/queue", config: "#/config", workflows: "#/workflows", + "remote-targets": "#/remote-targets", + license: "#/settings/license", }; /** diff --git a/vscode-extension/src/sections/index.ts b/vscode-extension/src/sections/index.ts index 74d79bd7..4dd21232 100644 --- a/vscode-extension/src/sections/index.ts +++ b/vscode-extension/src/sections/index.ts @@ -20,3 +20,5 @@ export { IssueTypeSection } from "./issuetype-section"; export { DelegatorSection } from "./delegator-section"; export { ModelServerSection } from "./modelserver-section"; export { ManagedProjectsSection } from "./managed-projects-section"; +export { RemoteTargetsSection } from "./remote-targets-section"; +export { LicenseSection } from "./license-section"; diff --git a/vscode-extension/src/sections/kanban-section.ts b/vscode-extension/src/sections/kanban-section.ts index fb331adc..ebb41189 100644 --- a/vscode-extension/src/sections/kanban-section.ts +++ b/vscode-extension/src/sections/kanban-section.ts @@ -4,6 +4,23 @@ import type { SectionContext, StatusSection, KanbanState, KanbanProviderState } import type { SectionId, SectionHealth } from "../generated"; import { getKanbanWorkspaces } from "../walkthrough"; +/** Row label and icon per provider, keyed by the canonical slug. */ +const PROVIDER_LABELS: Record = { + operator: "Operator", + jira: "Jira", + linear: "Linear", + github: "GitHub Projects", + openspec: "OpenSpec", +}; + +const PROVIDER_ICONS: Record = { + operator: "layout", + jira: "operator-atlassian", + linear: "operator-linear", + github: "github", + openspec: "checklist", +}; + export class KanbanSection implements StatusSection { readonly sectionId: SectionId = "kanban"; readonly prerequisites: SectionId[] = ["connections"]; @@ -156,8 +173,19 @@ export class KanbanSection implements StatusSection { } } + // The built-in board leads every list: it is always on, has no config + // section to parse, and is what the other providers sync into. + providers.unshift({ + provider: "operator", + key: "operator", + enabled: true, + displayName: ".tickets", + url: "https://operator.untra.io/getting-started/kanban/operator/", + projects: [], + }); + this.state = { - configured: providers.length > 0, + configured: true, providers, }; } @@ -192,22 +220,8 @@ export class KanbanSection implements StatusSection { if (this.state.configured) { for (const prov of this.state.providers) { - const providerLabel = - prov.provider === "jira" - ? "Jira" - : prov.provider === "linear" - ? "Linear" - : prov.provider === "openspec" - ? "OpenSpec" - : "GitHub Projects"; - const providerIcon = - prov.provider === "jira" - ? "operator-atlassian" - : prov.provider === "linear" - ? "operator-linear" - : prov.provider === "openspec" - ? "checklist" - : "github"; + const providerLabel = PROVIDER_LABELS[prov.provider]; + const providerIcon = PROVIDER_ICONS[prov.provider]; items.push( new StatusItem({ label: providerLabel, diff --git a/vscode-extension/src/sections/license-section.ts b/vscode-extension/src/sections/license-section.ts new file mode 100644 index 00000000..9d79f6ca --- /dev/null +++ b/vscode-extension/src/sections/license-section.ts @@ -0,0 +1,102 @@ +import * as vscode from "vscode"; +import { StatusItem } from "../status-item"; +import type { SectionContext, StatusSection } from "./types"; +import type { SectionId, SectionHealth } from "../generated"; +import type { LicenseResponse } from "../generated/LicenseResponse"; +import { discoverApiUrl, OperatorApiClient } from "../api-client"; + +/** + * License section - which tier this configuration runs at. + * + * Read-only, like the rest of the extension's status tree: installing a licence + * happens in the hosted Operator UI, which the rows link out to. A missing + * licence is the free tier working as intended, so it reads Gray rather than + * as a fault. + */ +const STATUS_LABELS: Record = { + missing: "Free", + valid: "Premium", + expired: "Expired", + not_yet_valid: "Not yet valid", + invalid: "Invalid", +}; + +const STATUS_HEALTH: Record = { + missing: "Gray", + valid: "Green", + expired: "Yellow", + not_yet_valid: "Yellow", + invalid: "Red", +}; + +const day = (seconds: bigint): string => + new Date(Number(seconds) * 1000).toISOString().slice(0, 10); + +export class LicenseSection implements StatusSection { + readonly sectionId: SectionId = "license"; + readonly prerequisites: SectionId[] = []; + + private license: LicenseResponse | null = null; + + health(): SectionHealth { + return this.license ? STATUS_HEALTH[this.license.status] : "Gray"; + } + + async check(ctx: SectionContext): Promise { + try { + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + this.license = await client.license(); + } catch { + this.license = null; + } + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + const label = this.license ? STATUS_LABELS[this.license.status] : "API required"; + return new StatusItem({ + label: "License", + description: label, + icon: "key", + collapsibleState: this.license + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + if (!this.license) { + return []; + } + const rows: StatusItem[] = [ + this.row("Configuration", this.license.profile_id, "symbol-namespace"), + ]; + if (this.license.terms) { + rows.push( + this.row("Licensed to", this.license.terms.sub, "account"), + this.row("License ID", this.license.terms.jti, "file"), + this.row( + "Valid", + `${day(this.license.terms.nbf)} to ${day(this.license.terms.exp)}`, + "calendar", + ), + ); + } else { + rows.push(this.row("Included", "Multiple local agents and local containers", "check")); + } + return rows; + } + + private row(label: string, description: string, icon: string): StatusItem { + return new StatusItem({ + label, + description, + icon, + collapsibleState: vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + command: { command: "operator.openLicense", title: "Open License in Operator UI" }, + }); + } +} diff --git a/vscode-extension/src/sections/remote-targets-section.ts b/vscode-extension/src/sections/remote-targets-section.ts new file mode 100644 index 00000000..0028e1aa --- /dev/null +++ b/vscode-extension/src/sections/remote-targets-section.ts @@ -0,0 +1,80 @@ +import * as vscode from "vscode"; +import { StatusItem } from "../status-item"; +import type { SectionContext, StatusSection } from "./types"; +import type { SectionId, SectionHealth } from "../generated"; +import type { TargetResponse } from "../generated/TargetResponse"; +import { discoverApiUrl, OperatorApiClient } from "../api-client"; + +/** + * Remote Targets section - SSH hosts and Coder workspaces, and whether this + * configuration may currently launch onto them. + * + * Stays visible without a licence: hiding it would make Premium look like a + * missing feature rather than a locked one. Registering and probing targets + * happens in the hosted Operator UI, which the rows link out to. + */ +export class RemoteTargetsSection implements StatusSection { + readonly sectionId: SectionId = "remote-targets"; + readonly prerequisites: SectionId[] = []; + + private targets: TargetResponse[] = []; + private apiAvailable = false; + + health(): SectionHealth { + if (!this.apiAvailable || this.targets.length === 0) { + return "Gray"; + } + return this.targets.every((target) => target.entitled) ? "Green" : "Yellow"; + } + + async check(ctx: SectionContext): Promise { + try { + const client = new OperatorApiClient(await discoverApiUrl(ctx.ticketsDir)); + const response = await client.listTargets(); + this.targets = response.targets.filter( + (target: TargetResponse) => target.kind === "ssh" || target.kind === "coder", + ); + this.apiAvailable = true; + } catch { + this.targets = []; + this.apiAvailable = false; + } + } + + getTopLevelItem(_ctx: SectionContext): StatusItem { + const description = this.apiAvailable + ? this.targets.length === 0 + ? "Premium · none configured" + : `${this.targets.length} configured` + : "API required"; + return new StatusItem({ + label: "Remote Targets", + description, + icon: "server-environment", + collapsibleState: + this.targets.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: this.health(), + }); + } + + getChildren(_ctx: SectionContext, _element?: StatusItem): StatusItem[] { + return this.targets.map( + (target) => + new StatusItem({ + label: target.display_name ?? target.name, + description: `${target.kind} · ${target.entitled ? "available" : "license required"}`, + icon: "server", + collapsibleState: vscode.TreeItemCollapsibleState.None, + sectionId: this.sectionId, + health: target.entitled ? "Green" : "Yellow", + command: { + command: "operator.openRemoteTargets", + title: "Open Remote Targets in Operator UI", + }, + }), + ); + } +} diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts index f01341d7..582d1d00 100644 --- a/vscode-extension/src/sections/types.ts +++ b/vscode-extension/src/sections/types.ts @@ -78,7 +78,7 @@ export interface ConfigState { /** Config-driven state for a single kanban provider */ export interface KanbanProviderState { - provider: "jira" | "linear" | "github" | "openspec"; + provider: "operator" | "jira" | "linear" | "github" | "openspec"; key: string; enabled: boolean; displayName: string; diff --git a/vscode-extension/src/status-provider.ts b/vscode-extension/src/status-provider.ts index 66c92564..134302fe 100644 --- a/vscode-extension/src/status-provider.ts +++ b/vscode-extension/src/status-provider.ts @@ -4,11 +4,11 @@ * Slim orchestrator that delegates to per-section modules in ./sections/. * Each section owns its state, check logic, and tree item rendering. * - * Sections use progressive disclosure - they only appear when prerequisites are met: - * Tier 0: Configuration (always visible) - * Tier 1: Connections (requires configReady) - * Tier 2: Kanban/kanban, LLM Tools/llm, Model Servers/model-servers, Git/git (requires connectionsReady / llmReady) - * Tier 3: Issue Types/issuetypes (kanbanConfigured), Delegators/delegators (llmConfigured), Managed Projects/projects (gitConfigured) + * Sections use progressive disclosure - they only appear when every prerequisite + * section is visible and not Red, mirroring the Rust TUI's section registry: + * Configuration, Workflows, Remote Targets, License: no prerequisites + * Connections <- config; Kanban, LLM Tools, Git <- connections + * Model Servers, Delegators <- llm; Issue Types <- kanban; Managed Projects <- git */ import * as vscode from "vscode"; @@ -25,6 +25,8 @@ import { IssueTypeSection } from "./sections/issuetype-section"; import { DelegatorSection } from "./sections/delegator-section"; import { ModelServerSection } from "./sections/modelserver-section"; import { ManagedProjectsSection } from "./sections/managed-projects-section"; +import { RemoteTargetsSection } from "./sections/remote-targets-section"; +import { LicenseSection } from "./sections/license-section"; import { WorkflowsSection } from "./sections/workflows-section"; // Backward-compatible re-exports @@ -59,6 +61,8 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { private delegatorSection: DelegatorSection; private modelServerSection: ModelServerSection; private managedProjectsSection: ManagedProjectsSection; + private remoteTargetsSection: RemoteTargetsSection; + private licenseSection: LicenseSection; private workflowsSection: WorkflowsSection; // All sections for check() and routing @@ -77,6 +81,8 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.delegatorSection = new DelegatorSection(); this.modelServerSection = new ModelServerSection(); this.managedProjectsSection = new ManagedProjectsSection(); + this.remoteTargetsSection = new RemoteTargetsSection(); + this.licenseSection = new LicenseSection(); this.workflowsSection = new WorkflowsSection(); // Canonical section order - must match the `SectionId` enum in @@ -93,6 +99,8 @@ export class StatusTreeProvider implements vscode.TreeDataProvider { this.delegatorSection, this.managedProjectsSection, this.workflowsSection, + this.remoteTargetsSection, + this.licenseSection, ]; this.sectionMap = new Map(this.allSections.map((s) => [s.sectionId, s])); this.ctx = this.buildContext(); diff --git a/vscode-extension/test/suite/api-client.test.ts b/vscode-extension/test/suite/api-client.test.ts index 0fcce431..0518cb26 100644 --- a/vscode-extension/test/suite/api-client.test.ts +++ b/vscode-extension/test/suite/api-client.test.ts @@ -20,6 +20,7 @@ import { LIVEZ_PATH, OperatorApiClient, discoverApiUrl, + profileApiPath, toJson, } from "../../src/api-client"; import { clearCredentialProvider, setCredentialProvider } from "../../src/auth/credentials"; @@ -55,6 +56,37 @@ interface RejectRequestBody { reason: string; } +suite("Profile-scoped routing", () => { + test("routes a request at the configuration the daemon serves", () => { + assert.strictEqual( + profileApiPath("/api/v1/tickets", "abc-123"), + "/api/v1/profiles/abc-123/tickets", + ); + }); + + test("leaves server-level routes unscoped", () => { + // These are served by the server itself, not by any one configuration; + // scoping them would 404 against the tenant dispatcher. + for (const serverPath of [ + "/api/v1/auth/session", + "/api/v1/health", + "/api/v1/integrations", + "/api/v1/profiles", + ]) { + assert.strictEqual(profileApiPath(serverPath, "abc-123"), serverPath); + } + }); + + test("leaves every path alone when the daemon reported no configuration", () => { + // An older daemon writes no profile_id; its routes are the unscoped ones. + assert.strictEqual(profileApiPath("/api/v1/tickets", undefined), "/api/v1/tickets"); + }); + + test("leaves the public liveness probe alone", () => { + assert.strictEqual(profileApiPath(LIVEZ_PATH, "abc-123"), LIVEZ_PATH); + }); +}); + suite("API Client Test Suite", () => { let fetchStub: sinon.SinonStub; let credentials: FakeCredentials; diff --git a/vscode-extension/test/suite/status-provider.test.ts b/vscode-extension/test/suite/status-provider.test.ts index e3e31c7c..be25b8db 100644 --- a/vscode-extension/test/suite/status-provider.test.ts +++ b/vscode-extension/test/suite/status-provider.test.ts @@ -306,7 +306,7 @@ suite("Status Provider Test Suite", () => { await provider.setTicketsDir(tempDir); const labels = getSectionLabels(provider.getChildren()); - assert.deepStrictEqual(labels, ["Configuration", "Workflows"]); + assert.deepStrictEqual(labels, ["Configuration", "Workflows", "Remote Targets", "License"]); }); test("tier 1: Configuration + Connections when config ready but no connections", async () => { @@ -319,10 +319,16 @@ suite("Status Provider Test Suite", () => { await provider.setTicketsDir(tempDir); const labels = getSectionLabels(provider.getChildren()); - assert.deepStrictEqual(labels, ["Configuration", "Connections", "Workflows"]); + assert.deepStrictEqual(labels, [ + "Configuration", + "Connections", + "Workflows", + "Remote Targets", + "License", + ]); }); - test("tier 2: adds Kanban, LLM Tools, Model Servers, Git when connections ready", async () => { + test("tier 2: adds Kanban, LLM Tools, Model Servers, Git, Issue Types when connections ready", async () => { const mockContext = createMockContext(sandbox, "/fake/working-dir"); sandbox.stub(configPaths, "configFileExists").resolves(true); sandbox.stub(configPaths, "getResolvedConfigPath").returns(""); @@ -354,8 +360,11 @@ suite("Status Provider Test Suite", () => { "LLM Tools", "Model Servers", "Git", + "Issue Types", "Delegators", "Workflows", + "Remote Targets", + "License", ]); }); @@ -432,8 +441,8 @@ suite("Status Provider Test Suite", () => { "Should include Managed Projects when git configured", ); assert.ok( - !labels.includes("Issue Types"), - "Should not include Issue Types when kanban not configured", + labels.includes("Issue Types"), + "Issue Types stays visible with no kanban config: the built-in board keeps Kanban green", ); }); @@ -480,6 +489,8 @@ suite("Status Provider Test Suite", () => { "Delegators", "Managed Projects", "Workflows", + "Remote Targets", + "License", ]); }); @@ -502,8 +513,8 @@ suite("Status Provider Test Suite", () => { const labels = getSectionLabels(provider.getChildren()); assert.deepStrictEqual( labels, - ["Configuration", "Connections", "Workflows"], - "Should only show tier 0+1 (plus the prerequisite-free Workflows) when connections not ready", + ["Configuration", "Connections", "Workflows", "Remote Targets", "License"], + "Should only show tier 0+1 plus the prerequisite-free sections when connections not ready", ); }); }); diff --git a/vscode-extension/test/suite/ticket-provider.test.ts b/vscode-extension/test/suite/ticket-provider.test.ts index 62e77453..3e0cccc6 100644 --- a/vscode-extension/test/suite/ticket-provider.test.ts +++ b/vscode-extension/test/suite/ticket-provider.test.ts @@ -14,7 +14,7 @@ import type { OutputChannel } from "vscode"; import { TicketTreeProvider } from "../../src/ticket-provider"; import { OperatorApiClient } from "../../src/api-client"; import { IssueTypeService } from "../../src/issuetype-service"; -import type { KanbanBoardResponse } from "../../src/generated"; +import type { KanbanBoardResponse, KanbanTicketCard } from "../../src/generated"; function mockOutputChannel(): OutputChannel { const channel = { @@ -42,9 +42,9 @@ function board(partial: Partial): KanbanBoardResponse { }; } -const QUEUE_CARD = { +const QUEUE_CARD: KanbanTicketCard = { id: "FEAT-9100", - step_display_name: null as string | null, + step_display_name: null, summary: "API-backed tree test", ticket_type: "FEAT", project: "operator", diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 93006cf0..88787015 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -3,6 +3,7 @@ import type { Config } from "../../src/generated/Config"; /** Sensible defaults matching Rust Config::default() */ const DEFAULT_CONFIG: Config = { + profile: { id: "00000000-0000-0000-0000-000000000000", name: "legacy" }, projects: [], agents: { max_parallel: 2, diff --git a/webcomponents/src/components/AppShell.module.css b/webcomponents/src/components/AppShell.module.css index 097ca430..711ad910 100644 --- a/webcomponents/src/components/AppShell.module.css +++ b/webcomponents/src/components/AppShell.module.css @@ -109,7 +109,9 @@ text-decoration: none; font-size: 0.875rem; border-radius: var(--radius); - transition: background-color 0.2s, color 0.2s; + transition: + background-color 0.2s, + color 0.2s; } .navLink:hover { @@ -144,22 +146,22 @@ box-shadow: 0 0 0 1px var(--color-cream); } -.navDot[data-health='green'] { +.navDot[data-health="green"] { background: var(--color-green-l3); } /* 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'] { +.active .navDot[data-health="green"] { background: var(--color-green-l1); } -.navDot[data-health='yellow'] { +.navDot[data-health="yellow"] { background: var(--warning); } -.navDot[data-health='red'] { +.navDot[data-health="red"] { background: var(--danger); } -.navDot[data-health='gray'] { +.navDot[data-health="gray"] { background: var(--text-muted); } diff --git a/webcomponents/src/components/Choice.module.css b/webcomponents/src/components/Choice.module.css index 7f7603be..591c49af 100644 --- a/webcomponents/src/components/Choice.module.css +++ b/webcomponents/src/components/Choice.module.css @@ -32,3 +32,15 @@ .choice small { color: var(--text-muted); } + +/* An option that leads the set spans the whole grid row. */ +.wide { + grid-column: 1 / -1; +} + +/* Already in effect: shown as chosen, but nothing to click. */ +.locked { + cursor: default; + border-color: var(--accent); + box-shadow: inset 3px 0 var(--accent); +} diff --git a/webcomponents/src/components/Choice.tsx b/webcomponents/src/components/Choice.tsx index ffc96ac6..913ba09f 100644 --- a/webcomponents/src/components/Choice.tsx +++ b/webcomponents/src/components/Choice.tsx @@ -20,6 +20,10 @@ export interface ChoiceProps { selected: boolean; onSelect: (value: Value) => void; children: ReactNode; + /** Span the whole grid row, for an option that leads the set. */ + wide?: boolean; + /** Already in effect and not something to pick; renders without a click. */ + locked?: boolean; } export function Choice({ @@ -27,12 +31,29 @@ export function Choice({ selected, onSelect, children, + wide = false, + locked = false, }: ChoiceProps) { + const className = [ + styles.choice, + selected ? styles.selected : "", + wide ? styles.wide : "", + locked ? styles.locked : "", + ] + .filter(Boolean) + .join(" "); + if (locked) { + return ( +
    + {children} +
    + ); + } return ( ); } - -function statusIcon(status: string): string { - switch (status) { - case "running": - return "▶"; - case "awaiting": - case "waiting": - case "blocked": - return "⏸"; - case "completed": - case "done": - return "✓"; - default: - return "•"; - } -} - -function priorityKey(priority: string): string { - const match = priority.match(/^P([0-3])/i); - return match ? `p${match[1]}` : "p2"; -} diff --git a/webcomponents/src/components/KanbanFilterBar.module.css b/webcomponents/src/components/KanbanFilterBar.module.css new file mode 100644 index 00000000..7336e143 --- /dev/null +++ b/webcomponents/src/components/KanbanFilterBar.module.css @@ -0,0 +1,77 @@ +.bar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.search { + flex: 1 1 14rem; + min-width: 10rem; + padding: 0.35rem 0.6rem; + color: var(--text); + font: inherit; + font-size: 0.8rem; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.facet { + display: inline-flex; + gap: 0.25rem; + align-items: center; + padding: 0; + margin: 0; + border: 0; +} + +.facetLabel { + padding: 0; + color: var(--text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.group { + display: inline-flex; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.option, +.optionActive { + padding: 0.25rem 0.6rem; + color: var(--text-muted); + font: inherit; + font-size: 0.75rem; + cursor: pointer; + background: transparent; + border: 0; +} + +.optionActive { + color: var(--text); + font-weight: 600; + background: var(--surface-alt); +} + +.clear { + padding: 0.25rem 0.6rem; + color: var(--text); + font: inherit; + font-size: 0.75rem; + cursor: pointer; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +@media (max-width: 900px) { + .search { + flex-basis: 100%; + } +} diff --git a/webcomponents/src/components/KanbanFilterBar.tsx b/webcomponents/src/components/KanbanFilterBar.tsx new file mode 100644 index 00000000..3435aa18 --- /dev/null +++ b/webcomponents/src/components/KanbanFilterBar.tsx @@ -0,0 +1,125 @@ +import { useCallback } from "react"; + +import type { TicketPriority } from "../generated/TicketPriority"; +import { + hasActiveFilters, + type KanbanFacets, + type KanbanFilterState, +} from "../shared/kanban-filters"; +import styles from "./KanbanFilterBar.module.css"; + +export interface KanbanFilterBarProps { + facets: KanbanFacets; + state: KanbanFilterState; + onChange: (next: KanbanFilterState) => void; + onClear: () => void; +} + +function toggle(selected: T[], value: T): T[] { + return selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]; +} + +export function KanbanFilterBar({ facets, state, onChange, onClear }: KanbanFilterBarProps) { + const onSearch = useCallback( + (e: React.ChangeEvent) => onChange({ ...state, search: e.target.value }), + [onChange, state], + ); + const onProject = useCallback( + (value: string) => onChange({ ...state, projects: toggle(state.projects, value) }), + [onChange, state], + ); + const onType = useCallback( + (value: string) => onChange({ ...state, types: toggle(state.types, value) }), + [onChange, state], + ); + const onPriority = useCallback( + (value: string) => + onChange({ ...state, priorities: toggle(state.priorities, value as TicketPriority) }), + [onChange, state], + ); + + return ( +
    + + + + + {hasActiveFilters(state) && ( + + )} +
    + ); +} + +/** A facet with fewer than two options cannot narrow anything, so it is hidden. */ +function Facet({ + label, + options, + selected, + onToggle, +}: { + label: string; + options: string[]; + selected: string[]; + onToggle: (value: string) => void; +}) { + if (options.length < 2) { + return null; + } + return ( +
    + {label} +
    + {options.map((option) => ( + + ))} +
    +
    + ); +} + +function FacetOption({ + option, + pressed, + onToggle, +}: { + option: string; + pressed: boolean; + onToggle: (value: string) => void; +}) { + const onClick = useCallback(() => onToggle(option), [onToggle, option]); + return ( + + ); +} diff --git a/webcomponents/src/components/LaunchForm.module.css b/webcomponents/src/components/LaunchForm.module.css index 1770897a..3eccc036 100644 --- a/webcomponents/src/components/LaunchForm.module.css +++ b/webcomponents/src/components/LaunchForm.module.css @@ -60,5 +60,10 @@ transition: background 0.15s; } -.launchBtn:hover:not(:disabled) { background: var(--action-bg-hover); } -.launchBtn:disabled { cursor: progress; opacity: 0.6; } +.launchBtn:hover:not(:disabled) { + background: var(--action-bg-hover); +} +.launchBtn:disabled { + cursor: progress; + opacity: 0.6; +} diff --git a/webcomponents/src/components/PremiumPaywall.module.css b/webcomponents/src/components/PremiumPaywall.module.css new file mode 100644 index 00000000..21cc219d --- /dev/null +++ b/webcomponents/src/components/PremiumPaywall.module.css @@ -0,0 +1,71 @@ +.panel { + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.heading, +.actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; +} + +.heading h2 { + margin: 0; + font-size: 1.1rem; + color: var(--text); +} + +/* Restrained by intent: a label, not a call to action. */ +.badge { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid var(--warning); + color: var(--warning); + border-radius: var(--radius-lg); + padding: 0.1rem 0.5rem; +} + +.panel p { + margin: 0; + line-height: 1.5; + color: var(--text-muted); +} + +.actions button, +.actions a { + min-height: 2rem; + display: inline-flex; + align-items: center; + padding: 0 0.85rem; + border-radius: var(--radius); + font: inherit; +} + +.actions button { + background: var(--action-bg); + color: var(--action-text); + border: 1px solid transparent; + cursor: pointer; +} + +.actions button:hover { + background: var(--action-bg-hover); +} + +.actions a { + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; +} + +.inline { + padding: 0.75rem; +} diff --git a/webcomponents/src/components/PremiumPaywall.test.ts b/webcomponents/src/components/PremiumPaywall.test.ts new file mode 100644 index 00000000..86413de5 --- /dev/null +++ b/webcomponents/src/components/PremiumPaywall.test.ts @@ -0,0 +1,41 @@ +/** + * The paywall renders a build-time URL as a link, so the one piece of logic + * worth asserting is which values are allowed to become an href. + */ + +import { describe, expect, test } from "bun:test"; + +import { safePurchaseDestination } from "./PremiumPaywall"; + +describe("safePurchaseDestination", () => { + test("accepts https, whatever the case", () => { + expect(safePurchaseDestination("https://operator.untra.io/premium")).toBe( + "https://operator.untra.io/premium", + ); + expect(safePurchaseDestination("HTTPS://operator.untra.io/premium")).toBe( + "HTTPS://operator.untra.io/premium", + ); + }); + + test("refuses everything that is not https", () => { + for (const hostile of [ + "http://operator.untra.io/premium", + // oxlint-disable-next-line no-script-url + "javascript:alert(1)", + // oxlint-disable-next-line no-script-url + "JavaScript:alert(1)", + "data:text/html,", + "//operator.untra.io/premium", + "operator.untra.io/premium", + " https://operator.untra.io/premium", + "", + ]) { + expect(safePurchaseDestination(hostile)).toBeNull(); + } + }); + + test("treats a missing destination as no link", () => { + expect(safePurchaseDestination(null)).toBeNull(); + expect(safePurchaseDestination(undefined)).toBeNull(); + }); +}); diff --git a/webcomponents/src/components/PremiumPaywall.tsx b/webcomponents/src/components/PremiumPaywall.tsx new file mode 100644 index 00000000..6d448a0d --- /dev/null +++ b/webcomponents/src/components/PremiumPaywall.tsx @@ -0,0 +1,53 @@ +import { useId } from "react"; +import styles from "./PremiumPaywall.module.css"; + +export type PremiumPaywallProps = { + feature: string; + description?: string; + purchaseUrl?: string | null; + onAddLicense: () => void; + inline?: boolean; +}; + +/** + * The purchase destination, or null if it is not one we will link to. + * + * The URL is a build-time input rather than something this component controls, + * so an http, javascript: or data: value must never become an anchor href. + */ +export function safePurchaseDestination(url: string | null | undefined): string | null { + return url && /^https:\/\//i.test(url) ? url : null; +} + +export function PremiumPaywall({ + feature, + description, + purchaseUrl, + onAddLicense, + inline = false, +}: PremiumPaywallProps) { + const headingId = useId(); + const safePurchaseUrl = safePurchaseDestination(purchaseUrl); + return ( +
    +
    +

    {feature}

    + Premium +
    +

    {description ?? "Available with an Operator Premium license for this configuration."}

    +
    + + {safePurchaseUrl && ( + + View Premium + + )} +
    +
    + ); +} diff --git a/webcomponents/src/components/RightPanel.module.css b/webcomponents/src/components/RightPanel.module.css index 47fc4076..3a3081e3 100644 --- a/webcomponents/src/components/RightPanel.module.css +++ b/webcomponents/src/components/RightPanel.module.css @@ -19,7 +19,28 @@ border-bottom: 1px solid var(--border); } -.title { color: var(--text); font-size: 0.9375rem; font-weight: 700; } -.close { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-muted); font-size: 1rem; line-height: 1; cursor: pointer; background: transparent; border: 0; border-radius: var(--radius-sm); } -.close:hover { color: var(--text); background: var(--surface-alt); } -.body { padding: 20px; } +.title { + color: var(--text); + font-size: 0.9375rem; + font-weight: 700; +} +.close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--radius-sm); +} +.close:hover { + color: var(--text); + background: var(--surface-alt); +} +.body { + padding: 20px; +} diff --git a/webcomponents/src/components/SectionCard.module.css b/webcomponents/src/components/SectionCard.module.css index 2ca560d7..09769783 100644 --- a/webcomponents/src/components/SectionCard.module.css +++ b/webcomponents/src/components/SectionCard.module.css @@ -6,7 +6,7 @@ border-radius: var(--radius-lg); } -.card[data-locked='true'] { +.card[data-locked="true"] { background: var(--surface-alt); } @@ -31,10 +31,18 @@ border-radius: 50%; } -.dot[data-health='green'] { background: var(--color-green-l1); } -.dot[data-health='yellow'] { background: var(--warning); } -.dot[data-health='red'] { background: var(--danger); } -.dot[data-health='gray'] { background: var(--text-muted); } +.dot[data-health="green"] { + background: var(--color-green-l1); +} +.dot[data-health="yellow"] { + background: var(--warning); +} +.dot[data-health="red"] { + background: var(--danger); +} +.dot[data-health="gray"] { + background: var(--text-muted); +} .label { font-size: 0.95rem; @@ -57,7 +65,9 @@ font-size: 0.75rem; } -.prereqLink { color: var(--accent-text); } +.prereqLink { + color: var(--accent-text); +} .rows { display: flex; @@ -75,8 +85,12 @@ font-size: 0.825rem; } -.rowLabel { font-weight: 500; } -.rowDesc { color: var(--text-muted); } +.rowLabel { + font-weight: 500; +} +.rowDesc { + color: var(--text-muted); +} .rowActions { display: flex; diff --git a/webcomponents/src/components/TicketCreateForm.module.css b/webcomponents/src/components/TicketCreateForm.module.css new file mode 100644 index 00000000..7f820758 --- /dev/null +++ b/webcomponents/src/components/TicketCreateForm.module.css @@ -0,0 +1,63 @@ +.form { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.fieldLabel { + color: var(--text-muted); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.select, +.input { + padding: 0.4rem 0.5rem; + color: var(--text); + font: inherit; + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.error { + padding: 0.6rem 0.75rem; + color: var(--danger); + font-size: 0.8rem; + background: var(--danger-bg); + border-radius: var(--radius); +} + +.createBtn { + padding: 0.5rem 0.75rem; + color: var(--action-text); + font: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + background: var(--action-bg); + border: 0; + border-radius: var(--radius); + transition: background 0.15s; +} + +.createBtn:hover:not(:disabled) { + background: var(--action-bg-hover); +} +.createBtn:disabled { + cursor: not-allowed; + opacity: 0.6; +} diff --git a/webcomponents/src/components/TicketCreateForm.tsx b/webcomponents/src/components/TicketCreateForm.tsx new file mode 100644 index 00000000..47458530 --- /dev/null +++ b/webcomponents/src/components/TicketCreateForm.tsx @@ -0,0 +1,95 @@ +import type { IssueTypeSummary } from "../generated/IssueTypeSummary"; +import type { ProjectSummary } from "../generated/ProjectSummary"; +import styles from "./TicketCreateForm.module.css"; + +export interface TicketCreateFormValue { + issueType: string; + project: string; + summary: string; +} + +export interface TicketCreateFormProps { + value: TicketCreateFormValue; + issueTypes: IssueTypeSummary[]; + projects: ProjectSummary[]; + busy?: boolean; + error?: string | null; + onChange: (value: TicketCreateFormValue) => void; + onSubmit: () => void; +} + +export function TicketCreateForm({ + value, + issueTypes, + projects, + busy = false, + error, + onChange, + onSubmit, +}: TicketCreateFormProps) { + const update = (key: K, next: TicketCreateFormValue[K]) => + onChange({ ...value, [key]: next }); + + const ready = value.issueType !== "" && value.project !== "" && value.summary.trim() !== ""; + + return ( +
    { + event.preventDefault(); + onSubmit(); + }} + > + + + + {error &&
    {error}
    } + +
    + ); +} diff --git a/webcomponents/src/components/TicketDetailView.module.css b/webcomponents/src/components/TicketDetailView.module.css index 584ca603..89d45dd6 100644 --- a/webcomponents/src/components/TicketDetailView.module.css +++ b/webcomponents/src/components/TicketDetailView.module.css @@ -1,12 +1,67 @@ -.panel { display: flex; flex-direction: column; gap: 1.25rem; } -.detail { display: flex; flex-direction: column; gap: 0.35rem; } -.detailRow { display: flex; gap: 0.5rem; align-items: center; } -.ticketType { color: var(--accent-text); font-size: 0.65rem; font-weight: 600; text-transform: uppercase; } -.ticketId { margin-left: auto; color: var(--text-muted); font-size: 0.75rem; } -.summary { margin: 0; font-size: 0.9rem; line-height: 1.35; } -.meta { margin: 0; color: var(--text-muted); font-size: 0.7rem; } -.result { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; background: var(--success-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); } -.resultHeader { color: var(--success); font-size: 0.85rem; font-weight: 600; } -.actions { display: flex; flex-direction: column; gap: 0.5rem; } -.graphSection { display: flex; flex-direction: column; gap: 0.5rem; } -.graphLabel { color: var(--text-muted); font-size: 0.65rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } +.panel { + display: flex; + flex-direction: column; + gap: 1.25rem; +} +.detail { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.detailRow { + display: flex; + gap: 0.5rem; + align-items: center; +} +.ticketType { + color: var(--accent-text); + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; +} +.ticketId { + margin-left: auto; + color: var(--text-muted); + font-size: 0.75rem; +} +.summary { + margin: 0; + font-size: 0.9rem; + line-height: 1.35; +} +.meta { + margin: 0; + color: var(--text-muted); + font-size: 0.7rem; +} +.result { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + background: var(--success-bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.resultHeader { + color: var(--success); + font-size: 0.85rem; + font-weight: 600; +} +.actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.graphSection { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.graphLabel { + color: var(--text-muted); + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/webcomponents/src/index.ts b/webcomponents/src/index.ts index 7b74ef18..ed61f4ed 100644 --- a/webcomponents/src/index.ts +++ b/webcomponents/src/index.ts @@ -1,5 +1,7 @@ import "./styles/semantic.css"; +export { PremiumPaywall, type PremiumPaywallProps } from "./components/PremiumPaywall"; + export { AppShell, BrandName, @@ -30,8 +32,12 @@ export { ConceptIcon } from "./components/ConceptIcon"; export type { ConceptIconProps } from "./components/ConceptIcon"; export { KanbanBoard } from "./components/KanbanBoard"; export type { KanbanBoardProps } from "./components/KanbanBoard"; +export { KanbanFilterBar } from "./components/KanbanFilterBar"; +export type { KanbanFilterBarProps } from "./components/KanbanFilterBar"; export { LaunchForm } from "./components/LaunchForm"; export type { LaunchFormProps, LaunchFormValue } from "./components/LaunchForm"; +export { TicketCreateForm } from "./components/TicketCreateForm"; +export type { TicketCreateFormProps, TicketCreateFormValue } from "./components/TicketCreateForm"; export { PageHeader } from "./components/PageHeader"; export type { PageHeaderProps } from "./components/PageHeader"; export { RightPanel } from "./components/RightPanel"; @@ -61,3 +67,13 @@ export type { QueueViewProps } from "./views/QueueView"; export { useDocumentTheme, usePhaseColors } from "./shared/theme"; export type { Theme } from "./shared/theme"; + +export { + DEFAULT_FILTER_STATE, + facetsFromBoard, + filterBoard, + hasActiveFilters, + useKanbanFilters, +} from "./shared/kanban-filters"; +export type { KanbanFacets, KanbanFilterState } from "./shared/kanban-filters"; +export { PRIORITY_KEY, PRIORITY_ORDER, STATUS_GLYPH } from "./shared/ticket-fields"; diff --git a/webcomponents/src/shared/kanban-filters.test.ts b/webcomponents/src/shared/kanban-filters.test.ts new file mode 100644 index 00000000..5a33c051 --- /dev/null +++ b/webcomponents/src/shared/kanban-filters.test.ts @@ -0,0 +1,161 @@ +/** + * Board filtering is a pure transform of the payload, which is the whole point + * of keeping it out of the components. + */ + +import { describe, expect, test } from "bun:test"; + +import type { KanbanBoardResponse } from "../generated/KanbanBoardResponse"; +import type { KanbanTicketCard } from "../generated/KanbanTicketCard"; +import { + DEFAULT_FILTER_STATE, + facetsFromBoard, + filterBoard, + hasActiveFilters, + type KanbanFilterState, +} from "./kanban-filters"; + +function card(over: Partial & { id: string }): KanbanTicketCard { + return { + summary: "A ticket", + ticket_type: "FEAT", + project: "operator", + status: "queued", + step: "plan", + step_display_name: "Plan", + priority: "P2-medium", + timestamp: "20260101-1200", + filename: `${over.id}.md`, + ...over, + }; +} + +const board: KanbanBoardResponse = { + queue: [ + card({ id: "FEAT-1", summary: "Add audit history", project: "operator" }), + card({ + id: "TASK-2", + summary: "Rotate signing key", + ticket_type: "TASK", + priority: "P0-critical", + }), + ], + running: [ + card({ + id: "FIX-3", + summary: "Stale queue results", + ticket_type: "FIX", + project: "gamesvc", + status: "running", + }), + ], + awaiting: [ + card({ + id: "INV-4", + summary: "Compare retry behaviour", + ticket_type: "INV", + project: "gamesvc", + status: "awaiting", + }), + ], + done: [ + card({ + id: "SPIKE-5", + summary: "Prototype layout", + ticket_type: "SPIKE", + status: "completed", + priority: "P3-low", + }), + ], + total_count: 5, + last_updated: "2026-01-01T12:00:00Z", +}; + +function withFilter(over: Partial): KanbanFilterState { + return { ...DEFAULT_FILTER_STATE, ...over }; +} + +describe("filterBoard", () => { + test("the default state is inert", () => { + expect(filterBoard(board, DEFAULT_FILTER_STATE)).toEqual(board); + expect(hasActiveFilters(DEFAULT_FILTER_STATE)).toBe(false); + }); + + test("search matches summary and id, case-insensitively", () => { + expect(filterBoard(board, withFilter({ search: "AUDIT" })).queue.map((c) => c.id)).toEqual([ + "FEAT-1", + ]); + expect(filterBoard(board, withFilter({ search: "task-2" })).queue.map((c) => c.id)).toEqual([ + "TASK-2", + ]); + }); + + test("search requires every term to match", () => { + expect(filterBoard(board, withFilter({ search: "rotate key" })).total_count).toBe(1); + expect(filterBoard(board, withFilter({ search: "rotate nonsense" })).total_count).toBe(0); + }); + + test("filters by project", () => { + const filtered = filterBoard(board, withFilter({ projects: ["gamesvc"] })); + expect(filtered.queue).toEqual([]); + expect(filtered.running.map((c) => c.id)).toEqual(["FIX-3"]); + expect(filtered.awaiting.map((c) => c.id)).toEqual(["INV-4"]); + }); + + test("filters by type, OR within the facet", () => { + const filtered = filterBoard(board, withFilter({ types: ["FEAT", "FIX"] })); + expect(filtered.total_count).toBe(2); + }); + + test("filters by priority", () => { + const filtered = filterBoard(board, withFilter({ priorities: ["P0-critical"] })); + expect(filtered.queue.map((c) => c.id)).toEqual(["TASK-2"]); + }); + + test("ANDs across facets", () => { + expect( + filterBoard(board, withFilter({ projects: ["gamesvc"], types: ["FIX"] })).total_count, + ).toBe(1); + expect( + filterBoard(board, withFilter({ projects: ["operator"], types: ["FIX"] })).total_count, + ).toBe(0); + }); + + test("recomputes total_count from what is shown", () => { + expect(filterBoard(board, withFilter({ projects: ["gamesvc"] })).total_count).toBe(2); + }); + + test("filtering everything out is distinguishable from an empty queue", () => { + const filtered = filterBoard(board, withFilter({ search: "nothing matches this" })); + expect(filtered.total_count).toBe(0); + expect(hasActiveFilters(withFilter({ search: "nothing matches this" }))).toBe(true); + }); + + test("keeps last_updated so the meta line stays honest", () => { + expect(filterBoard(board, withFilter({ types: ["FEAT"] })).last_updated).toBe( + board.last_updated, + ); + }); +}); + +describe("facetsFromBoard", () => { + test("derives deduped, sorted options from the board itself", () => { + const facets = facetsFromBoard(board); + expect(facets.projects).toEqual(["gamesvc", "operator"]); + expect(facets.types).toEqual(["FEAT", "FIX", "INV", "SPIKE", "TASK"]); + }); + + test("offers priorities most urgent first, only those present", () => { + expect(facetsFromBoard(board).priorities).toEqual(["P0-critical", "P2-medium", "P3-low"]); + }); +}); + +describe("hasActiveFilters", () => { + test("whitespace-only search is not a filter", () => { + expect(hasActiveFilters(withFilter({ search: " " }))).toBe(false); + }); + + test("any non-empty facet counts", () => { + expect(hasActiveFilters(withFilter({ types: ["FEAT"] }))).toBe(true); + }); +}); diff --git a/webcomponents/src/shared/kanban-filters.ts b/webcomponents/src/shared/kanban-filters.ts new file mode 100644 index 00000000..f65685ed --- /dev/null +++ b/webcomponents/src/shared/kanban-filters.ts @@ -0,0 +1,161 @@ +/** + * Client-side board filtering. + * + * The board endpoint returns everything; narrowing it is view state, so it + * lives here as a pure transform plus a hook that remembers the choice per + * viewer. Empty arrays mean "no filter" - there are no sentinel values. + */ + +import { useCallback, useState } from "react"; + +import type { KanbanBoardResponse } from "../generated/KanbanBoardResponse"; +import type { KanbanTicketCard } from "../generated/KanbanTicketCard"; +import type { TicketPriority } from "../generated/TicketPriority"; +import { PRIORITY_ORDER } from "./ticket-fields"; + +const STORAGE_KEY = "operator-queue-filters"; + +const COLUMNS = ["queue", "running", "awaiting", "done"] as const; + +export interface KanbanFilterState { + search: string; + projects: string[]; + types: string[]; + priorities: TicketPriority[]; +} + +export const DEFAULT_FILTER_STATE: KanbanFilterState = { + search: "", + projects: [], + types: [], + priorities: [], +}; + +/** The options a bar can offer, derived from the board rather than hardcoded. */ +export interface KanbanFacets { + projects: string[]; + types: string[]; + priorities: TicketPriority[]; +} + +function allCards(board: KanbanBoardResponse): KanbanTicketCard[] { + return COLUMNS.flatMap((column) => board[column]); +} + +function distinct(values: string[]): string[] { + return [...new Set(values)].toSorted(); +} + +export function hasActiveFilters(state: KanbanFilterState): boolean { + return ( + state.search.trim().length > 0 || + state.projects.length > 0 || + state.types.length > 0 || + state.priorities.length > 0 + ); +} + +export function facetsFromBoard(board: KanbanBoardResponse): KanbanFacets { + const cards = allCards(board); + const present = new Set(cards.map((c) => c.priority)); + return { + projects: distinct(cards.map((c) => c.project)), + types: distinct(cards.map((c) => c.ticket_type)), + priorities: PRIORITY_ORDER.filter((p) => present.has(p)), + }; +} + +/** Same matching the docs-site catalog search uses: every term, anywhere. */ +function matchesSearch(card: KanbanTicketCard, terms: string[]): boolean { + if (terms.length === 0) { + return true; + } + const haystack = `${card.id} ${card.summary}`.toLowerCase(); + return terms.every((term) => haystack.includes(term)); +} + +function matchesFacet(value: string, selected: string[]): boolean { + return selected.length === 0 || selected.includes(value); +} + +export function filterBoard( + board: KanbanBoardResponse, + state: KanbanFilterState, +): KanbanBoardResponse { + if (!hasActiveFilters(state)) { + return board; + } + const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean); + const keep = (card: KanbanTicketCard) => + matchesSearch(card, terms) && + matchesFacet(card.project, state.projects) && + matchesFacet(card.ticket_type, state.types) && + matchesFacet(card.priority, state.priorities); + + const queue = board.queue.filter(keep); + const running = board.running.filter(keep); + const awaiting = board.awaiting.filter(keep); + const done = board.done.filter(keep); + + return { + queue, + running, + awaiting, + done, + total_count: queue.length + running.length + awaiting.length + done.length, + last_updated: board.last_updated, + }; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +/** Storage can be absent or throw in a restricted webview; never trust it. */ +function readStoredFilters(): KanbanFilterState { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + const candidate = parsed as Partial>; + return { + search: typeof candidate.search === "string" ? candidate.search : "", + projects: isStringArray(candidate.projects) ? candidate.projects : [], + types: isStringArray(candidate.types) ? candidate.types : [], + priorities: isStringArray(candidate.priorities) + ? candidate.priorities.filter((p): p is TicketPriority => + (PRIORITY_ORDER as string[]).includes(p), + ) + : [], + }; + } + } + } catch { + // localStorage unavailable (e.g. restricted webview) - fall through + } + return DEFAULT_FILTER_STATE; +} + +export interface KanbanFiltersHandle { + state: KanbanFilterState; + setState: (next: KanbanFilterState) => void; + clear: () => void; +} + +export function useKanbanFilters(): KanbanFiltersHandle { + const [state, setStateRaw] = useState(readStoredFilters); + + const setState = useCallback((next: KanbanFilterState) => { + setStateRaw(next); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // ignore persistence failures + } + }, []); + + const clear = useCallback(() => setState(DEFAULT_FILTER_STATE), [setState]); + + return { state, setState, clear }; +} diff --git a/webcomponents/src/shared/ticket-fields.test.ts b/webcomponents/src/shared/ticket-fields.test.ts new file mode 100644 index 00000000..77b8367f --- /dev/null +++ b/webcomponents/src/shared/ticket-fields.test.ts @@ -0,0 +1,39 @@ +/** + * The board renders a glyph and a CSS key per card. Both maps are keyed by a + * generated union, so what is worth asserting is that every variant is covered + * and nothing falls through to a default. + */ + +import { describe, expect, test } from "bun:test"; + +import { PRIORITY_KEY, PRIORITY_ORDER, STATUS_GLYPH } from "./ticket-fields"; + +const STATUSES = ["queued", "running", "awaiting", "completed"]; +const PRIORITIES = ["P0-critical", "P1-high", "P2-medium", "P3-low"] as const; + +describe("STATUS_GLYPH", () => { + test("covers every status exactly once", () => { + expect(Object.keys(STATUS_GLYPH).toSorted()).toEqual(STATUSES.toSorted()); + }); + + test("gives each status a distinct glyph", () => { + const glyphs = Object.values(STATUS_GLYPH); + expect(new Set(glyphs).size).toBe(glyphs.length); + }); +}); + +describe("PRIORITY_KEY", () => { + test("covers every priority exactly once", () => { + expect(Object.keys(PRIORITY_KEY).toSorted()).toEqual(PRIORITIES.toSorted() as string[]); + }); + + test("maps to the p0-p3 keys the stylesheet selects on", () => { + expect(Object.values(PRIORITY_KEY)).toEqual(["p0", "p1", "p2", "p3"]); + }); +}); + +describe("PRIORITY_ORDER", () => { + test("is most urgent first", () => { + expect(PRIORITY_ORDER).toEqual([...PRIORITIES]); + }); +}); diff --git a/webcomponents/src/shared/ticket-fields.ts b/webcomponents/src/shared/ticket-fields.ts new file mode 100644 index 00000000..5c90882e --- /dev/null +++ b/webcomponents/src/shared/ticket-fields.ts @@ -0,0 +1,27 @@ +/** + * Presentation for the two closed ticket fields. + * + * Both maps are keyed by a generated union, so a variant added in Rust is a + * TypeScript error here rather than a silent fallback on the board. + */ + +import type { TicketPriority } from "../generated/TicketPriority"; +import type { TicketStatus } from "../generated/TicketStatus"; + +export const STATUS_GLYPH: Record = { + queued: "•", + running: "▶", + awaiting: "⏸", + completed: "✓", +}; + +/** Feeds the `data-priority` hook the board stylesheet selects on. */ +export const PRIORITY_KEY: Record = { + "P0-critical": "p0", + "P1-high": "p1", + "P2-medium": "p2", + "P3-low": "p3", +}; + +/** Most urgent first, matching the Rust enum's declaration order. */ +export const PRIORITY_ORDER = Object.keys(PRIORITY_KEY) as TicketPriority[]; diff --git a/webcomponents/src/styles/semantic.css b/webcomponents/src/styles/semantic.css index 3982279a..90979b19 100644 --- a/webcomponents/src/styles/semantic.css +++ b/webcomponents/src/styles/semantic.css @@ -11,19 +11,19 @@ --action-text: var(--color-white); --danger: #a33a29; --danger-bg: #fbeae6; - --warning: #b8860b; + --warning: #7d5c07; --warning-bg: #f7f0d8; --success: #267066; --success-bg: #e4f0ec; --radius-sm: 3px; --radius: 4px; --radius-lg: 8px; - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, - sans-serif; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace; } -[data-theme='dark'] { +[data-theme="dark"] { --surface: #1a2426; --surface-alt: #141b1c; --border: #2c3a3c; diff --git a/webcomponents/src/views/DashboardView.module.css b/webcomponents/src/views/DashboardView.module.css index 8c665ce7..0d23c0c0 100644 --- a/webcomponents/src/views/DashboardView.module.css +++ b/webcomponents/src/views/DashboardView.module.css @@ -1,11 +1,61 @@ -.page { max-width: 1200px; } -.subBar { display: flex; gap: 1rem; align-items: center; margin-bottom: 1.5rem; } -.statusLink { margin-left: auto; font-size: 0.8rem; } -.statusLink a { color: var(--accent-text); } -.error { padding: 0.75rem 1rem; margin-bottom: 1rem; color: var(--danger); font-size: 0.875rem; background: var(--danger-bg); border-radius: var(--radius-lg); } -.statusBanner { padding: 0.25rem 0.75rem; color: var(--success); font-size: 0.8rem; background: var(--success-bg); border-radius: var(--radius-lg); } -.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } -.card { padding: 1rem; text-align: center; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); } -.cardValue { color: var(--accent-text); font-size: 2rem; font-weight: 700; } -.cardLabel { margin-top: 0.25rem; color: var(--text-muted); font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; } -.meta { margin-bottom: 1rem; color: var(--text-muted); font-size: 0.825rem; } +.page { + max-width: 1200px; +} +.subBar { + display: flex; + gap: 1rem; + align-items: center; + margin-bottom: 1.5rem; +} +.statusLink { + margin-left: auto; + font-size: 0.8rem; +} +.statusLink a { + color: var(--accent-text); +} +.error { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + color: var(--danger); + font-size: 0.875rem; + background: var(--danger-bg); + border-radius: var(--radius-lg); +} +.statusBanner { + padding: 0.25rem 0.75rem; + color: var(--success); + font-size: 0.8rem; + background: var(--success-bg); + border-radius: var(--radius-lg); +} +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} +.card { + padding: 1rem; + text-align: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.cardValue { + color: var(--accent-text); + font-size: 2rem; + font-weight: 700; +} +.cardLabel { + margin-top: 0.25rem; + color: var(--text-muted); + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.meta { + margin-bottom: 1rem; + color: var(--text-muted); + font-size: 0.825rem; +} diff --git a/webcomponents/src/views/IssueTypesView.module.css b/webcomponents/src/views/IssueTypesView.module.css index 00362bdf..07902128 100644 --- a/webcomponents/src/views/IssueTypesView.module.css +++ b/webcomponents/src/views/IssueTypesView.module.css @@ -1,30 +1,145 @@ -.page { max-width: 1100px; } -.split { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 1.5rem; } -.list { display: flex; flex-direction: column; gap: 0.25rem; } -.item { display: flex; gap: 0.75rem; align-items: center; padding: 0.6rem 0.75rem; color: var(--text); font: inherit; text-align: left; cursor: pointer; background: transparent; border: 1px solid transparent; border-radius: var(--radius-lg); transition: background 0.15s; } -.item:hover { background: var(--surface-alt); } -.selectedItem { background: var(--surface-alt); border-color: var(--color-salmon); } -.glyph { width: 2rem; flex-shrink: 0; font-size: 1.25rem; text-align: center; } -.itemName { display: block; font-size: 0.875rem; font-weight: 500; } -.itemMeta { display: block; color: var(--text-muted); font-size: 0.7rem; } -.detail { min-width: 0; padding: 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); } -.placeholder { color: var(--text-muted); font-size: 0.875rem; } -.detailTitle { display: flex; gap: 0.5rem; align-items: center; margin: 0 0 0.5rem; font-size: 1.25rem; font-weight: 600; } -.detailGlyph { font-size: 1.5rem; } -.detailDesc { margin: 0 0 1rem; color: var(--text-muted); font-size: 0.875rem; } -.kvGrid { display: grid; grid-template-columns: 100px 1fr; gap: 0.3rem 1rem; margin-bottom: 1.25rem; font-size: 0.825rem; } -.label { color: var(--text-muted); } -.steps { padding-top: 1rem; border-top: 1px solid var(--border); } -.stepsHead { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; } -.stepsTitle { margin: 0; font-size: 0.9rem; font-weight: 600; } -.toggle { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); } -.toggleBtn, .toggleActive { padding: 0.25rem 0.7rem; color: var(--text-muted); font: inherit; font-size: 0.75rem; cursor: pointer; background: transparent; border: 0; } -.toggleActive { color: var(--text); font-weight: 600; background: var(--surface-alt); } -.stepList { padding-left: 1.25rem; margin: 0; } -.step { margin-bottom: 0.4rem; font-size: 0.825rem; } -.stepName { font-weight: 500; } -.stepMeta { margin-left: 0.5rem; color: var(--text-muted); font-size: 0.75rem; } +.page { + max-width: 1100px; +} +.split { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 1.5rem; +} +.list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.item { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.6rem 0.75rem; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-lg); + transition: background 0.15s; +} +.item:hover { + background: var(--surface-alt); +} +.selectedItem { + background: var(--surface-alt); + border-color: var(--color-salmon); +} +.glyph { + width: 2rem; + flex-shrink: 0; + font-size: 1.25rem; + text-align: center; +} +.itemName { + display: block; + font-size: 0.875rem; + font-weight: 500; +} +.itemMeta { + display: block; + color: var(--text-muted); + font-size: 0.7rem; +} +.detail { + min-width: 0; + padding: 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.placeholder { + color: var(--text-muted); + font-size: 0.875rem; +} +.detailTitle { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0 0 0.5rem; + font-size: 1.25rem; + font-weight: 600; +} +.detailGlyph { + font-size: 1.5rem; +} +.detailDesc { + margin: 0 0 1rem; + color: var(--text-muted); + font-size: 0.875rem; +} +.kvGrid { + display: grid; + grid-template-columns: 100px 1fr; + gap: 0.3rem 1rem; + margin-bottom: 1.25rem; + font-size: 0.825rem; +} +.label { + color: var(--text-muted); +} +.steps { + padding-top: 1rem; + border-top: 1px solid var(--border); +} +.stepsHead { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} +.stepsTitle { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} +.toggle { + display: inline-flex; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} +.toggleBtn, +.toggleActive { + padding: 0.25rem 0.7rem; + color: var(--text-muted); + font: inherit; + font-size: 0.75rem; + cursor: pointer; + background: transparent; + border: 0; +} +.toggleActive { + color: var(--text); + font-weight: 600; + background: var(--surface-alt); +} +.stepList { + padding-left: 1.25rem; + margin: 0; +} +.step { + margin-bottom: 0.4rem; + font-size: 0.825rem; +} +.stepName { + font-weight: 500; +} +.stepMeta { + margin-left: 0.5rem; + color: var(--text-muted); + font-size: 0.75rem; +} @media (max-width: 900px) { - .split { grid-template-columns: 1fr; } + .split { + grid-template-columns: 1fr; + } } diff --git a/webcomponents/src/views/QueueView.module.css b/webcomponents/src/views/QueueView.module.css index 51040bb9..31a0bf2e 100644 --- a/webcomponents/src/views/QueueView.module.css +++ b/webcomponents/src/views/QueueView.module.css @@ -1,2 +1,54 @@ -.page { max-width: 1200px; } -.meta { margin-bottom: 1.25rem; color: var(--text-muted); font-size: 0.825rem; } +.page { + max-width: 1200px; +} + +.meta { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; + margin-bottom: 1.25rem; + color: var(--text-muted); + font-size: 0.825rem; +} + +.newTicketBtn { + padding: 0.35rem 0.7rem; + color: var(--action-text); + font: inherit; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + background: var(--action-bg); + border: 0; + border-radius: var(--radius); + transition: background 0.15s; +} + +.newTicketBtn:hover { + background: var(--action-bg-hover); +} + +.noMatches { + display: flex; + gap: 0.6rem; + align-items: center; + justify-content: center; + padding: 2.5rem 1rem; + color: var(--text-muted); + font-size: 0.875rem; + background: var(--surface-alt); + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +.linkBtn { + padding: 0; + color: var(--color-cornflower); + font: inherit; + font-size: 0.875rem; + text-decoration: underline; + cursor: pointer; + background: none; + border: 0; +} diff --git a/webcomponents/src/views/QueueView.tsx b/webcomponents/src/views/QueueView.tsx index 8ab8c3cc..162aa993 100644 --- a/webcomponents/src/views/QueueView.tsx +++ b/webcomponents/src/views/QueueView.tsx @@ -3,7 +3,14 @@ import type { KanbanBoardResponse } from "../generated/KanbanBoardResponse"; import type { KanbanTicketCard } from "../generated/KanbanTicketCard"; import { AsyncState, type AsyncValue } from "../components/AsyncState"; import { KanbanBoard } from "../components/KanbanBoard"; +import { KanbanFilterBar } from "../components/KanbanFilterBar"; import { PageHeader, type PageHeaderProps } from "../components/PageHeader"; +import { + facetsFromBoard, + filterBoard, + hasActiveFilters, + useKanbanFilters, +} from "../shared/kanban-filters"; import styles from "./QueueView.module.css"; export interface QueueViewProps { @@ -12,9 +19,18 @@ export interface QueueViewProps { error?: string | null; updatedLabel?: string; onOpenTicket?: (ticket: KanbanTicketCard) => void; + /** Opens the create form. Omitted on surfaces that cannot write. */ + onCreateTicket?: () => void; } -export function QueueView({ header, board, error, updatedLabel, onOpenTicket }: QueueViewProps) { +export function QueueView({ + header, + board, + error, + updatedLabel, + onOpenTicket, + onCreateTicket, +}: QueueViewProps) { const errorValue = useMemo | null>( () => (error ? { status: "error", message: error } : null), [error], @@ -25,14 +41,63 @@ export function QueueView({ header, board, error, updatedLabel, onOpenTicket }: {errorValue && {() => null}} {(data) => ( - <> -
    - {data.total_count} tickets{updatedLabel ? ` · updated ${updatedLabel}` : ""} -
    - - + )}
    ); } + +function FilterableBoard({ + board, + updatedLabel, + onOpenTicket, + onCreateTicket, +}: { + board: KanbanBoardResponse; + updatedLabel?: string; + onOpenTicket?: (ticket: KanbanTicketCard) => void; + onCreateTicket?: () => void; +}) { + const { state, setState, clear } = useKanbanFilters(); + // The board object is replaced on every poll, so both derivations rerun. + const facets = useMemo(() => facetsFromBoard(board), [board]); + const filtered = useMemo(() => filterBoard(board, state), [board, state]); + + const active = hasActiveFilters(state); + const countLabel = active + ? `${filtered.total_count} of ${board.total_count} tickets` + : `${board.total_count} tickets`; + + return ( + <> + +
    + + {countLabel} + {updatedLabel ? ` · updated ${updatedLabel}` : ""} + + {onCreateTicket && ( + + )} +
    + {active && filtered.total_count === 0 ? ( +
    + No tickets match these filters. + +
    + ) : ( + + )} + + ); +} diff --git a/webcomponents/stories/components/KanbanBoard.stories.tsx b/webcomponents/stories/components/KanbanBoard.stories.tsx index 44bcb350..480021ef 100644 --- a/webcomponents/stories/components/KanbanBoard.stories.tsx +++ b/webcomponents/stories/components/KanbanBoard.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { KanbanBoard } from "../../src/components/KanbanBoard"; -import { board, emptyBoard, unnamedStepTicket } from "../fixtures/operator"; +import { DEFAULT_FILTER_STATE, filterBoard } from "../../src/shared/kanban-filters"; +import { board, busyBoard, emptyBoard, unnamedStepTicket } from "../fixtures/operator"; const meta = { title: "Components/KanbanBoard", @@ -34,3 +35,10 @@ export const UnnamedStep: Story = { export const Narrow: Story = { globals: { viewport: { value: "narrow" } }, }; + +/** What the board renders once a filter narrows it: gamesvc tickets only. */ +export const Filtered: Story = { + args: { + board: filterBoard(busyBoard, { ...DEFAULT_FILTER_STATE, projects: ["gamesvc"] }), + }, +}; diff --git a/webcomponents/stories/components/KanbanFilterBar.stories.tsx b/webcomponents/stories/components/KanbanFilterBar.stories.tsx new file mode 100644 index 00000000..8cd78b1c --- /dev/null +++ b/webcomponents/stories/components/KanbanFilterBar.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { KanbanFilterBar } from "../../src/components/KanbanFilterBar"; +import { DEFAULT_FILTER_STATE, facetsFromBoard } from "../../src/shared/kanban-filters"; +import { busyBoard } from "../fixtures/operator"; + +const facets = facetsFromBoard(busyBoard); + +const meta = { + title: "Components/KanbanFilterBar", + component: KanbanFilterBar, + args: { + facets, + state: DEFAULT_FILTER_STATE, + onChange: () => undefined, + onClear: () => undefined, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Inert: Story = {}; + +/** The clear control only appears once the state differs from the default. */ +export const Active: Story = { + args: { + state: { ...DEFAULT_FILTER_STATE, projects: ["gamesvc"], priorities: ["P0-critical"] }, + }, +}; + +/** A facet with fewer than two options is not worth offering. */ +export const SingleProject: Story = { + args: { facets: { ...facets, projects: ["operator"] } }, +}; diff --git a/webcomponents/stories/components/PremiumPaywall.stories.tsx b/webcomponents/stories/components/PremiumPaywall.stories.tsx new file mode 100644 index 00000000..7eff5d05 --- /dev/null +++ b/webcomponents/stories/components/PremiumPaywall.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PremiumPaywall } from "../../src/components/PremiumPaywall"; + +const NOOP = () => undefined; + +const meta = { + title: "Components/PremiumPaywall", + component: PremiumPaywall, + args: { + feature: "Remote targets", + purchaseUrl: "https://operator.untra.io/premium", + onAddLicense: NOOP, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The full-section form, as the Remote Targets page renders it. */ +export const Section: Story = {}; + +/** Inline, as the onboarding execution-mode step renders it. */ +export const Inline: Story = { + args: { inline: true }, +}; + +/** A build with no purchase destination configured offers only "Add license". */ +export const WithoutPurchaseUrl: Story = { + args: { purchaseUrl: null }, +}; + +/** + * A non-https destination is not linked. The URL is a build-time input, so this + * is the state that must not render an anchor. + */ +export const RefusesInsecurePurchaseUrl: Story = { + args: { purchaseUrl: "http://operator.untra.io/premium" }, +}; + +/** Callers can replace the default explanation. */ +export const CustomDescription: Story = { + args: { + feature: "SSH hosts", + description: + "Running agents on another machine requires a Premium license for this configuration.", + }, +}; + +export const DarkTheme: Story = { globals: { theme: "dark" } }; + +export const Narrow: Story = { + globals: { viewport: { value: "narrow" } }, +}; diff --git a/webcomponents/stories/fixtures/operator.ts b/webcomponents/stories/fixtures/operator.ts index b848ca97..837f0eda 100644 --- a/webcomponents/stories/fixtures/operator.ts +++ b/webcomponents/stories/fixtures/operator.ts @@ -87,6 +87,39 @@ export const board: KanbanBoardResponse = { last_updated: "2026-09-14T16:30:00Z", }; +/** Enough cards across projects, types and priorities to exercise filtering. */ +export const busyBoard: KanbanBoardResponse = { + queue: [ + queuedTicket, + unnamedStepTicket, + { + ...queuedTicket, + id: "FEAT-1043", + summary: "Paginate the leaderboard endpoint", + project: "gamesvc", + priority: "P0-critical", + timestamp: "20260914-0935", + filename: "FEAT-1043.md", + }, + ], + running: [ + runningTicket, + { + ...runningTicket, + id: "FIX-319", + summary: "Retry webhook delivery on 5xx", + project: "platform", + priority: "P3-low", + timestamp: "20260914-0950", + filename: "FIX-319.md", + }, + ], + awaiting: [awaitingTicket], + done: [completedTicket], + total_count: 7, + last_updated: "2026-09-14T16:30:00Z", +}; + export const emptyBoard: KanbanBoardResponse = { queue: [], running: [], @@ -108,7 +141,7 @@ export const queueStatus: QueueStatusResponse = { in_progress: 1, awaiting: 1, completed: 1, - by_type: { inv: 1, fix: 1, feat: 1, spike: 1 }, + by_type: { INV: 1, FIX: 1, TASK: 0, FEAT: 1, SPIKE: 1 }, }; const createStep = ( diff --git a/webcomponents/tsconfig.json b/webcomponents/tsconfig.json index 713d7434..980e2886 100644 --- a/webcomponents/tsconfig.json +++ b/webcomponents/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx",