diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ba225bef..09864d7a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -43,7 +43,7 @@ jobs: components: rustfmt, clippy - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -117,7 +117,7 @@ jobs: components: llvm-tools-preview - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -160,7 +160,7 @@ jobs: done - name: Upload to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: codecov.json flags: rust @@ -179,7 +179,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y tmux - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -212,7 +212,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -258,7 +258,7 @@ jobs: targets: ${{ matrix.target }} - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -321,7 +321,7 @@ jobs: run: scripts/notarize.sh "${{ matrix.artifact_name }}" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} @@ -356,7 +356,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -410,7 +410,7 @@ jobs: fi - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} @@ -444,7 +444,7 @@ jobs: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts @@ -543,7 +543,7 @@ jobs: - uses: actions/checkout@v6 - name: Download linux binaries - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: operator-linux-* path: bins @@ -556,17 +556,17 @@ jobs: cp bins/operator-linux-arm64 ./operator-linux-arm64 chmod +x operator-linux-amd64 operator-linux-arm64 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push multi-arch image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 @@ -580,7 +580,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Trigger docs workflow - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | await github.rest.actions.createWorkflowDispatch({ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d69866b7..8019a072 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -38,7 +38,7 @@ jobs: # Cache cargo for faster builds - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index f5caf091..72a96a96 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -80,7 +80,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -93,7 +93,7 @@ jobs: run: cargo build --locked --release - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: target/release/operator${{ matrix.extension || '' }} @@ -125,7 +125,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -138,7 +138,7 @@ jobs: run: cargo build --locked --release - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: opr8r/target/release/opr8r${{ matrix.extension || '' }} @@ -181,7 +181,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -191,13 +191,13 @@ jobs: restore-keys: ${{ matrix.os }}-cargo-test- - name: Download operator binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.operator_artifact }} path: target/release - name: Download opr8r binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: target/release @@ -240,7 +240,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -250,13 +250,13 @@ jobs: restore-keys: ${{ matrix.os }}-cargo-test- - name: Download operator binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.operator_artifact }} path: target/release - name: Download opr8r binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: target/release @@ -309,7 +309,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -319,13 +319,13 @@ jobs: restore-keys: ${{ matrix.os }}-cargo-test- - name: Download operator binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.operator_artifact }} path: target/release - name: Download opr8r binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: target/release @@ -362,7 +362,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -372,7 +372,7 @@ jobs: restore-keys: ${{ matrix.os }}-cargo-test- - name: Download operator binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.artifact }} path: target/release @@ -414,7 +414,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -424,7 +424,7 @@ jobs: restore-keys: ${{ matrix.os }}-cargo-test- - name: Download operator binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.artifact }} path: target/release @@ -468,13 +468,13 @@ jobs: - uses: actions/checkout@v6 - name: Download operator binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.operator_artifact }} path: target/release - name: Download opr8r binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: opr8r-bin @@ -533,14 +533,14 @@ jobs: - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' cache: 'npm' cache-dependency-path: vscode-extension/package-lock.json - name: Download opr8r binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: vscode-extension/bin diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 34181863..1ac8b1a3 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -6,8 +6,10 @@ on: - main paths: - 'src/api/providers/kanban/**' + - 'src/api/providers/model_server/**' - 'src/git/**' - 'tests/kanban_integration.rs' + - 'tests/model_server_integration.rs' - 'tests/git_integration.rs' - '.github/workflows/integration-tests.yml' pull_request: @@ -15,8 +17,10 @@ on: - main paths: - 'src/api/providers/kanban/**' + - 'src/api/providers/model_server/**' - 'src/git/**' - 'tests/kanban_integration.rs' + - 'tests/model_server_integration.rs' - 'tests/git_integration.rs' workflow_dispatch: inputs: @@ -32,6 +36,10 @@ on: description: 'Run GitHub Projects integration tests' type: boolean default: true + run_model_servers: + description: 'Run model-server (provider model-listing) integration tests' + type: boolean + default: true run_git: description: 'Run Git integration tests' type: boolean @@ -64,7 +72,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -102,6 +110,53 @@ jobs: OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} run: cargo test --locked --test kanban_integration github_tests -- --nocapture --test-threads=1 + # Model-server (provider model-listing) integration tests. + # Listing models consumes no inference tokens. + - name: Run keyless OpenRouter model-list test + if: github.event_name != 'workflow_dispatch' || inputs.run_model_servers + run: cargo test --locked --test model_server_integration openrouter_keyless -- --nocapture --test-threads=1 + + - name: Run Anthropic model-list tests + if: >- + (github.event_name != 'workflow_dispatch' || inputs.run_model_servers) && + env.OPERATOR_ANTHROPIC_API_KEY != '' + env: + OPERATOR_ANTHROPIC_API_KEY: ${{ secrets.OPERATOR_ANTHROPIC_API_KEY }} + run: cargo test --locked --test model_server_integration anthropic -- --nocapture --test-threads=1 + + - name: Run OpenAI model-list tests + if: >- + (github.event_name != 'workflow_dispatch' || inputs.run_model_servers) && + env.OPERATOR_OPENAI_API_KEY != '' + env: + OPERATOR_OPENAI_API_KEY: ${{ secrets.OPERATOR_OPENAI_API_KEY }} + run: cargo test --locked --test model_server_integration openai -- --nocapture --test-threads=1 + + - name: Run Google model-list tests + if: >- + (github.event_name != 'workflow_dispatch' || inputs.run_model_servers) && + env.OPERATOR_GEMINI_API_KEY != '' + env: + OPERATOR_GEMINI_API_KEY: ${{ secrets.OPERATOR_GEMINI_API_KEY }} + run: cargo test --locked --test model_server_integration google -- --nocapture --test-threads=1 + + - name: Run keyed OpenRouter model-list tests + if: >- + (github.event_name != 'workflow_dispatch' || inputs.run_model_servers) && + env.OPERATOR_OPENROUTER_API_KEY != '' + env: + OPERATOR_OPENROUTER_API_KEY: ${{ secrets.OPERATOR_OPENROUTER_API_KEY }} + run: cargo test --locked --test model_server_integration openrouter_keyed -- --nocapture --test-threads=1 + + - name: Run model-server consistency test + if: github.event_name != 'workflow_dispatch' || inputs.run_model_servers + env: + OPERATOR_ANTHROPIC_API_KEY: ${{ secrets.OPERATOR_ANTHROPIC_API_KEY }} + OPERATOR_OPENAI_API_KEY: ${{ secrets.OPERATOR_OPENAI_API_KEY }} + OPERATOR_GEMINI_API_KEY: ${{ secrets.OPERATOR_GEMINI_API_KEY }} + OPERATOR_OPENROUTER_API_KEY: ${{ secrets.OPERATOR_OPENROUTER_API_KEY }} + run: cargo test --locked --test model_server_integration test_all_providers_return_same_model_shape -- --nocapture --test-threads=1 + - name: Run cross-provider tests env: OPERATOR_JIRA_DOMAIN: ${{ secrets.OPERATOR_JIRA_DOMAIN }} diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index 641b19f5..c40fb2ae 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -36,7 +36,7 @@ jobs: components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -79,7 +79,7 @@ jobs: components: llvm-tools-preview - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -96,7 +96,7 @@ jobs: run: cargo llvm-cov --locked --all-features --codecov --output-path codecov.json - name: Upload to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: opr8r/codecov.json flags: opr8r @@ -134,7 +134,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -170,7 +170,7 @@ jobs: run: scripts/notarize.sh opr8r/target/release/opr8r - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: opr8r/target/release/opr8r${{ matrix.extension || '' }} diff --git a/.github/workflows/sign-windows.yaml b/.github/workflows/sign-windows.yaml index ff148817..ccf52af5 100644 --- a/.github/workflows/sign-windows.yaml +++ b/.github/workflows/sign-windows.yaml @@ -30,7 +30,7 @@ jobs: SIGNED_BLOB_NAME: signed-${{ github.run_id }}-${{ inputs.artifact-name }} steps: - name: Download unsigned artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ inputs.artifact-name }} path: unsigned @@ -126,7 +126,7 @@ jobs: --file "signed/${{ steps.find-exe.outputs.exe_name }}" - name: Upload signed artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ inputs.artifact-name }} path: signed/${{ steps.find-exe.outputs.exe_name }} diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 87fb026c..56f7d65a 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' cache: 'npm' @@ -64,7 +64,7 @@ jobs: - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: vscode-extension/coverage/lcov.info flags: vscode-extension @@ -102,7 +102,7 @@ jobs: uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -138,7 +138,7 @@ jobs: run: scripts/notarize.sh opr8r/target/release/opr8r - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: opr8r/target/release/opr8r${{ matrix.extension || '' }} @@ -165,14 +165,14 @@ jobs: - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' cache: 'npm' cache-dependency-path: vscode-extension/package-lock.json - name: Download opr8r binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ${{ matrix.opr8r_artifact }} path: vscode-extension/bin @@ -195,7 +195,7 @@ jobs: run: npx vsce package --target ${{ matrix.vscode_target }} - name: Upload VSIX artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: vsix-${{ matrix.vscode_target }} path: vscode-extension/*.vsix @@ -212,7 +212,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' @@ -220,7 +220,7 @@ jobs: run: npm install -g @vscode/vsce - name: Download all VSIX artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: vsix-* path: vsix-packages diff --git a/.github/workflows/zed-extension.yaml b/.github/workflows/zed-extension.yaml index fbbb126e..1de6a222 100644 --- a/.github/workflows/zed-extension.yaml +++ b/.github/workflows/zed-extension.yaml @@ -54,7 +54,7 @@ jobs: command: check - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: operator-zed-extension path: | diff --git a/CLAUDE.md b/CLAUDE.md index c31122d3..9b59b8eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,9 @@ - **Config**: config crate (TOML) - **File Watching**: notify crate +## Code Style +Aim for functional software development with a focus on stateless, single responsibility focus. comments should be terse and used judiciously. + ## Development Standards ### Mandatory Before Committing @@ -208,6 +211,7 @@ Operator uses a schema-driven, code-derived documentation strategy to reduce mai | `src/main.rs` + `src/env_vars.rs` | `docs/cli/index.md` | CLI commands and env vars | | `src/config.rs` | `docs/configuration/index.md` | Config structure (via schemars) | | `src/rest/` | `docs/schemas/openapi.json` | REST API spec (via utoipa) | +| `src/docs_gen/llms.rs` + `docs/*/index.md` | `docs/llms.txt` | llms.txt site map for LLMs (no front matter; served verbatim) | ### Regenerating Documentation diff --git a/Cargo.lock b/Cargo.lock index 79273c71..03204654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agent-client-protocol" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" +checksum = "5efba6592048ef8a9ac97de8d79b2d9933d8ac4d94f7a2de102348fed0c61103" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -21,23 +21,20 @@ dependencies = [ "futures", "futures-concurrency", "jsonrpcmsg", - "rmcp", "rustc-hash", "schemars 1.2.1", "serde", "serde_json", "shell-words", - "tokio", - "tokio-util", "tracing", "uuid", ] [[package]] name = "agent-client-protocol-derive" -version = "0.11.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" +checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" dependencies = [ "quote", "syn", @@ -45,9 +42,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.13.2" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" +checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" dependencies = [ "anyhow", "derive_more", @@ -59,18 +56,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -311,14 +296,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", "axum-macros", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -331,8 +316,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -346,11 +330,31 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", "bytes", "futures-util", "http", @@ -359,7 +363,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "serde_core", "tower-layer", "tower-service", "tracing", @@ -367,9 +371,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -387,12 +391,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -407,9 +405,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -423,6 +421,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -505,9 +512,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -588,23 +595,30 @@ dependencies = [ [[package]] name = "config" -version = "0.14.1" +version = "0.15.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +checksum = "f316c6237b2d38be61949ecd15268a4c6ca32570079394a2444d9ce2c72a72d8" dependencies = [ "async-trait", "convert_case 0.6.0", "json5", - "nom", "pathdiff", "ron", "rust-ini", - "serde", + "serde-untagged", + "serde_core", "serde_json", "toml", + "winnow", "yaml-rust2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -678,6 +692,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -696,25 +719,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -727,7 +731,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crossterm_winapi", "mio", "parking_lot", @@ -762,6 +766,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.20.11" @@ -912,8 +925,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -922,7 +946,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -933,17 +966,29 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2", ] @@ -1015,18 +1060,23 @@ dependencies = [ "syn", ] -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1094,6 +1144,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1108,6 +1159,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1369,10 +1426,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -1382,7 +1435,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -1393,11 +1455,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.8.4" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.1", ] [[package]] @@ -1463,6 +1525,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1523,7 +1594,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -1724,6 +1795,17 @@ dependencies = [ "libc", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + [[package]] name = "inotify-sys" version = "0.1.5" @@ -1831,7 +1913,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -1912,14 +1994,16 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mac-notification-sys" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "04fd0110fd05744c3c904acf1a8ca624a721d75a35638f127cd5fb6b7ccfb1bf" dependencies = [ "cc", + "log", "objc2", "objc2-foundation", "time", + "uuid", ] [[package]] @@ -1933,9 +2017,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -1968,12 +2052,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2014,32 +2092,40 @@ dependencies = [ ] [[package]] -name = "nom" -version = "7.1.3" +name = "notify" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" dependencies = [ - "memchr", - "minimal-lexical", + "bitflags 2.13.0", + "filetime", + "fsevent-sys", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", ] [[package]] name = "notify" -version = "7.0.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", - "filetime", + "bitflags 2.13.0", "fsevent-sys", - "inotify", + "inotify 0.11.2", "kqueue", "libc", "log", "mio", - "notify-types", + "notify-types 2.1.0", "walkdir", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2065,6 +2151,15 @@ dependencies = [ "instant", ] +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -2128,7 +2223,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -2145,13 +2240,34 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2", "libc", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2170,7 +2286,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -2215,12 +2331,13 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-extra", "backon", "chrono", "clap", "config", "crossterm", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "glob", @@ -2229,7 +2346,7 @@ dependencies = [ "lazy_static", "mac-notification-sys", "mime_guess", - "notify", + "notify 8.2.0", "notify-rust", "once_cell", "operator-relay", @@ -2241,7 +2358,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", + "sha2 0.11.0", "sysinfo", "tempfile", "thiserror 2.0.18", @@ -2268,8 +2385,8 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "dirs", - "notify", + "dirs 5.0.1", + "notify 7.0.0", "serde", "serde_json", "tokio", @@ -2338,12 +2455,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "pathdiff" version = "0.2.3" @@ -2396,7 +2507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2496,7 +2607,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2628,7 +2739,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cassowary", "compact_str", "crossterm", @@ -2643,33 +2754,13 @@ dependencies = [ "unicode-width 0.2.0", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -2683,6 +2774,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2738,7 +2840,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-channel", @@ -2792,51 +2894,18 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmcp" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" -dependencies = [ - "async-trait", - "base64 0.22.1", - "chrono", - "futures", - "pastey", - "pin-project-lite", - "rmcp-macros", - "schemars 1.2.1", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "rmcp-macros" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "serde_json", - "syn", -] - [[package]] name = "ron" -version = "0.8.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "base64 0.21.7", - "bitflags 2.11.1", + "bitflags 2.13.0", + "once_cell", "serde", "serde_derive", + "typeid", + "unicode-ident", ] [[package]] @@ -2869,15 +2938,15 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] [[package]] name = "rust-ini" -version = "0.20.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ "cfg-if", "ordered-multimap", @@ -2904,7 +2973,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2917,7 +2986,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3040,7 +3109,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3073,6 +3142,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3142,11 +3223,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3167,7 +3248,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64 0.22.1", + "base64", "bs58", "chrono", "hex", @@ -3213,8 +3294,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3403,16 +3495,17 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.32.1" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" dependencies = [ - "core-foundation-sys", "libc", "memchr", "ntapi", - "rayon", - "windows 0.57.0", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows 0.62.2", ] [[package]] @@ -3421,7 +3514,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3651,7 +3744,6 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3659,23 +3751,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", + "indexmap 2.14.0", + "serde_core", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -3687,20 +3773,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -3708,9 +3780,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.3", + "winnow", ] [[package]] @@ -3719,14 +3791,14 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -3750,7 +3822,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -3858,12 +3930,11 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" -version = "10.1.0" +version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ "chrono", - "lazy_static", "serde_json", "thiserror 2.0.18", "ts-rs-macros", @@ -3872,9 +3943,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" -version = "10.1.0" +version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" dependencies = [ "proc-macro2", "quote", @@ -3893,6 +3964,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.0" @@ -4013,9 +4090,9 @@ dependencies = [ [[package]] name = "utoipa-axum" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839e89ad0db7f9e8737dace8ff43c1ce0711d5e0d08cc1c9d31cc8454d4643ee" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" dependencies = [ "axum", "paste", @@ -4039,12 +4116,12 @@ dependencies = [ [[package]] name = "utoipa-swagger-ui" -version = "8.1.0" +version = "9.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4b5ac679cc6dfc5ea3f2823b0291c777750ffd5e13b21137e0f7ac0e8f9617" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64 0.22.1", + "base64", "mime_guess", "regex", "rust-embed", @@ -4057,9 +4134,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4211,7 +4288,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -4248,14 +4325,11 @@ dependencies = [ [[package]] name = "which" -version = "7.0.3" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ - "either", - "env_home", - "rustix 1.1.4", - "winsafe", + "libc", ] [[package]] @@ -4291,25 +4365,27 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.57.0" +version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", ] [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -4322,15 +4398,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.57.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core 0.62.2", ] [[package]] @@ -4339,8 +4412,8 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -4352,8 +4425,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -4367,18 +4440,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", ] [[package]] -name = "windows-implement" -version = "0.57.0" +name = "windows-future" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -4392,17 +4465,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -4437,23 +4499,24 @@ dependencies = [ ] [[package]] -name = "windows-registry" -version = "0.6.1" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ + "windows-core 0.62.2", "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", ] [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-targets 0.52.6", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4594,6 +4657,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -4741,15 +4813,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.3" @@ -4759,12 +4822,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4829,7 +4886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -4867,9 +4924,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yaml-rust2" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", @@ -4928,7 +4985,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow", "zbus_macros", "zbus_names", "zvariant", @@ -4956,7 +5013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 1.0.3", + "winnow", "zvariant", ] @@ -5042,21 +5099,24 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" dependencies = [ "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap 2.14.0", "memchr", - "thiserror 2.0.18", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" @@ -5084,7 +5144,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow", "zvariant_derive", "zvariant_utils", ] @@ -5112,5 +5172,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow 1.0.3", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index f2bb44df..97db7a56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,8 @@ name = "operator" version = "0.2.1" edition = "2021" rust-version = "1.95" -description = "Multi-agent orchestration dashboard for gbqr.us" -authors = ["gbqr.us"] +description = "Multi-agent orchestration dashboard for kanban shaped software development" +authors = ["untra"] license = "MIT" default-run = "operator" @@ -25,6 +25,11 @@ path = "src/bin/generate_types.rs" operator-relay = { path = "crates/relay" } # TUI +# NOTE: ratatui is held at 0.29 and crossterm at 0.28 because the official +# `tui-textarea` 0.7 depends on ratatui 0.29 / crossterm 0.28. +# Bumping either here pulls a second ratatui/crossterm into the tree and the +# `Style`/`KeyEvent` types stop unifying in `form_field.rs`. Revisit when +# upstream `tui-textarea` supports ratatui 0.30. ratatui = "0.29" crossterm = "0.28" tui-textarea = "0.7" @@ -36,18 +41,18 @@ handlebars = "6" tokio = { version = "1", features = ["full"] } # File watching -notify = "7" +notify = "8" # Configuration -config = "0.14" +config = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" -toml = "0.8" +toml = "1" # Schema and TypeScript generation schemars = { version = "1.0", features = ["chrono04", "uuid1", "preserve_order"] } -ts-rs = { version = "10", features = ["uuid-impl", "chrono-impl", "no-serde-warnings", "serde-json-impl"] } +ts-rs = { version = "12", features = ["uuid-impl", "chrono-impl", "no-serde-warnings", "serde-json-impl"] } # CLI clap = { version = "4", features = ["derive"] } @@ -60,19 +65,19 @@ thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" -dirs = "5" +dirs = "6" glob = "0.3" regex = "1" once_cell = "1" lazy_static = "1" backon = "1" -which = "7" +which = "8" # System info (for core count) -sysinfo = "0.32" +sysinfo = "0.39" # Cryptography (for content hashing) -sha2 = "0.10" +sha2 = "0.11" # HTTP client for API calls reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } @@ -81,7 +86,9 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } async-trait = "0.1" # REST API -axum = { version = "0.7", features = ["macros", "tokio", "tracing"] } +axum = { version = "0.8", features = ["macros", "tokio", "tracing"] } +# `Host` extractor moved out of axum core in 0.8; it now lives in axum-extra. +axum-extra = "0.10" tokio-stream = "0.1" futures-util = "0.3" tower = "0.5" @@ -90,15 +97,15 @@ http-body-util = "0.1" # OpenAPI documentation utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } -utoipa-swagger-ui = { version = "8", features = ["axum"] } +utoipa-swagger-ui = { version = "9", features = ["axum"] } # Agent Client Protocol (ACP) — JSON-RPC 2.0 over stdio for editor integration -agent-client-protocol = "0.12" +agent-client-protocol = "0.14" # Embedded web UI (behind embed-ui feature flag) rust-embed = { version = "8", optional = true } mime_guess = { version = "2", optional = true } -utoipa-axum = "0.1" +utoipa-axum = "0.2" [features] default = ["embed-ui"] diff --git a/README.md b/README.md index 0b8edc7f..9d7c92b8 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ * **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/) +* **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/) + * **LLM Tool** [![Claude](https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=white)](https://operator.untra.io/getting-started/agents/claude/) [![Codex](https://img.shields.io/badge/Codex-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/agents/codex/) [![Gemini CLI](https://img.shields.io/badge/Gemini_CLI-8E75B2?logo=googlegemini&logoColor=white)](https://operator.untra.io/getting-started/agents/gemini-cli/) -* **Kanban Provider** [![Jira](https://img.shields.io/badge/Jira-0052CC?logo=jira&logoColor=white)](https://operator.untra.io/getting-started/kanban/jira/) [![Linear](https://img.shields.io/badge/Linear-5E6AD2?logo=linear&logoColor=white)](https://operator.untra.io/getting-started/kanban/linear/) [![GitHub Projects](https://img.shields.io/badge/GitHub_Projects-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/kanban/github/) +* **Model Provider** [![Anthropic](https://img.shields.io/badge/Anthropic-D97757?logo=anthropic&logoColor=white)](https://operator.untra.io/getting-started/model-servers/anthropic/) [![OpenAI](https://img.shields.io/badge/OpenAI-000000?logo=openai&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openai/) [![Google](https://img.shields.io/badge/Google-4285F4?logo=google&logoColor=white)](https://operator.untra.io/getting-started/model-servers/google/) [![OpenRouter](https://img.shields.io/badge/OpenRouter-6566F1?logo=openrouter&logoColor=white)](https://operator.untra.io/getting-started/model-servers/openrouter/) [![Ollama](https://img.shields.io/badge/Ollama-000000?logo=ollama&logoColor=white)](https://operator.untra.io/getting-started/model-servers/ollama/) + * **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/) @@ -25,6 +28,7 @@ An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-st - you do work assigned from tickets on a kanban board, such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/), [_Linear_](https://operator.untra.io/getting-started/kanban/linear/), or [_GitHub Projects_](https://operator.untra.io/getting-started/kanban/github/) - you use LLM assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/), or [_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/) - you are drowning in the AI software development soup. diff --git a/agnt-plugin/README.md b/agnt-plugin/README.md new file mode 100644 index 00000000..b5a8a04d --- /dev/null +++ b/agnt-plugin/README.md @@ -0,0 +1,91 @@ +# operator-plugin (AGNT.gg) + +An [AGNT.gg](https://agnt.gg) plugin that exposes **Operator!**'s ticket +orchestration as workflow nodes. Drop these nodes into an AGNT workflow to +create tickets, launch coding agents, poll the queue, export workflows, and +raise investigations — all driven by Operator's local REST API. + +This is the **AGNT → Operator** direction. The companion direction (Operator → +AGNT) is the `operator workflow export --format agnt` emitter built into +Operator, which emits graphs composed of the `operator-launch-agent` nodes this +plugin defines. + +## Nodes + +| Node | Operator REST endpoint | +|------|------------------------| +| `operator-create-ticket` | `POST /api/v1/tickets` | +| `operator-launch-agent` | `POST /api/v1/tickets/{id}/launch` (whole ticket, by `id`) | +| `operator-run-step` | `POST /api/v1/tickets/{ticket}/launch` (the node type `--format agnt` emits) | +| `operator-queue-status` | `GET /api/v1/queue/status` | +| `operator-export-workflow` | `POST /api/v1/tickets/{id}/workflow-export?format=agnt` | +| `operator-alert` | `POST /api/v1/alerts` | + +Each node is a thin `fetch()` wrapper around an endpoint Operator already +serves (see `lib/operator-client.js`). The plugin is **zero-dependency** (Node +18+ global `fetch`). + +## Configuring the Operator base URL + +Every node accepts an `operatorBaseUrl` parameter. If omitted, the plugin uses +the `OPERATOR_BASE_URL` environment variable, then falls back to +`http://localhost:7008` (Operator's default `[rest_api].port`). + +Start Operator's REST API with: + +```bash +operator api +``` + +## Example workflow + +``` +webhook-trigger + → operator-create-ticket { template: "fix", project: "gamesvc", summary: "{{payload.title}}" } + → operator-launch-agent { id: "{{prev.result.id}}" } + → operator-queue-status + → slack-send +``` + +`operator-create-ticket` returns `{ id, filename, path }`, so the next node can +launch the freshly created ticket by id. + +## Trust & permissions + +These nodes drive a real Operator instance: they create tickets and launch +agents against your repositories. Only point `operatorBaseUrl` at an Operator +API you control and trust. + +## Building the `.agnt` package + +Packaging uses AGNT's bundled builder (it gzips the manifest + JS + any +`node_modules`). From a checkout of the AGNT repo: + +```bash +# copy or symlink this directory into AGNT's plugin dev tree +cp -r agnt-plugin /path/to/agnt/backend/plugins/dev/operator-plugin +cd /path/to/agnt/backend/plugins +node build-plugin.js operator-plugin +# → plugin-builds/operator-plugin.agnt +``` + +Install the resulting `.agnt` via AGNT's Marketplace UI, or drop it into +`~/Library/Application Support/AGNT/plugins/installed/` and reload: + +```bash +curl -X POST http://localhost:3333/api/plugins/reload +``` + +## Alternative: the MCP bridge (no plugin) + +Operator also ships a stdio MCP server exposing ~18 orchestration tools. AGNT +consumes stdio MCP servers natively — register Operator without this plugin via +AGNT's MCP settings: + +```json +{ "name": "operator", "command": "operator", "args": ["mcp"] } +``` + +The plugin's value over the raw MCP bridge is first-class canvas nodes with +typed parameters and marketplace discoverability. See +`docs/getting-started/integrations/agnt/` for the full comparison. diff --git a/agnt-plugin/alert.js b/agnt-plugin/alert.js new file mode 100644 index 00000000..9100df0a --- /dev/null +++ b/agnt-plugin/alert.js @@ -0,0 +1,23 @@ +// operator-alert — POST /api/v1/alerts +import { callOperator } from "./lib/operator-client.js"; + +class AlertTool { + async execute(params, _inputData, _workflowEngine) { + if (!params || !params.message) { + return { success: false, result: null, error: "missing required param: message" }; + } + return callOperator({ + params, + path: "/api/v1/alerts", + method: "POST", + body: { + source: params.source || "agnt", + message: params.message, + severity: params.severity || "S2", + project: params.project, + }, + }); + } +} + +export default new AlertTool(); diff --git a/agnt-plugin/create-ticket.js b/agnt-plugin/create-ticket.js new file mode 100644 index 00000000..85aa26ca --- /dev/null +++ b/agnt-plugin/create-ticket.js @@ -0,0 +1,22 @@ +// operator-create-ticket — POST /api/v1/tickets +import { callOperator } from "./lib/operator-client.js"; + +class CreateTicketTool { + async execute(params, _inputData, _workflowEngine) { + if (!params || !params.template) { + return { success: false, result: null, error: "missing required param: template" }; + } + return callOperator({ + params, + path: "/api/v1/tickets", + method: "POST", + body: { + template: params.template, + project: params.project, + summary: params.summary, + }, + }); + } +} + +export default new CreateTicketTool(); diff --git a/agnt-plugin/export.js b/agnt-plugin/export.js new file mode 100644 index 00000000..7425a2a3 --- /dev/null +++ b/agnt-plugin/export.js @@ -0,0 +1,18 @@ +// operator-export-workflow — POST /api/v1/tickets/{id}/workflow-export?format=... +import { callOperator } from "./lib/operator-client.js"; + +class ExportWorkflowTool { + async execute(params, _inputData, _workflowEngine) { + if (!params || !params.id) { + return { success: false, result: null, error: "missing required param: id" }; + } + const format = params.format || "agnt"; + return callOperator({ + params, + path: `/api/v1/tickets/${encodeURIComponent(params.id)}/workflow-export?format=${encodeURIComponent(format)}`, + method: "POST", + }); + } +} + +export default new ExportWorkflowTool(); diff --git a/agnt-plugin/launch.js b/agnt-plugin/launch.js new file mode 100644 index 00000000..fc8ee406 --- /dev/null +++ b/agnt-plugin/launch.js @@ -0,0 +1,22 @@ +// operator-launch-agent — POST /api/v1/tickets/{id}/launch +import { callOperator } from "./lib/operator-client.js"; + +class LaunchAgentTool { + async execute(params, _inputData, _workflowEngine) { + if (!params || !params.id) { + return { success: false, result: null, error: "missing required param: id" }; + } + return callOperator({ + params, + path: `/api/v1/tickets/${encodeURIComponent(params.id)}/launch`, + method: "POST", + body: { + delegator: params.delegator, + model: params.model, + wrapper: params.wrapper, + }, + }); + } +} + +export default new LaunchAgentTool(); diff --git a/agnt-plugin/lib/operator-client.js b/agnt-plugin/lib/operator-client.js new file mode 100644 index 00000000..60d4509e --- /dev/null +++ b/agnt-plugin/lib/operator-client.js @@ -0,0 +1,56 @@ +// Shared HTTP helper for the operator-plugin tools. +// +// Zero-dependency: uses the global `fetch` (Node >= 18). Every tool returns the +// AGNT contract `{ success, result, error }`. + +const DEFAULT_BASE_URL = "http://localhost:7008"; + +/** + * Resolve the Operator REST base URL from params, env, or the default. + */ +export function resolveBaseUrl(params) { + const fromParam = params && params.operatorBaseUrl; + const fromEnv = + typeof process !== "undefined" && process.env + ? process.env.OPERATOR_BASE_URL + : undefined; + return (fromParam || fromEnv || DEFAULT_BASE_URL).replace(/\/+$/, ""); +} + +/** + * Call the Operator REST API and normalize the response into the AGNT + * `{ success, result, error }` contract. + * + * @param {object} opts + * @param {object} opts.params tool params (used to resolve the base URL) + * @param {string} opts.path request path, e.g. "/api/v1/queue/status" + * @param {string} [opts.method=GET] + * @param {object} [opts.body] JSON body for POST/PUT + */ +export async function callOperator({ params, path, method = "GET", body }) { + const baseUrl = resolveBaseUrl(params); + const url = `${baseUrl}${path}`; + try { + const init = { method, headers: { Accept: "application/json" } }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + const text = await res.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = text; + } + if (!res.ok) { + const detail = + parsed && parsed.error ? parsed.error : `HTTP ${res.status}`; + return { success: false, result: parsed, error: `${method} ${url} failed: ${detail}` }; + } + return { success: true, result: parsed, error: null }; + } catch (e) { + return { success: false, result: null, error: `${method} ${url} failed: ${e.message}` }; + } +} diff --git a/agnt-plugin/manifest.json b/agnt-plugin/manifest.json new file mode 100644 index 00000000..e9160815 --- /dev/null +++ b/agnt-plugin/manifest.json @@ -0,0 +1,266 @@ +{ + "name": "operator-plugin", + "version": "0.2.1", + "description": "Orchestrate Operator! ticket-driven coding agents from AGNT workflows", + "author": "untra", + "displayName": "Operator!", + "homepage": "https://github.com/untra/operator", + "icon": "table", + "tools": [ + { + "type": "operator-create-ticket", + "entryPoint": "./create-ticket.js", + "schema": { + "title": "Operator: Create Ticket", + "category": "action", + "type": "operator-create-ticket", + "icon": "file", + "description": "Create a new Operator ticket from a template and write it to the queue.", + "documentation": "https://operator.untra.io/getting-started/integrations/agnt/", + "parameters": { + "operatorBaseUrl": { + "type": "string", + "inputType": "text", + "description": "Base URL of the Operator REST API", + "placeholder": "http://localhost:7008", + "default": "http://localhost:7008" + }, + "template": { + "type": "string", + "inputType": "select", + "description": "Template key for the ticket type", + "options": ["feat", "fix", "task", "spike", "inv", "assess", "sync", "init"], + "required": true + }, + "project": { + "type": "string", + "inputType": "text", + "description": "Project the ticket targets", + "placeholder": "gamesvc" + }, + "summary": { + "type": "string", + "inputType": "textarea", + "description": "One-line summary of the work" + } + }, + "outputs": { + "success": { "type": "boolean", "description": "Whether the ticket was created" }, + "result": { "type": "object", "description": "Created ticket: { id, filename, path }" }, + "error": { "type": "string", "description": "Error message if the call failed" } + } + } + }, + { + "type": "operator-launch-agent", + "entryPoint": "./launch.js", + "schema": { + "title": "Operator: Launch Agent", + "category": "action", + "type": "operator-launch-agent", + "icon": "bolt", + "description": "Launch/start a coding agent for an existing Operator ticket.", + "documentation": "https://operator.untra.io/getting-started/integrations/agnt/", + "parameters": { + "operatorBaseUrl": { + "type": "string", + "inputType": "text", + "description": "Base URL of the Operator REST API", + "placeholder": "http://localhost:7008", + "default": "http://localhost:7008" + }, + "id": { + "type": "string", + "inputType": "text", + "description": "Ticket id to launch", + "placeholder": "FEAT-1234", + "required": true + }, + "delegator": { + "type": "string", + "inputType": "text", + "description": "Named delegator to use (optional)" + }, + "model": { + "type": "string", + "inputType": "text", + "description": "Model to use (optional)", + "placeholder": "sonnet / opus" + }, + "wrapper": { + "type": "string", + "inputType": "select", + "description": "Session wrapper (optional)", + "options": ["vscode", "tmux", "cmux", "terminal"] + } + }, + "outputs": { + "success": { "type": "boolean", "description": "Whether the agent launched" }, + "result": { "type": "object", "description": "Launch details: { agent_id, ticket_id, session_id, command, terminal_name, working_directory, branch }" }, + "error": { "type": "string", "description": "Error message if the call failed" } + } + } + }, + { + "type": "operator-run-step", + "entryPoint": "./run-step.js", + "schema": { + "title": "Operator: Run Step", + "category": "action", + "type": "operator-run-step", + "icon": "terminal", + "description": "Run an Operator ticket for one issuetype step. This is the node type emitted by `operator workflow export --format agnt`.", + "documentation": "https://operator.untra.io/getting-started/integrations/agnt/", + "parameters": { + "operatorBaseUrl": { + "type": "string", + "inputType": "text", + "description": "Base URL of the Operator REST API", + "placeholder": "http://localhost:7008", + "default": "http://localhost:7008" + }, + "ticket": { + "type": "string", + "inputType": "text", + "description": "Ticket id to run", + "placeholder": "FEAT-1234", + "required": true + }, + "step": { + "type": "string", + "inputType": "text", + "description": "Step name this node represents (informational)" + }, + "model": { + "type": "string", + "inputType": "text", + "description": "Model to use (optional)", + "placeholder": "sonnet / opus" + } + }, + "outputs": { + "success": { "type": "boolean", "description": "Whether the ticket run launched" }, + "result": { "type": "object", "description": "Launch details: { agent_id, ticket_id, session_id, command, terminal_name, working_directory, branch }" }, + "error": { "type": "string", "description": "Error message if the call failed" } + } + } + }, + { + "type": "operator-queue-status", + "entryPoint": "./queue.js", + "schema": { + "title": "Operator: Queue Status", + "category": "action", + "type": "operator-queue-status", + "icon": "chart", + "description": "Read the Operator queue status (counts by status, active agents).", + "documentation": "https://operator.untra.io/getting-started/integrations/agnt/", + "parameters": { + "operatorBaseUrl": { + "type": "string", + "inputType": "text", + "description": "Base URL of the Operator REST API", + "placeholder": "http://localhost:7008", + "default": "http://localhost:7008" + } + }, + "outputs": { + "success": { "type": "boolean", "description": "Whether the status was read" }, + "result": { "type": "object", "description": "Queue counts: { queued, in_progress, awaiting, completed, by_type }" }, + "error": { "type": "string", "description": "Error message if the call failed" } + } + } + }, + { + "type": "operator-export-workflow", + "entryPoint": "./export.js", + "schema": { + "title": "Operator: Export Workflow", + "category": "action", + "type": "operator-export-workflow", + "icon": "code", + "description": "Export a ticket (rendered against its issuetype) to a Claude or AGNT workflow.", + "documentation": "https://operator.untra.io/getting-started/integrations/agnt/", + "parameters": { + "operatorBaseUrl": { + "type": "string", + "inputType": "text", + "description": "Base URL of the Operator REST API", + "placeholder": "http://localhost:7008", + "default": "http://localhost:7008" + }, + "id": { + "type": "string", + "inputType": "text", + "description": "Ticket id to export", + "placeholder": "FEAT-1234", + "required": true + }, + "format": { + "type": "string", + "inputType": "select", + "description": "Output format", + "options": ["agnt", "claude"], + "default": "agnt" + } + }, + "outputs": { + "success": { "type": "boolean", "description": "Whether the export succeeded" }, + "result": { "type": "object", "description": "Exported workflow: { ticket_id, issuetype_key, suggested_filename, contents }" }, + "error": { "type": "string", "description": "Error message if the call failed" } + } + } + }, + { + "type": "operator-alert", + "entryPoint": "./alert.js", + "schema": { + "title": "Operator: Raise Alert", + "category": "action", + "type": "operator-alert", + "icon": "webhook", + "description": "Raise an external alert as an Operator investigation (INV) ticket.", + "documentation": "https://operator.untra.io/getting-started/integrations/agnt/", + "parameters": { + "operatorBaseUrl": { + "type": "string", + "inputType": "text", + "description": "Base URL of the Operator REST API", + "placeholder": "http://localhost:7008", + "default": "http://localhost:7008" + }, + "source": { + "type": "string", + "inputType": "text", + "description": "Where the alert came from", + "placeholder": "pagerduty", + "default": "agnt" + }, + "message": { + "type": "string", + "inputType": "textarea", + "description": "The alert message / summary", + "required": true + }, + "severity": { + "type": "string", + "inputType": "select", + "description": "Severity label", + "options": ["S1", "S2", "S3"], + "default": "S2" + }, + "project": { + "type": "string", + "inputType": "text", + "description": "Project the investigation targets (optional)" + } + }, + "outputs": { + "success": { "type": "boolean", "description": "Whether the investigation was created" }, + "result": { "type": "object", "description": "Created investigation: { id, filename }" }, + "error": { "type": "string", "description": "Error message if the call failed" } + } + } + } + ] +} diff --git a/agnt-plugin/package.json b/agnt-plugin/package.json new file mode 100644 index 00000000..a3c5822e --- /dev/null +++ b/agnt-plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "operator-plugin", + "version": "0.2.1", + "description": "Orchestrate Operator! ticket-driven coding agents from AGNT workflows", + "author": "untra", + "license": "MIT", + "type": "module", + "private": true, + "engines": { + "node": ">=18" + } +} diff --git a/agnt-plugin/queue.js b/agnt-plugin/queue.js new file mode 100644 index 00000000..38c37047 --- /dev/null +++ b/agnt-plugin/queue.js @@ -0,0 +1,10 @@ +// operator-queue-status — GET /api/v1/queue/status +import { callOperator } from "./lib/operator-client.js"; + +class QueueStatusTool { + async execute(params, _inputData, _workflowEngine) { + return callOperator({ params, path: "/api/v1/queue/status" }); + } +} + +export default new QueueStatusTool(); diff --git a/agnt-plugin/run-step.js b/agnt-plugin/run-step.js new file mode 100644 index 00000000..885cad6e --- /dev/null +++ b/agnt-plugin/run-step.js @@ -0,0 +1,26 @@ +// operator-run-step — the node type emitted by `operator workflow export --format agnt`. +// +// Each exported node represents one issuetype step and carries +// { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks +// Operator to run it via the launch endpoint. Operator sequences its own steps +// internally, so the per-step nodes are a faithful visualization of the ticket's +// shape; executing them drives the one underlying Operator ticket (the launch +// endpoint's relaunch path tolerates a ticket that is already in progress). +import { callOperator } from "./lib/operator-client.js"; + +class RunStepTool { + async execute(params, _inputData, _workflowEngine) { + const ticket = params && (params.ticket || params.id); + if (!ticket) { + return { success: false, result: null, error: "missing required param: ticket" }; + } + return callOperator({ + params, + path: `/api/v1/tickets/${encodeURIComponent(ticket)}/launch`, + method: "POST", + body: { model: params.model }, + }); + } +} + +export default new RunStepTool(); diff --git a/bindings/AgentProfile.ts b/bindings/AgentProfile.ts new file mode 100644 index 00000000..4e3a8b10 --- /dev/null +++ b/bindings/AgentProfile.ts @@ -0,0 +1,64 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RemoteAgentRef } from "./RemoteAgentRef"; +import type { XOperator } from "./XOperator"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A canonical cross-tool agent definition (serialized as `agent-profile.json`). + * + * Shared core (`name`/`provider`/`model`/`system_prompt`/`skills`/`mcp_servers`/ + * `tools`) plus namespaced extension bags. `x_operator` is typed (Operator owns + * those fields); `x_agnt` and `x_openai` are opaque per-platform pass-throughs. + */ +export type AgentProfile = { +/** + * Unique agent name (maps to [`Delegator::name`]). + */ +name: string, +/** + * Inference provider / CLI tool (maps to [`Delegator::llm_tool`]). + */ +provider: string, +/** + * Model alias or id (maps to [`Delegator::model`]). + */ +model: string, +/** + * System prompt. Operator has no first-class system prompt, so this is + * preserved opaquely across import (see [`Delegator::unmapped_core`]). + */ +system_prompt?: string | null, +/** + * Named skills. Preserved opaquely across import. + */ +skills: Array, +/** + * MCP server names. Preserved opaquely across import. + */ +mcp_servers: Array, +/** + * Tool names. Preserved opaquely across import. + */ +tools: Array, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). + * `None` = a locally launchable agent, not bound to a remote platform. + */ +remote_agent?: RemoteAgentRef | null, +/** + * Operator-owned extension fields (typed). `None` when the agent carries no + * Operator-specific configuration. + */ +x_operator?: XOperator | null, +/** + * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, + * `creditLimit`, ...). Operator never interprets this — pure pass-through. + */ +x_agnt?: JsonValue | null, +/** + * OpenAI-owned extension fields, opaque (`instructions`, `tools`, + * `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a + * second platform — never interpreted. This field is the whole per-tool cost + * of adding `OpenAI`: a passthrough bag, no mapping logic. + */ +x_openai?: JsonValue | null, }; diff --git a/bindings/CreateAlertRequest.ts b/bindings/CreateAlertRequest.ts new file mode 100644 index 00000000..59d35690 --- /dev/null +++ b/bindings/CreateAlertRequest.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to raise an external alert as an investigation ticket. + */ +export type CreateAlertRequest = { +/** + * Where the alert came from (e.g. `pagerduty`, `sentry`). + */ +source: string, +/** + * The alert message / summary. + */ +message: string, +/** + * Severity label (e.g. `S1`, `S2`). + */ +severity: string, +/** + * Optional project the investigation targets. + */ +project: string | null, }; diff --git a/bindings/CreateAlertResponse.ts b/bindings/CreateAlertResponse.ts new file mode 100644 index 00000000..6de3518d --- /dev/null +++ b/bindings/CreateAlertResponse.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response after raising an alert. + */ +export type CreateAlertResponse = { +/** + * The created investigation ticket's id. + */ +id: string, +/** + * The investigation ticket filename written to the queue. + */ +filename: string, }; diff --git a/bindings/CreateDelegatorRequest.ts b/bindings/CreateDelegatorRequest.ts index 40743b64..02a32eff 100644 --- a/bindings/CreateDelegatorRequest.ts +++ b/bindings/CreateDelegatorRequest.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; +import type { RemoteAgentRef } from "./RemoteAgentRef"; /** * Request to create a new delegator @@ -24,7 +25,7 @@ display_name: string | null, /** * Arbitrary model properties */ -model_properties: { [key in string]?: string }, +model_properties: { [key in string]: string }, /** * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ @@ -32,4 +33,9 @@ model_server: string | null, /** * Optional launch configuration */ -launch_config: DelegatorLaunchConfigDto | null, }; +launch_config: DelegatorLaunchConfigDto | null, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + * set, the delegator is export-only and cannot be launched locally. + */ +remote_agent: RemoteAgentRef | null, }; diff --git a/bindings/CreateModelServerRequest.ts b/bindings/CreateModelServerRequest.ts index baeed11c..36960e9d 100644 --- a/bindings/CreateModelServerRequest.ts +++ b/bindings/CreateModelServerRequest.ts @@ -23,7 +23,7 @@ api_key_env: string | null, /** * Additional environment variables */ -extra_env: { [key in string]?: string }, +extra_env: { [key in string]: string }, /** * Optional display name for UI */ diff --git a/bindings/CreateTicketRequest.ts b/bindings/CreateTicketRequest.ts new file mode 100644 index 00000000..f02a576c --- /dev/null +++ b/bindings/CreateTicketRequest.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to create a new ticket from a template. + */ +export type CreateTicketRequest = { +/** + * Template type key (feature, fix, spike, investigation, task). + */ +template: string, +/** + * Project the ticket targets (filled into the template's `project` value). + */ +project: string | null, +/** + * One-line summary (filled into the template's `summary` value). + */ +summary: string | null, +/** + * Additional Handlebars values for the template. Explicit `project`/ + * `summary` fields take precedence over the same keys here. + */ +values: { [key in string]: string }, }; diff --git a/bindings/CreateTicketResponse.ts b/bindings/CreateTicketResponse.ts new file mode 100644 index 00000000..31ca944e --- /dev/null +++ b/bindings/CreateTicketResponse.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response after creating a ticket. + */ +export type CreateTicketResponse = { +/** + * The created ticket's id (e.g. `FEAT-1234`). + */ +id: string, +/** + * The ticket filename written to the queue. + */ +filename: string, +/** + * The absolute path the ticket was written to. + */ +path: string, }; diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index ea631eed..160896af 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -1,5 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; +import type { RemoteAgentRef } from "./RemoteAgentRef"; +import type { JsonValue } from "./serde_json/JsonValue"; /** * Agent delegator configuration for autonomous ticket launching @@ -27,7 +29,7 @@ display_name: string | null, /** * Arbitrary model properties (e.g., `reasoning_effort`, sandbox) */ -model_properties: { [key in string]?: string }, +model_properties: { [key in string]: string }, /** * Optional launch configuration */ @@ -37,4 +39,36 @@ launch_config: DelegatorLaunchConfig | null, * `None` means use the `llm_tool`'s implicit vendor default * (claude → anthropic-api, codex → openai-api, gemini → google-api). */ -model_server: string | null, }; +model_server: string | null, +/** + * Declarative reference to a remote, named agent on another platform + * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). + * + * Export-only: Operator has no runtime client for those platforms, so a + * delegator carrying this CANNOT be launched locally — resolution errors out + * (see `delegator_resolution`). It is stored, listed, serialized into an + * `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + * `--format agnt` workflow export as an `agnt-agent` node. `None` = ordinary, + * locally launchable delegator. + */ +remote_agent?: RemoteAgentRef | null, +/** + * Opaque AGNT-namespaced extension fields, preserved verbatim across an + * `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, + * `assignedWorkflows`, `creditLimit`). Operator never interprets this. + */ +x_agnt?: JsonValue | null, +/** + * Opaque OpenAI-namespaced extension fields, preserved verbatim across an + * `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, + * `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. + */ +x_openai?: JsonValue | null, +/** + * Opaque carry for `AgentProfile` shared-core fields Operator cannot model + * first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an + * import→export round-trip is lossless. Distinct from `x_agnt`: these are + * shared-core fields, not AGNT-specific, so folding them into `x_agnt` would + * corrupt that namespace. Operator never interprets this. + */ +unmapped_core?: JsonValue | null, }; diff --git a/bindings/DelegatorLaunchConfigDto.ts b/bindings/DelegatorLaunchConfigDto.ts index b6606779..d43e8aef 100644 --- a/bindings/DelegatorLaunchConfigDto.ts +++ b/bindings/DelegatorLaunchConfigDto.ts @@ -14,7 +14,7 @@ yolo: boolean, /** * Permission mode override */ -permission_mode: string | null, +permission_mode?: string | null, /** * Additional CLI flags */ @@ -22,24 +22,24 @@ flags: Array, /** * Override global `git.use_worktrees` (None = use global setting) */ -use_worktrees: boolean | null, +use_worktrees?: boolean | null, /** * Whether to create a git branch for the ticket (None = default behavior) */ -create_branch: boolean | null, +create_branch?: boolean | null, /** * Run in docker container (None = use global `launch.docker.enabled`) */ -docker: boolean | null, +docker?: boolean | null, /** * Prompt text to prepend before the generated step prompt */ -prompt_prefix: string | null, +prompt_prefix?: string | null, /** * Prompt text to append after the generated step prompt */ -prompt_suffix: string | null, +prompt_suffix?: string | null, /** * Override global relay auto-inject MCP setting per-delegator (None = use global setting) */ -operator_relay: boolean | null, }; +operator_relay?: boolean | null, }; diff --git a/bindings/DelegatorResponse.ts b/bindings/DelegatorResponse.ts index 5cdecefd..2279cd66 100644 --- a/bindings/DelegatorResponse.ts +++ b/bindings/DelegatorResponse.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DelegatorLaunchConfigDto } from "./DelegatorLaunchConfigDto"; +import type { RemoteAgentRef } from "./RemoteAgentRef"; /** * Response for a single delegator @@ -24,7 +25,7 @@ display_name: string | null, /** * Arbitrary model properties */ -model_properties: { [key in string]?: string }, +model_properties: { [key in string]: string }, /** * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ @@ -32,4 +33,9 @@ model_server: string | null, /** * Optional launch configuration */ -launch_config: DelegatorLaunchConfigDto | null, }; +launch_config: DelegatorLaunchConfigDto | null, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + * set, the delegator is export-only and cannot be launched locally. + */ +remote_agent: RemoteAgentRef | null, }; diff --git a/bindings/ExecutionProcess.ts b/bindings/ExecutionProcess.ts index f945f91a..fb7eb963 100644 --- a/bindings/ExecutionProcess.ts +++ b/bindings/ExecutionProcess.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; import type { ProcessStatus } from "./ProcessStatus"; import type { RunReason } from "./RunReason"; +import type { JsonValue } from "./serde_json/JsonValue"; /** * An individual execution process within an attempt diff --git a/bindings/ExternalMcpServer.ts b/bindings/ExternalMcpServer.ts index 9fe22b47..91dcaa33 100644 --- a/bindings/ExternalMcpServer.ts +++ b/bindings/ExternalMcpServer.ts @@ -29,7 +29,7 @@ args: Array, * Environment variables passed to the MCP server process. * Values support `${VAR}` interpolation. */ -env: { [key in string]?: string }, +env: { [key in string]: string }, /** * Whether this server is enabled. Allows disabling without removing config. */ diff --git a/bindings/GithubProjectsConfig.ts b/bindings/GithubProjectsConfig.ts index d372e4a0..86acaaf8 100644 --- a/bindings/GithubProjectsConfig.ts +++ b/bindings/GithubProjectsConfig.ts @@ -30,4 +30,4 @@ api_key_env: string, /** * Per-project sync configuration. Keys are `GraphQL` project node IDs. */ -projects: { [key in string]?: ProjectSyncConfig }, }; +projects: { [key in string]: ProjectSyncConfig }, }; diff --git a/bindings/HealthResponse.ts b/bindings/HealthResponse.ts index cd540284..82fc6333 100644 --- a/bindings/HealthResponse.ts +++ b/bindings/HealthResponse.ts @@ -3,4 +3,12 @@ /** * Health check response */ -export type HealthResponse = { status: string, version: string, }; +export type HealthResponse = { status: string, version: string, +/** + * Top-level directory name of the operator working root (e.g. "acme"). + */ +directory_name: string, +/** + * Non-reversible fingerprint of the working root's canonical path. + */ +directory_id: string, }; diff --git a/bindings/JiraConfig.ts b/bindings/JiraConfig.ts index 0194d0ef..334dd609 100644 --- a/bindings/JiraConfig.ts +++ b/bindings/JiraConfig.ts @@ -22,4 +22,4 @@ email: string, /** * Per-project sync configuration */ -projects: { [key in string]?: ProjectSyncConfig }, }; +projects: { [key in string]: ProjectSyncConfig }, }; diff --git a/bindings/KanbanConfig.ts b/bindings/KanbanConfig.ts index e90992f3..14c40172 100644 --- a/bindings/KanbanConfig.ts +++ b/bindings/KanbanConfig.ts @@ -15,11 +15,11 @@ export type KanbanConfig = { /** * Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") */ -jira: { [key in string]?: JiraConfig }, +jira: { [key in string]: JiraConfig }, /** * Linear instances keyed by workspace slug */ -linear: { [key in string]?: LinearConfig }, +linear: { [key in string]: LinearConfig }, /** * GitHub Projects v2 instances keyed by owner login (user or org) * @@ -28,4 +28,4 @@ linear: { [key in string]?: LinearConfig }, * 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 }, }; +github: { [key in string]: GithubProjectsConfig }, }; diff --git a/bindings/LinearConfig.ts b/bindings/LinearConfig.ts index c9e37b08..78ff5ab9 100644 --- a/bindings/LinearConfig.ts +++ b/bindings/LinearConfig.ts @@ -18,4 +18,4 @@ api_key_env: string, /** * Per-team sync configuration */ -projects: { [key in string]?: ProjectSyncConfig }, }; +projects: { [key in string]: ProjectSyncConfig }, }; diff --git a/bindings/ListKanbanProjectsRequest.ts b/bindings/ListKanbanProjectsRequest.ts index 6ca8c6e8..c2a7868b 100644 --- a/bindings/ListKanbanProjectsRequest.ts +++ b/bindings/ListKanbanProjectsRequest.ts @@ -7,4 +7,4 @@ import type { LinearCredentials } from "./LinearCredentials"; /** * Request to list projects/teams from a provider using ephemeral creds. */ -export type ListKanbanProjectsRequest = { provider: KanbanProviderKind, jira: JiraCredentials | null, linear: LinearCredentials | null, github: GithubCredentials | null, }; +export type ListKanbanProjectsRequest = { provider: KanbanProviderKind, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, }; diff --git a/bindings/LlmProvider.ts b/bindings/LlmProvider.ts index 3e5ef2b9..91dd6010 100644 --- a/bindings/LlmProvider.ts +++ b/bindings/LlmProvider.ts @@ -24,7 +24,7 @@ flags: Array, /** * Environment variables to set when launching */ -env: { [key in string]?: string }, +env: { [key in string]: string }, /** * Whether this provider requires approval gates */ diff --git a/bindings/LlmTask.ts b/bindings/LlmTask.ts index fdebb5c6..3aa62bce 100644 --- a/bindings/LlmTask.ts +++ b/bindings/LlmTask.ts @@ -7,12 +7,12 @@ export type LlmTask = { /** * LLM task ID (e.g., Claude delegate mode task UUID) */ -id: string | null, +id?: string | null, /** * LLM task status: "open" or "resolved" */ -status: string | null, +status?: string | null, /** * List of task IDs that must resolve before this task */ -blocked_by: Array, }; +blocked_by?: Array, }; diff --git a/bindings/LlmToolUsage.ts b/bindings/LlmToolUsage.ts index 87c791ff..59e8e86f 100644 --- a/bindings/LlmToolUsage.ts +++ b/bindings/LlmToolUsage.ts @@ -32,4 +32,4 @@ last_used: string, /** * Per-model breakdown */ -model_usage: { [key in string]?: LlmModelUsage }, }; +model_usage: { [key in string]: LlmModelUsage }, }; diff --git a/bindings/LlmToolsConfig.ts b/bindings/LlmToolsConfig.ts index d67e8acf..9843b6cf 100644 --- a/bindings/LlmToolsConfig.ts +++ b/bindings/LlmToolsConfig.ts @@ -31,4 +31,4 @@ default_model: string | null, /** * Per-tool overrides for skill directories (keyed by `tool_name`) */ -skill_directory_overrides: { [key in string]?: SkillDirectoriesOverride }, }; +skill_directory_overrides: { [key in string]: SkillDirectoriesOverride }, }; diff --git a/bindings/ModelServer.ts b/bindings/ModelServer.ts index c1650f78..cc4c61f1 100644 --- a/bindings/ModelServer.ts +++ b/bindings/ModelServer.ts @@ -17,7 +17,7 @@ export type ModelServer = { */ name: string, /** - * Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + * Kind: "ollama", "openrouter", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" */ kind: string, /** @@ -32,7 +32,7 @@ api_key_env: string | null, /** * Additional environment variables set when spawning agents that use this server */ -extra_env: { [key in string]?: string }, +extra_env: { [key in string]: string }, /** * Optional display name for UI */ diff --git a/bindings/ModelServerKindEntry.ts b/bindings/ModelServerKindEntry.ts index 04dfd8bf..6f4fa26a 100644 --- a/bindings/ModelServerKindEntry.ts +++ b/bindings/ModelServerKindEntry.ts @@ -27,4 +27,34 @@ icon: string, /** * Whether this is an implicit vendor builtin (always present, not deletable) */ -is_builtin: boolean, }; +is_builtin: boolean, +/** + * Provider-class slug grouping kinds within the Model Provider vertical + * ("first-party" or "gateway"). Distinct from `is_builtin`. (Field name + * kept as `category` for wire/TS stability.) + */ +category: string, +/** + * Human-friendly provider-class group header (e.g. "First-party") + */ +category_label: string, +/** + * Brand-icon basename (e.g. "ollama") for surfaces that render vendor + * logos, or `None` to fall back to the `icon` codicon. + */ +brand_icon: string | null, +/** + * Default inference base URL used to probe this provider's models when no + * instance declares one, or `None` for bring-your-own-endpoint kinds. + */ +default_base_url: string | null, +/** + * Default env var the probe reads to authenticate (e.g. `ANTHROPIC_API_KEY`), + * or `None` when no key is needed by default. + */ +default_api_key_env: string | null, +/** + * Whether operator can probe this provider from built-in defaults (it has a + * `default_base_url`) without the user first declaring an instance. + */ +connectable: boolean, }; diff --git a/bindings/ModelServerResponse.ts b/bindings/ModelServerResponse.ts index a0bb4715..147fc6ee 100644 --- a/bindings/ModelServerResponse.ts +++ b/bindings/ModelServerResponse.ts @@ -23,7 +23,7 @@ api_key_env: string | null, /** * Additional environment variables set when spawning agents that use this server */ -extra_env: { [key in string]?: string }, +extra_env: { [key in string]: string }, /** * Optional display name for UI */ diff --git a/bindings/MultiAgentGroup.ts b/bindings/MultiAgentGroup.ts index 41fa3cde..efba6c86 100644 --- a/bindings/MultiAgentGroup.ts +++ b/bindings/MultiAgentGroup.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; import type { MultiAgentPhase } from "./MultiAgentPhase"; import type { PendingSubAgent } from "./PendingSubAgent"; +import type { JsonValue } from "./serde_json/JsonValue"; /** * Tracks a group of agents working on a single multi-agent step @@ -36,7 +36,7 @@ phase: MultiAgentPhase, * (delegator name for `multi_model`, index for `multi_prompt`, * `{delegator}:{prompt_idx}` for `matrixed`). */ -individual_outputs: { [key in string]?: JsonValue }, +individual_outputs: { [key in string]: JsonValue }, /** * Final aggregated output (set when phase = Complete) */ @@ -52,4 +52,4 @@ pending_launches: Array, /** * Maps launched `agent_id` to the `variant_key` used as the output key. */ -agent_variant_keys: { [key in string]?: string }, }; +agent_variant_keys: { [key in string]: string }, }; diff --git a/bindings/ProjectLlmStats.ts b/bindings/ProjectLlmStats.ts index 75a1b7fe..e6c6a0fe 100644 --- a/bindings/ProjectLlmStats.ts +++ b/bindings/ProjectLlmStats.ts @@ -20,7 +20,7 @@ preferred_model: string | null, /** * Usage history per LLM tool */ -tool_usage: { [key in string]?: LlmToolUsage }, +tool_usage: { [key in string]: LlmToolUsage }, /** * Last updated timestamp */ diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index 6ec8a088..60a1c500 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -19,12 +19,12 @@ sync_statuses: Array, * Optional `IssueTypeCollection` name this project maps to. * Not required for kanban onboarding or sync. */ -collection_name: string | null, +collection_name?: string | null, /** * Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). * Multiple kanban types can map to the same operator template. */ -type_mappings: { [key in string]?: string }, +type_mappings: { [key in string]: string }, /** * When true, operator pushes status changes and activity logs back to this kanban project. * Ticket state changes (todo→doing, doing→done) and step completions with delegator info diff --git a/bindings/RemoteAgentRef.ts b/bindings/RemoteAgentRef.ts new file mode 100644 index 00000000..bd55facd --- /dev/null +++ b/bindings/RemoteAgentRef.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A declarative reference to a remote, named agent hosted by another platform. + * + * `platform` is the hosting service (`"agnt"`, `"openai"`) — deliberately + * distinct from the core `provider`/`llm_tool` (the model or coding CLI). These + * agents are API/memory-native and live on the remote side; Operator has no + * runtime client for them, so a delegator carrying one is **export-only** and + * cannot be launched locally (see the guard in `delegator_resolution`). + */ +export type RemoteAgentRef = { +/** + * Hosting platform (e.g. `"agnt"`, `"openai"`). + */ +platform: string, +/** + * Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). + */ +id: string, }; diff --git a/bindings/RestApiConfig.ts b/bindings/RestApiConfig.ts index 24dbe4a2..3e7c6682 100644 --- a/bindings/RestApiConfig.ts +++ b/bindings/RestApiConfig.ts @@ -8,6 +8,12 @@ export type RestApiConfig = { * Whether the REST API is enabled */ enabled: boolean, +/** + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so + * the server — which reports the project directory name — is not reachable + * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + */ +host: string, /** * Port for the REST API server */ diff --git a/bindings/SectionRowDto.ts b/bindings/SectionRowDto.ts index ad76b44d..c9705967 100644 --- a/bindings/SectionRowDto.ts +++ b/bindings/SectionRowDto.ts @@ -21,6 +21,11 @@ depth: number, label: string, description: string, * Icon hint (e.g. "check", "warning", "tool", "folder"). */ icon: string, +/** + * Optional vendor-brand basename (e.g. "ollama"). When set, the web UI + * renders `/icons/{brand_icon}.svg` instead of the semantic `icon`. + */ +brand_icon: string | null, /** * Health: "green" | "yellow" | "red" | "gray". */ diff --git a/bindings/SetKanbanSessionEnvRequest.ts b/bindings/SetKanbanSessionEnvRequest.ts index 506fd2d7..cdde94c3 100644 --- a/bindings/SetKanbanSessionEnvRequest.ts +++ b/bindings/SetKanbanSessionEnvRequest.ts @@ -8,4 +8,4 @@ import type { LinearSessionEnv } from "./LinearSessionEnv"; * Request to set kanban-related env vars on the server for the current * session so subsequent `from_config` calls find the API key. */ -export type SetKanbanSessionEnvRequest = { provider: KanbanProviderKind, jira: JiraSessionEnv | null, linear: LinearSessionEnv | null, github: GithubSessionEnv | null, }; +export type SetKanbanSessionEnvRequest = { provider: KanbanProviderKind, jira?: JiraSessionEnv | null, linear?: LinearSessionEnv | null, github?: GithubSessionEnv | null, }; diff --git a/bindings/State.ts b/bindings/State.ts index c2852cb0..a3f4c674 100644 --- a/bindings/State.ts +++ b/bindings/State.ts @@ -8,11 +8,11 @@ export type State = { paused: boolean, agents: Array, completed: Arr /** * Per-project LLM usage statistics */ -project_llm_stats: { [key in string]?: ProjectLlmStats }, +project_llm_stats: { [key in string]: ProjectLlmStats }, /** * Per-project issue type collection preferences (`project_name` -> `collection_name`) */ -project_collection_prefs: { [key in string]?: string }, +project_collection_prefs: { [key in string]: string }, /** * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) */ diff --git a/bindings/StatusResponse.ts b/bindings/StatusResponse.ts index cce98462..5b2d8026 100644 --- a/bindings/StatusResponse.ts +++ b/bindings/StatusResponse.ts @@ -3,4 +3,12 @@ /** * Status response with registry info */ -export type StatusResponse = { status: string, version: string, issuetype_count: number, collection_count: number, active_collection: string, }; +export type StatusResponse = { status: string, version: string, +/** + * Top-level directory name of the operator working root (e.g. "acme"). + */ +directory_name: string, +/** + * Non-reversible fingerprint of the working root's canonical path. + */ +directory_id: string, issuetype_count: number, collection_count: number, active_collection: string, }; diff --git a/bindings/TicketDetailResponse.ts b/bindings/TicketDetailResponse.ts index 5153b1b5..fcee9d0c 100644 --- a/bindings/TicketDetailResponse.ts +++ b/bindings/TicketDetailResponse.ts @@ -55,11 +55,11 @@ filepath: string, /** * Session IDs per step (`step_name` -> `session_uuid`) */ -sessions: { [key in string]?: string }, +sessions: { [key in string]: string }, /** * Delegator used per step (`step_name` -> `delegator_name`) */ -step_delegators: { [key in string]?: string }, +step_delegators: { [key in string]: string }, /** * Path to git worktree (if created) */ diff --git a/bindings/UpdateModelServerRequest.ts b/bindings/UpdateModelServerRequest.ts index 875b57ab..d9aa1401 100644 --- a/bindings/UpdateModelServerRequest.ts +++ b/bindings/UpdateModelServerRequest.ts @@ -21,7 +21,7 @@ api_key_env: string | null, /** * Additional environment variables */ -extra_env: { [key in string]?: string }, +extra_env: { [key in string]: string }, /** * Optional display name for UI */ diff --git a/bindings/ValidateKanbanCredentialsRequest.ts b/bindings/ValidateKanbanCredentialsRequest.ts index bc6c2628..f230f2f1 100644 --- a/bindings/ValidateKanbanCredentialsRequest.ts +++ b/bindings/ValidateKanbanCredentialsRequest.ts @@ -7,4 +7,4 @@ import type { LinearCredentials } from "./LinearCredentials"; /** * Request to validate kanban credentials without persisting them. */ -export type ValidateKanbanCredentialsRequest = { provider: KanbanProviderKind, jira: JiraCredentials | null, linear: LinearCredentials | null, github: GithubCredentials | null, }; +export type ValidateKanbanCredentialsRequest = { provider: KanbanProviderKind, jira?: JiraCredentials | null, linear?: LinearCredentials | null, github?: GithubCredentials | null, }; diff --git a/bindings/ValidateKanbanCredentialsResponse.ts b/bindings/ValidateKanbanCredentialsResponse.ts index 090cbc5b..163f0d05 100644 --- a/bindings/ValidateKanbanCredentialsResponse.ts +++ b/bindings/ValidateKanbanCredentialsResponse.ts @@ -9,4 +9,4 @@ import type { LinearValidationDetailsDto } from "./LinearValidationDetailsDto"; * `valid: false` is returned for auth failures — never a 4xx/5xx HTTP * status — so clients can display `error` inline without exception handling. */ -export type ValidateKanbanCredentialsResponse = { valid: boolean, error: string | null, jira: JiraValidationDetailsDto | null, linear: LinearValidationDetailsDto | null, github: GithubValidationDetailsDto | null, }; +export type ValidateKanbanCredentialsResponse = { valid: boolean, error?: string | null, jira?: JiraValidationDetailsDto | null, linear?: LinearValidationDetailsDto | null, github?: GithubValidationDetailsDto | null, }; diff --git a/bindings/VsCodeTerminalCreateOptions.ts b/bindings/VsCodeTerminalCreateOptions.ts index b829c961..d2fa48df 100644 --- a/bindings/VsCodeTerminalCreateOptions.ts +++ b/bindings/VsCodeTerminalCreateOptions.ts @@ -15,4 +15,4 @@ workingDir?: string, /** * Environment variables to set */ -env?: { [key in string]?: string }, }; +env?: { [key in string]: string }, }; diff --git a/bindings/VsCodeTicketMetadata.ts b/bindings/VsCodeTicketMetadata.ts index 07c19e9a..214666f1 100644 --- a/bindings/VsCodeTicketMetadata.ts +++ b/bindings/VsCodeTicketMetadata.ts @@ -27,7 +27,7 @@ project: string, /** * Session UUIDs by step name */ -sessions?: { [key in string]?: string }, +sessions?: { [key in string]: string }, /** * Git worktree path if using per-ticket worktrees */ diff --git a/bindings/WriteKanbanConfigRequest.ts b/bindings/WriteKanbanConfigRequest.ts index ca794271..445c618a 100644 --- a/bindings/WriteKanbanConfigRequest.ts +++ b/bindings/WriteKanbanConfigRequest.ts @@ -10,4 +10,4 @@ import type { WriteLinearConfigBody } from "./WriteLinearConfigBody"; * This endpoint does NOT take the secret — only the env var NAME * (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. */ -export type WriteKanbanConfigRequest = { provider: KanbanProviderKind, jira: WriteJiraConfigBody | null, linear: WriteLinearConfigBody | null, github: WriteGithubConfigBody | null, }; +export type WriteKanbanConfigRequest = { provider: KanbanProviderKind, jira?: WriteJiraConfigBody | null, linear?: WriteLinearConfigBody | null, github?: WriteGithubConfigBody | null, }; diff --git a/bindings/XOperator.ts b/bindings/XOperator.ts new file mode 100644 index 00000000..eb325238 --- /dev/null +++ b/bindings/XOperator.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; + +/** + * The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator + * carries that have no shared-core equivalent. AGNT ignores this bag; Operator + * round-trips it losslessly. + */ +export type XOperator = { +/** + * Optional display name for UI. + */ +display_name?: string | null, +/** + * Arbitrary model properties (e.g. `reasoning_effort`, sandbox). + */ +model_properties?: { [key in string]: string }, +/** + * Name of a declared `ModelServer` (`None` = implicit vendor default). + */ +model_server?: string | null, +/** + * Launch configuration (permission mode, flags, worktree/docker, prompt + * wrapping, ...). + */ +launch_config?: DelegatorLaunchConfig | null, }; diff --git a/bindings/serde_json/JsonValue.ts b/bindings/serde_json/JsonValue.ts index 3ad5da8d..320a852f 100644 --- a/bindings/serde_json/JsonValue.ts +++ b/bindings/serde_json/JsonValue.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; +export type JsonValue = number | string | boolean | Array | { [key in string]: JsonValue } | null; diff --git a/catalog-info.yaml b/catalog-info.yaml index 12d58a05..9ed7c4b7 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -8,7 +8,7 @@ kind: Component metadata: name: operator title: Operator - description: Multi-agent orchestration dashboard for gbqr.us - TUI application for managing ticket queues, launching Claude Code agents, and tracking progress across projects. + description: Multi-agent orchestration dashboard for kanban shaped software development - TUI application for managing ticket queues, launching Claude Code agents, and tracking progress across projects. annotations: backstage.io/techdocs-ref: dir:. github.com/project-slug: untra/operator diff --git a/config/default.toml b/config/default.toml index 78516d9e..73b7622a 100644 --- a/config/default.toml +++ b/config/default.toml @@ -112,8 +112,8 @@ connect_timeout_ms = 5000 # Model servers (where models are hosted) # Implicit builtins exist for anthropic-api, openai-api, google-api and do not # need to be declared. Declare a `[[model_servers]]` entry when pointing a -# delegator at an alternate host (ollama, lmstudio, vllm, any OpenAI-compatible -# endpoint). +# delegator at a many-model provider (openrouter, ollama, lmstudio, vllm, any +# OpenAI-compatible endpoint). # # [[model_servers]] # name = "ollama-local" @@ -121,6 +121,15 @@ connect_timeout_ms = 5000 # base_url = "http://localhost:11434" # display_name = "Ollama (local)" # +# # OpenRouter: one OpenAI-compatible key fronting 300+ models. +# # Set OPENROUTER_API_KEY in your environment; it is referenced, never stored. +# [[model_servers]] +# name = "openrouter" +# kind = "openrouter" +# base_url = "https://openrouter.ai/api/v1" +# api_key_env = "OPENROUTER_API_KEY" +# display_name = "OpenRouter" +# # # Example for running an Anthropic-protocol bridge (e.g. claude-code-router) # # in front of ollama so the `claude` CLI can talk to local models: # [[model_servers]] diff --git a/docs/_config.yml b/docs/_config.yml index be5360f3..ac30b979 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -5,7 +5,10 @@ url: "https://operator.untra.io" baseurl: "" # Social/SEO -image: /assets/img/patricia_512.png +# 1200x630 social card (raster PNG — SVG is not rendered by social unfurlers). +# Also set in `defaults` below because jekyll-seo-tag emits og:image from +# page.image, not site.image. +image: /assets/img/operator_og.png twitter: card: summary_large_image @@ -36,6 +39,7 @@ defaults: path: "" values: layout: "doc" + image: /assets/img/operator_og.png # Collections collections_dir: . diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index c11a7313..83a63e4e 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -51,6 +51,24 @@ docs: - title: Gemini CLI url: /getting-started/agents/gemini-cli/ icon: gemini + - title: Supported Model Providers + url: /getting-started/model-servers/ + children: + - title: Anthropic + url: /getting-started/model-servers/anthropic/ + icon: anthropic + - title: OpenAI + url: /getting-started/model-servers/openai/ + icon: codex + - title: Google + url: /getting-started/model-servers/google/ + icon: google + - title: OpenRouter + url: /getting-started/model-servers/openrouter/ + icon: openrouter + - title: Ollama + url: /getting-started/model-servers/ollama/ + icon: ollama - title: Supported Git Repositories url: /getting-started/git/ children: @@ -78,6 +96,11 @@ docs: - title: Docker url: /getting-started/platforms/docker/ icon: docker + - title: Automation Platform Integrations + url: /getting-started/integrations/ + children: + - title: AGNT.gg + url: /getting-started/integrations/agnt/ - title: Core children: - title: Kanban diff --git a/docs/assets/icons/anthropic.svg b/docs/assets/icons/anthropic.svg new file mode 100644 index 00000000..c917480d --- /dev/null +++ b/docs/assets/icons/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/docs/assets/icons/google.svg b/docs/assets/icons/google.svg new file mode 100644 index 00000000..2eaf9155 --- /dev/null +++ b/docs/assets/icons/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/docs/assets/icons/ollama.svg b/docs/assets/icons/ollama.svg new file mode 100644 index 00000000..bc368e99 --- /dev/null +++ b/docs/assets/icons/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/docs/assets/icons/openrouter.svg b/docs/assets/icons/openrouter.svg new file mode 100644 index 00000000..83ec807b --- /dev/null +++ b/docs/assets/icons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/docs/assets/img/operator_og.png b/docs/assets/img/operator_og.png new file mode 100644 index 00000000..52c75083 Binary files /dev/null and b/docs/assets/img/operator_og.png differ diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 122f02d1..daca344f 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -228,7 +228,7 @@ collection = [] [api] pr_check_interval_secs = 60 rate_limit_check_interval_secs = 300 -rate_limit_warning_threshold = 0.20000000298023224 +rate_limit_warning_threshold = 0.2 [logging] level = "info" @@ -265,6 +265,7 @@ detection_complete = false [rest_api] enabled = true +host = "127.0.0.1" port = 7008 cors_origins = [] diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 35b2c74b..07e03506 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -127,16 +127,55 @@ model = "opus" prompt_suffix = "\n\nThink carefully before acting." ``` +## Agent profiles & remote agents + +A delegator can be serialized to a portable **agent profile** (`agent-profile.json`) — a +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. + +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]] +name = "agnt-researcher" +# the agent lives on AGNT; Operator references it but never runs it +[delegators.remote_agent] +platform = "agnt" # or "openai" +id = "Research Assistant" # AGNT agent name, or an OpenAI asst_… id +``` + +Remote agents are **export-only**: Operator has no runtime client for those platforms, so a +delegator carrying a `remote_agent` cannot be launched locally — resolution returns a +`RemoteOnlyDelegator` error on every launch path. When the platform is `agnt`, the reference is +surfaced in the [`--format agnt` workflow export](/docs/) as an `agnt-agent` node; other platforms +ride opaquely in the profile. + +> **Caveat:** a non-AGNT remote delegator (e.g. `platform = "openai"`) used as a step agent in an +> `agnt` export still emits an ordinary `operator-run-step` node. If AGNT runs that node it calls +> back into Operator, which then hits the `RemoteOnlyDelegator` guard and errors. Don't bind a +> non-AGNT remote delegator as the step agent of a workflow you intend to export to AGNT. + +> **Design note — the interchange is tool-agnostic.** AGNT was the first remote platform; adding +> OpenAI Assistants as the second cost only a generic `remote_agent { platform, id }` reference and +> an opaque `x_openai` bag mirroring `x_agnt` — **no new mapping logic, no executor, no export +> node.** That's the evidence the schema core is not shaped around any one tool. + ## REST API -The running Operator API exposes full CRUD for delegators: +The running Operator API exposes full CRUD for delegators, plus agent-profile interchange: | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/v1/delegators` | List all delegators | | `POST` | `/api/v1/delegators` | Create a delegator | | `POST` | `/api/v1/delegators/from-tool` | Create from a detected tool (auto-generates name) | +| `POST` | `/api/v1/delegators/import-profile` | Create a delegator from an `AgentProfile` | | `GET` | `/api/v1/delegators/{name}` | Get one delegator | +| `GET` | `/api/v1/delegators/{name}/profile` | Export a delegator as an `AgentProfile` | | `PUT` | `/api/v1/delegators/{name}` | Update a delegator | | `DELETE` | `/api/v1/delegators/{name}` | Delete a delegator | diff --git a/docs/getting-started/integrations/agnt.md b/docs/getting-started/integrations/agnt.md new file mode 100644 index 00000000..c3525b7a --- /dev/null +++ b/docs/getting-started/integrations/agnt.md @@ -0,0 +1,100 @@ +--- +title: "AGNT.gg" +description: "Export Operator workflows to AGNT.gg and drive Operator from AGNT workflows." +layout: doc +--- + +# AGNT.gg + +[AGNT.gg](https://agnt.gg) is a local-first agent operating system: a desktop +app + local runtime with visual graph workflows, agents, a plugin marketplace, +and native MCP support. Operator connects to AGNT in both directions. + +## Operator → AGNT: export a workflow + +Render any ticket (against its issuetype) into an AGNT workflow graph: + +```bash +operator workflow export FEAT-1234 --format agnt +# writes FEAT-1234.agnt.workflow.json +``` + +Or over REST (also used by the `operator-export-workflow` plugin node): + +```bash +curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?format=agnt" +``` + +The output is an AGNT `{ name, description, nodes, edges }` graph. Each operator +step becomes one `operator-run-step` node (carrying `{ ticket, step, prompt, … }` +in its config); the `next_step` chain becomes edges. This export runs in AGNT +once the `operator-plugin` (below) is installed, since `operator-run-step` is one +of that plugin's node types. Operator sequences a ticket's steps internally, so +the per-step nodes are a faithful **visualization** of the ticket's shape; running +the graph drives the one underlying Operator ticket. + +> Like the Claude `.js` export, this is a one-way, lossy *flattening*. AGNT nodes +> can't reproduce Operator's terminal coding sessions, and human review gates / +> RAG / MCP / fan-out steps are recorded in each node's `config.gap` rather than +> faithfully executed. Treat the export as a runnable **scaffold**, not an +> equivalent. + +## AGNT → Operator: the `operator-plugin` + +The [`operator-plugin`](https://github.com/untra/operator/tree/main/agnt-plugin) +adds Operator nodes to AGNT's canvas. Each is a thin wrapper around Operator's +REST API: + +| Node | Endpoint | +|------|----------| +| `operator-create-ticket` | `POST /api/v1/tickets` | +| `operator-launch-agent` | `POST /api/v1/tickets/{id}/launch` | +| `operator-run-step` | `POST /api/v1/tickets/{ticket}/launch` (the export's node type) | +| `operator-queue-status` | `GET /api/v1/queue/status` | +| `operator-export-workflow` | `POST /api/v1/tickets/{id}/workflow-export` | +| `operator-alert` | `POST /api/v1/alerts` | + +### Install + +1. Start Operator's REST API: `operator api` (default port `7008`). +2. Build the plugin from the AGNT repo: + ```bash + cp -r agnt-plugin /path/to/agnt/backend/plugins/dev/operator-plugin + cd /path/to/agnt/backend/plugins && node build-plugin.js operator-plugin + ``` +3. Install `plugin-builds/operator-plugin.agnt` via AGNT's Marketplace UI (or drop + it into `~/Library/Application Support/AGNT/plugins/installed/` and + `POST http://localhost:3333/api/plugins/reload`). + +Set each node's `operatorBaseUrl` (or the `OPERATOR_BASE_URL` env var) if Operator +isn't on the default `http://localhost:7008`. + +### Example + +``` +webhook-trigger + → operator-create-ticket { template: "fix", project: "gamesvc", summary: "{{payload.title}}" } + → operator-launch-agent { id: "{{prev.result.id}}" } + → operator-queue-status + → slack-send +``` + +## Alternative: the MCP bridge (no plugin) + +AGNT consumes stdio MCP servers natively. Register Operator without building a +plugin via AGNT's MCP settings: + +```json +{ "name": "operator", "command": "operator", "args": ["mcp"] } +``` + +This surfaces Operator's ~18 MCP tools in AGNT immediately. The plugin's +advantages over the raw bridge are first-class canvas nodes with typed +parameters and marketplace discoverability — but the bridge is zero-build and +the same `operator mcp` server works with any MCP-capable platform. + +## Trust & permissions + +These integrations create tickets and launch agents against your repositories. +Only connect an AGNT instance you control, point `operatorBaseUrl` at a trusted +Operator API, and gate MCP write tools with `[mcp].expose_ticket_write_tools`. diff --git a/docs/getting-started/integrations/index.md b/docs/getting-started/integrations/index.md new file mode 100644 index 00000000..39bb4c53 --- /dev/null +++ b/docs/getting-started/integrations/index.md @@ -0,0 +1,50 @@ +--- +title: "Automation Platform Integrations" +description: "Connect Operator! to agent OS and automation platforms like AGNT.gg." +layout: doc +--- + +# Automation Platform Integrations + +Operator is not just a standalone TUI — it exposes its ticket orchestration over +a **REST API** and a **stdio MCP server**, so external *automation platforms* and +*agent operating systems* can drive it, and Operator can hand work out to them. + +This is a distinct integration category from your [kanban +provider](/getting-started/kanban/), [coding agent](/getting-started/agents/), +[git host](/getting-started/git/), or [session +wrapper](/getting-started/sessions/). Those are the building blocks Operator +*uses*; automation platforms are peers that *compose with* Operator. + +## The two directions + +Operator connects to an automation platform in two complementary directions: + +| Direction | What it means | How | +|-----------|---------------|-----| +| **Operator → platform** | Export an Operator issuetype/ticket as the platform's native workflow | `operator workflow export --format ` | +| **Platform → Operator** | The platform calls Operator to create tickets, launch agents, poll the queue | a platform plugin, or Operator's MCP server | + +## The portable substrate + +Because the "platform → Operator" direction rides on **REST + MCP** — both +portable, widely supported substrates — exposing them once connects Operator to +the *whole category*, not just one tool. Platforms in this space include +[AGNT.gg](https://agnt.gg), n8n, Activepieces, Windmill, Dify, Flowise, +Langflow, OpenAI AgentKit, and Zapier/Make. + +Operator already ships: + +- a **REST API** (`operator api`, default port `7008`) with endpoints to create + tickets, launch agents, read queue status, export workflows, and raise alerts; +- a **stdio MCP server** (`operator mcp`) exposing ~18 orchestration tools, which + any MCP-capable client (Claude Code, Cursor, Zed, and MCP-consuming automation + platforms) can spawn. + +## Supported integrations + +- **[AGNT.gg](/getting-started/integrations/agnt/)** — export Operator workflows + as AGNT graphs, and drive Operator from AGNT workflows via the `operator-plugin`. + +> Write/launch tools mutate your repositories. Only connect platforms you trust, +> and gate Operator's MCP write tools with `[mcp].expose_ticket_write_tools`. diff --git a/docs/getting-started/model-servers/anthropic.md b/docs/getting-started/model-servers/anthropic.md new file mode 100644 index 00000000..628ddf3c --- /dev/null +++ b/docs/getting-started/model-servers/anthropic.md @@ -0,0 +1,60 @@ +--- +title: "Anthropic" +description: "Connect Anthropic as a first-party model provider and list its models live." +layout: doc +--- + +# Anthropic + +[**Anthropic**](https://www.anthropic.com/) is a first-party model provider — it +produces the Claude family of models and serves them from its own API. It is the +zero-config default for the `claude` llm tool, and a first-class +[model provider](./) in its own right: once connected, operator lists its +available models live so delegators can pick one. + +> **Model provider ≠ llm tool.** Anthropic (the provider) serves the models; +> [Claude Code](../agents/claude/) (the llm tool) is the CLI that drives a coding +> session. A delegator pairs a tool with a provider's model. + +## Connect + +Operator references your key by env-var name — it never stores the secret. Set +the standard Anthropic key and operator can probe the provider: + +```bash +export ANTHROPIC_API_KEY="sk-ant-..." +``` + +A provider is **connected** when its `/models` probe succeeds (key present + +endpoint reachable). In the Model Providers view (web `/#/model-providers` or the +VS Code section) Anthropic then shows ● connected with its live model list. + +## Listing models + +Operator probes `https://api.anthropic.com/v1/models` and stays agnostic to which +models exist — it lists whatever the API returns rather than hardcoding names: + +```bash +GET /api/v1/model-servers/kinds/anthropic-api/models # { reachable, models[], error? } +``` + +## Use from a delegator + +Pick a connected provider + one of its live models in the Create Delegator form, +or declare it directly. `model` is a live id from the list above: + +```toml +[[delegators]] +name = "claude-opus" +llm_tool = "claude" +model = "claude-opus-4-20250514" +# model_server omitted → resolves to the implicit anthropic-api default +``` + +## Custom endpoint / proxy + +To route Claude through a proxy or bridge (e.g. a local model behind +`claude-code-router`), declare an `anthropic-api` server with an explicit +`base_url`; that base URL is then also injected at spawn (`ANTHROPIC_BASE_URL`). +The probe default (`https://api.anthropic.com`) is **probe-only** and never +alters the agent's launch path on its own. diff --git a/docs/getting-started/model-servers/google.md b/docs/getting-started/model-servers/google.md new file mode 100644 index 00000000..cb6f3fcd --- /dev/null +++ b/docs/getting-started/model-servers/google.md @@ -0,0 +1,54 @@ +--- +title: "Google" +description: "Connect Google (Gemini) as a first-party model provider and list its models live." +layout: doc +--- + +# Google + +[**Google**](https://ai.google.dev/) is a first-party model provider — it +produces the Gemini family and serves them from its own API. It is the +zero-config default for the `gemini` llm tool, and a first-class +[model provider](./): once connected, operator lists its available models live +for delegators. + +> **Model provider ≠ llm tool.** Google (the provider) serves the models; +> [Gemini CLI](../agents/gemini-cli/) (the llm tool) is the CLI. A delegator +> pairs a tool with a provider's model. + +## Connect + +Operator references your key by env-var name — it never stores the secret: + +```bash +export GEMINI_API_KEY="..." +``` + +A provider is **connected** when its `/models` probe succeeds. Google then shows +● connected in the Model Providers view with its live model list. + +## Listing models + +Operator probes `https://generativelanguage.googleapis.com/v1beta/models` +([model list reference](https://ai.google.dev/api/models)) and lists whatever the +API returns, stripping the `models/` prefix so the id matches `--model`: + +```bash +GET /api/v1/model-servers/kinds/google-api/models # { reachable, models[], error? } +``` + +## Use from a delegator + +```toml +[[delegators]] +name = "gemini-pro" +llm_tool = "gemini" +model = "gemini-1.5-pro" +# model_server omitted → resolves to the implicit google-api default +``` + +## Custom endpoint / proxy + +Declare a `google-api` server with an explicit `base_url` to point Gemini at a +proxy; that base URL is injected at spawn (`GOOGLE_GEMINI_BASE_URL`). The probe +default is **probe-only** and never changes the launch path on its own. diff --git a/docs/getting-started/model-servers/index.md b/docs/getting-started/model-servers/index.md index a73b7581..b35b82e5 100644 --- a/docs/getting-started/model-servers/index.md +++ b/docs/getting-started/model-servers/index.md @@ -1,20 +1,40 @@ --- layout: default -title: Model Servers +title: Supported Model Providers parent: Getting Started nav_order: 5 -has_children: false +has_children: true --- -# Model Servers +# Model Providers A **model server** is a named host that serves models via an inference API. It's orthogonal to the LLM tool that runs your coding agent: - **LLM tools** (claude, codex, gemini) are the agentic CLIs that drive the coding session — they use tools, edit files, resume sessions. -- **Model servers** are where the model weights live — Anthropic's API, OpenAI's API, Google's API, or a local/alt host like ollama, lmstudio, or vllm. +- **Model servers** are where the model weights live — Anthropic's API, OpenAI's API, Google's API, or a many-model provider like [OpenRouter](openrouter/), a local [Ollama](ollama/) server, lmstudio, or vllm. A delegator pairs an LLM tool with a model (and, optionally, a model server). +## Two families + +Every kind is a **model provider**. They split into two classes — the grouping +every surface (README badges, the status tree, the REST `/kinds` catalog) derives +from `ModelServerKind::provider_class()`: + +- **First-party** — a single vendor's own API: [Anthropic](anthropic/) + (`anthropic-api`), [OpenAI](openai/) (`openai-api`), [Google](google/) + (`google-api`). These double as the zero-config defaults for the + claude/codex/gemini tools, so you rarely declare them — but they're first-class: + operator lists each one's live models from its `/models` endpoint when the + corresponding key env is set. +- **Gateways** — a host or aggregator that fronts *many* models behind one + endpoint: [OpenRouter](openrouter/) (`openrouter`), a local [Ollama](ollama/) + server (`ollama`), or any `openai-compat` / `lmstudio` server. Declare one to + reach its whole catalog. + +`provider_class()` (first-party vs gateway) answers a different question than +`is_builtin()` (whether the kind is a zero-config default that can't be deleted). + ## The three-layer hierarchy ``` @@ -30,9 +50,9 @@ A delegator pairs an LLM tool with a model (and, optionally, a model server). name, llm_tool, model, model_server (optional) ``` -## Implicit builtins +## Zero-config defaults -You don't need to declare a model server for the vendor-default path. Every detected LLM tool has an implicit builtin: +You don't need to declare a model server for the vendor-default path. Every detected LLM tool has an implicit first-party default: | llm_tool | implicit model_server | |----------|------------------------| @@ -50,6 +70,7 @@ Delegators that omit `model_server` resolve to these builtins automatically. Exi | `openai-api` | OpenAI / a compatible proxy | | `google-api` | Google Gemini API | | `ollama` | Local ollama server (`ollama serve`, default `http://localhost:11434`) | +| `openrouter` | [OpenRouter](openrouter/) hosted gateway to 300+ models (`https://openrouter.ai/api/v1`) | | `openai-compat` | Any OpenAI-API-compatible server (vllm, lmstudio, together.ai, groq, …) | | `lmstudio` | LM Studio's local server | @@ -113,6 +134,7 @@ same probe doubles as a reachability check — there is no separate "test connec ``` GET /api/v1/model-servers # list (declared + implicit builtins) GET /api/v1/model-servers/kinds # the supported-kind catalog (single source of truth) +GET /api/v1/model-servers/kinds/{slug}/models # live models for a provider (connection check) GET /api/v1/model-servers/{name} # fetch by name GET /api/v1/model-servers/{name}/models # live model list + reachability POST /api/v1/model-servers # create @@ -129,7 +151,7 @@ keyed by the server's protocol: | kind | base URL var | API key var (mapped from `api_key_env`) | |-----------------|-----------------------|------------------------------------------| | `anthropic-api` | `ANTHROPIC_BASE_URL` | `ANTHROPIC_API_KEY` | -| `openai-api` / `openai-compat` / `ollama` / `lmstudio` | `OPENAI_BASE_URL` | `OPENAI_API_KEY` | +| `openai-api` / `openai-compat` / `ollama` / `openrouter` / `lmstudio` | `OPENAI_BASE_URL` | `OPENAI_API_KEY` | | `google-api` | `GOOGLE_GEMINI_BASE_URL` | `GEMINI_API_KEY` | The API key is injected **by reference**, not by value: if `api_key_env = "MY_KEY"`, diff --git a/docs/getting-started/model-servers/ollama.md b/docs/getting-started/model-servers/ollama.md new file mode 100644 index 00000000..047e2310 --- /dev/null +++ b/docs/getting-started/model-servers/ollama.md @@ -0,0 +1,63 @@ +--- +title: "Ollama" +description: "Run local open models with Ollama as an Operator model provider." +layout: doc +--- + +# Ollama + +[**Ollama**](https://ollama.com/) runs open models (Llama, Qwen, Mistral, …) +locally and serves them over an OpenAI-compatible API. Declare it as a +[model server](./) to drive agents against models on your own machine — no cloud +key required. + +## Prerequisites + +- Ollama installed and running: [ollama.com/download](https://ollama.com/download), + then `ollama serve` (default `http://localhost:11434`) +- At least one model pulled, e.g. `ollama pull qwen2.5-coder` +- An OpenAI-protocol LLM tool — **codex** works directly; claude/gemini need a + bridge (see [Protocol compatibility](./#protocol-compatibility)) + +## Configuration + +Declare the server in `operator.toml` (no API key needed for a local server): + +```toml +[[model_servers]] +name = "ollama-local" +kind = "ollama" +base_url = "http://localhost:11434" +display_name = "Ollama (local)" +``` + +Then reference it from a delegator: + +```toml +[[delegators]] +name = "codex-local-qwen" +llm_tool = "codex" +model = "qwen2.5-coder" +model_server = "ollama-local" +``` + +## Listing models + +Ollama enumerates its pulled models at `/api/tags`. Operator probes it for the +live list (which doubles as a reachability check): + +```bash +# REST +GET /api/v1/model-servers/ollama-local/models # { reachable, models[], error? } +``` + +In the VS Code status tree, expand the `ollama-local` server to browse the models +you've pulled. + +## How env injection works + +Ollama speaks the OpenAI protocol, so when a delegator resolves to it Operator +exports `OPENAI_BASE_URL=http://localhost:11434`. A local server needs no key; if +you've put one behind a proxy, set `api_key_env` and it is injected **by +reference**. See the [Model Providers overview](./#how-env-injection-works) for +the full mechanism. diff --git a/docs/getting-started/model-servers/openai.md b/docs/getting-started/model-servers/openai.md new file mode 100644 index 00000000..ad1e6edd --- /dev/null +++ b/docs/getting-started/model-servers/openai.md @@ -0,0 +1,53 @@ +--- +title: "OpenAI" +description: "Connect OpenAI as a first-party model provider and list its models live." +layout: doc +--- + +# OpenAI + +[**OpenAI**](https://openai.com/) is a first-party model provider — it produces +the GPT family and serves them from its own API. It is the zero-config default +for the `codex` llm tool, and a first-class [model provider](./): once connected, +operator lists its available models live for delegators to pick from. + +> **Model provider ≠ llm tool.** OpenAI (the provider) serves the models; +> [Codex](../agents/codex/) (the llm tool) is the CLI. A delegator pairs a tool +> with a provider's model. + +## Connect + +Operator references your key by env-var name — it never stores the secret: + +```bash +export OPENAI_API_KEY="sk-..." +``` + +A provider is **connected** when its `/models` probe succeeds. OpenAI then shows +● connected in the Model Providers view with its live model list. + +## Listing models + +Operator probes `https://api.openai.com/v1/models` and lists whatever the API +returns (agnostic to the specific model set): + +```bash +GET /api/v1/model-servers/kinds/openai-api/models # { reachable, models[], error? } +``` + +## Use from a delegator + +```toml +[[delegators]] +name = "codex-gpt" +llm_tool = "codex" +model = "gpt-4o" +# model_server omitted → resolves to the implicit openai-api default +``` + +## Custom endpoint / proxy + +Declare an `openai-api` server with an explicit `base_url` to point Codex at a +proxy; that base URL is then injected at spawn (`OPENAI_BASE_URL`). The probe +default (`https://api.openai.com`) is **probe-only** — it never changes the +launch path on its own. diff --git a/docs/getting-started/model-servers/openrouter.md b/docs/getting-started/model-servers/openrouter.md new file mode 100644 index 00000000..57a8aa90 --- /dev/null +++ b/docs/getting-started/model-servers/openrouter.md @@ -0,0 +1,76 @@ +--- +title: "OpenRouter" +description: "Reach hundreds of models through OpenRouter, one OpenAI-compatible gateway." +layout: doc +--- + +# OpenRouter + +[**OpenRouter**](https://openrouter.ai/) is a hosted gateway that fronts hundreds +of models (Anthropic, OpenAI, Google, Meta, Mistral, and more) behind a single +OpenAI-compatible endpoint and one API key. Declare it once as a +[model server](./) and any delegator can target the whole catalog. + +## Prerequisites + +- An OpenRouter account and an API key from + [openrouter.ai/keys](https://openrouter.ai/keys) +- An OpenAI-protocol LLM tool — **codex** works directly; claude/gemini need a + bridge (see [Protocol compatibility](./#protocol-compatibility)) + +## Configuration + +Export your key (kept out of config — Operator references it by name): + +```bash +export OPENROUTER_API_KEY="sk-or-..." +``` + +Declare the server in `operator.toml`: + +```toml +[[model_servers]] +name = "openrouter" +kind = "openrouter" +base_url = "https://openrouter.ai/api/v1" +api_key_env = "OPENROUTER_API_KEY" +display_name = "OpenRouter" +``` + +Then reference it from a delegator. The `model` is an OpenRouter model id (the +`vendor/model` form from its catalog): + +```toml +[[delegators]] +name = "codex-openrouter-sonnet" +llm_tool = "codex" +model = "anthropic/claude-3.5-sonnet" +model_server = "openrouter" +``` + +## Listing models + +OpenRouter publishes its catalog at `/models`. Operator probes it for the live +model list (which doubles as a reachability check): + +```bash +# REST +GET /api/v1/model-servers/openrouter/models # { reachable, models[], error? } +``` + +In the VS Code status tree, expand the `openrouter` server to browse its models; +each model's human label comes from OpenRouter's `name` field. + +## How env injection works + +OpenRouter speaks the OpenAI protocol, so when a delegator resolves to it +Operator exports: + +| var | value | +|-----|-------| +| `OPENAI_BASE_URL` | `https://openrouter.ai/api/v1` | +| `OPENAI_API_KEY` | `${OPENROUTER_API_KEY}` (by reference) | + +The key is injected **by reference**, never by value — the secret is never +written into the on-disk command script. See the +[Model Providers overview](./#how-env-injection-works) for the full mechanism. diff --git a/docs/index.md b/docs/index.md index 4ce46118..2fde473e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ Welcome friend! Operator! is an application These are tools that comparable and aspirational for Operator +- [agnt.gg](https://agnt.gg) - [agtx](https://github.com/fynnfluegge/agtx) - [claude-relay](https://github.com/Innestic/claude-relay) - [kanbots](https://github.com/Innestic/claude-relay) diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 00000000..da8843bd --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,34 @@ + + + +# Operator! + +> Operator! is a Rust TUI for orchestrating Claude Code and other LLM coding agents across multi-repository codebases, driven by kanban-style markdown tickets. It connects to Jira, Linear, and GitHub Projects; launches agents in tmux, cmux, Zellij, VS Code, or Zed sessions; reaches models via Anthropic, OpenAI, Google, OpenRouter, or Ollama; and enforces team workflows. + +Operator runs from the root of your work directory, discovers projects by LLM marker files (CLAUDE.md / CODEX.md / GEMINI.md) and git repositories, and manages a `.tickets/` queue. It exposes a REST API and an embedded web developer portal. + +## Getting Started +- [Getting Started](https://operator.untra.io/getting-started/): Install and configure Operator! for managing AI-assisted development workflows. +- [Downloads](https://operator.untra.io/downloads/): Download Operator! binaries for macOS, Linux, and Windows. + +## Core Concepts +- [Kanban Workflow](https://operator.untra.io/kanban/): Understand the kanban-style workflow for managing tickets through queue, in-progress, and completed stages. +- [Tickets](https://operator.untra.io/tickets/): Create and manage tickets with markdown format, naming conventions, and best practices for LLM agents. +- [Issue Types](https://operator.untra.io/issue-types/): Learn about INV, FIX, FEAT, SPIKE, and TASK issue types, custom definitions, and Jira imports. +- [Agents](https://operator.untra.io/agents/): Understand agent lifecycle, states, autonomous vs paired modes, parallelism rules, and session tracking. +- [Delegators](https://operator.untra.io/delegators/): Named LLM tool + model pairings for autonomous ticket launching. + +## Integrations +- [LLM Tools](https://operator.untra.io/llm-tools/): Configure Claude Code and other LLM tools for AI-powered agent integration with Operator!. +- [Relay](https://operator.untra.io/relay/): Multi-agent peer-to-peer communication hub embedded in Operator. + +## Reference +- [CLI Reference](https://operator.untra.io/cli/): Commands and environment variables. +- [Configuration](https://operator.untra.io/configuration/): TOML configuration structure and options. +- [Schema Reference](https://operator.untra.io/schemas/): Ticket metadata, issue type, and OpenAPI schema reference. +- [Keyboard Shortcuts](https://operator.untra.io/shortcuts/): TUI keyboard shortcuts by context. +- [Project Taxonomy](https://operator.untra.io/taxonomy/): Project Kinds across five tiers. + +## Optional +- [Architecture](https://operator.untra.io/architecture/): System design overview. +- [GitHub Repository](https://github.com/untra/operator): Source code (Rust, MIT). diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 6a7e353b..c0b8bd4f 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -95,6 +95,7 @@ "$ref": "#/$defs/RestApiConfig", "default": { "enabled": true, + "host": "127.0.0.1", "port": 7008, "cors_origins": [] } @@ -1061,6 +1062,11 @@ "type": "boolean", "default": true }, + "host": { + "description": "Address the REST API binds to. Defaults to `127.0.0.1` (local only) so\nthe server — which reports the project directory name — is not reachable\nfrom other hosts. Set to `0.0.0.0` to expose it on all interfaces.", + "type": "string", + "default": "127.0.0.1" + }, "port": { "description": "Port for the REST API server", "type": "integer", @@ -1412,6 +1418,26 @@ "null" ], "default": null + }, + "remote_agent": { + "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally — resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and — for `platform == \"agnt\"` — surfaced in the\n`--format agnt` workflow export as an `agnt-agent` node. `None` = ordinary,\nlocally launchable delegator.", + "anyOf": [ + { + "$ref": "#/$defs/RemoteAgentRef" + }, + { + "type": "null" + } + ] + }, + "x_agnt": { + "description": "Opaque AGNT-namespaced extension fields, preserved verbatim across an\n`AgentProfile` round-trip so re-export is lossless (e.g. `memory`,\n`assignedWorkflows`, `creditLimit`). Operator never interprets this." + }, + "x_openai": { + "description": "Opaque OpenAI-namespaced extension fields, preserved verbatim across an\n`AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`,\n`metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted." + }, + "unmapped_core": { + "description": "Opaque carry for `AgentProfile` shared-core fields Operator cannot model\nfirst-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an\nimport→export round-trip is lossless. Distinct from `x_agnt`: these are\nshared-core fields, not AGNT-specific, so folding them into `x_agnt` would\ncorrupt that namespace. Operator never interprets this." } }, "required": [ @@ -1495,6 +1521,24 @@ } } }, + "RemoteAgentRef": { + "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) — deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", + "type": "object", + "properties": { + "platform": { + "description": "Hosting platform (e.g. `\"agnt\"`, `\"openai\"`).", + "type": "string" + }, + "id": { + "description": "Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id).", + "type": "string" + } + }, + "required": [ + "platform", + "id" + ] + }, "ModelServer": { "description": "A named host that serves models via an inference API.\n\nModel servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI\n(`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint\n(`model_server`, e.g. ollama-local, openai-api, a custom vllm host).\n\nImplicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are\nreturned by [`implicit_model_server_for_tool`] and do not need to be declared\nin config.", "type": "object", @@ -1504,7 +1548,7 @@ "type": "string" }, "kind": { - "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"", + "description": "Kind: \"ollama\", \"openrouter\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"", "type": "string" }, "base_url": { diff --git a/docs/schemas/config.md b/docs/schemas/config.md index c7d97c1c..596d8ce1 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -359,6 +359,7 @@ REST API server configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether the REST API is enabled | +| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server — which reports the project directory name — is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | | `port` | `integer` | No | Port for the REST API server | | `cors_origins` | `array` | No | CORS allowed origins (empty = allow all) | @@ -503,6 +504,10 @@ that can be used to launch agents for tickets. | `model_properties` | `object` | No | Arbitrary model properties (e.g., `reasoning_effort`, sandbox) | | `launch_config` | object | No | Optional launch configuration | | `model_server` | `string` \| `null` | No | Name of a declared `ModelServer` (from `Config.model_servers`). `None` means use the `llm_tool`'s implicit vendor default (claude → anthropic-api, codex → openai-api, gemini → google-api). | +| `remote_agent` | object | No | Declarative reference to a remote, named agent on another platform (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). Export-only: Operator has no runtime client for those platforms, so a delegator carrying this CANNOT be launched locally — resolution errors out (see `delegator_resolution`). It is stored, listed, serialized into an `AgentProfile`, and — for `platform == "agnt"` — surfaced in the `--format agnt` workflow export as an `agnt-agent` node. `None` = ordinary, locally launchable delegator. | +| `x_agnt` | object | No | Opaque AGNT-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, `assignedWorkflows`, `creditLimit`). Operator never interprets this. | +| `x_openai` | object | No | Opaque OpenAI-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. | +| `unmapped_core` | object | No | Opaque carry for `AgentProfile` shared-core fields Operator cannot model first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an import→export round-trip is lossless. Distinct from `x_agnt`: these are shared-core fields, not AGNT-specific, so folding them into `x_agnt` would corrupt that namespace. Operator never interprets this. | ### DelegatorLaunchConfig @@ -523,6 +528,21 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override. | `prompt_suffix` | `string` \| `null` | No | Prompt text to append after the generated step prompt | | `operator_relay` | `boolean` \| `null` | No | Override global relay auto-inject MCP setting per-delegator (None = use global setting) | +### RemoteAgentRef + +A declarative reference to a remote, named agent hosted by another platform. + +`platform` is the hosting service (`"agnt"`, `"openai"`) — deliberately +distinct from the core `provider`/`llm_tool` (the model or coding CLI). These +agents are API/memory-native and live on the remote side; Operator has no +runtime client for them, so a delegator carrying one is **export-only** and +cannot be launched locally (see the guard in `delegator_resolution`). + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `platform` | `string` | Yes | Hosting platform (e.g. `"agnt"`, `"openai"`). | +| `id` | `string` | Yes | Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). | + ### ModelServer A named host that serves models via an inference API. @@ -538,7 +558,7 @@ in config. | Property | Type | Required | Description | | --- | --- | --- | --- | | `name` | `string` | Yes | Unique name (e.g., "ollama-local", "vllm-gpu1") | -| `kind` | `string` | Yes | Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" | +| `kind` | `string` | Yes | Kind: "ollama", "openrouter", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" | | `base_url` | `string` \| `null` | No | Base URL of the inference endpoint (e.g., `http://localhost:11434`). `None` for implicit vendor servers means use the SDK default. | | `api_key_env` | `string` \| `null` | No | Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`) | | `extra_env` | `object` | No | Additional environment variables set when spawning agents that use this server | diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index 67a0f24f..84503d71 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -13,7 +13,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc ## Schema Information - **$schema**: `http://json-schema.org/draft-07/schema#` -- **$id**: `https://gbqr.us/operator/ticket-metadata.schema.json` +- **$id**: `https://operator.untra.io/schemas/ticket_metadata.schema.json` - **Additional Properties**: Allowed ## Required Fields @@ -30,11 +30,12 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc | `step` | `string` | No | Current workflow step name (e.g., plan, build, code, test, deploy) | | `priority` | `string` | No | Ticket priority level | | `project` | `string` | No | Target project name (subdirectory in projects root) | -| `created` | `string` (date) | No | Creation date in YYYY-MM-DD format (legacy, prefer createdDatetime) | -| `createdDatetime` | `string` (date-time) | No | ISO 8601 datetime when the ticket was created | -| `startedDatetime` | `string` (date-time) | No | ISO 8601 datetime when work began on the ticket (moved to running status) | -| `completedDatetime` | `string` (date-time) | No | ISO 8601 datetime when the ticket was completed | | `branch` | `string` | No | Git branch name for this ticket (auto-generated from type and summary) | +| `worktree_path` | `string` | No | Filesystem path to the git worktree for this ticket (per-ticket isolation) | +| `external_id` | `string` | No | External issue ID from the kanban provider (e.g., PROJ-123 for Jira, ENG-456 for Linear) | +| `external_url` | `string` (uri) | No | Full URL to the issue in the external provider's web UI | +| `external_provider` | `string` | No | Provider name for the external issue (e.g., jira, linear) | +| `step_delegators` | `object` | No | Step name to delegator name mapping. Populated when a step launches; used for bidirectional kanban activity logs. | | `sessions` | `object` | No | Step name to LLM session UUID mapping. Each step gets its own session ID for continuity. | | `llm_task` | `object` | No | LLM task metadata for delegate mode integration | @@ -71,38 +72,44 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc - **Type**: `string` - **Examples**: `gamesvc`, `operator`, `www`, `iac` -### created +### branch -- **Description**: Creation date in YYYY-MM-DD format (legacy, prefer createdDatetime) -- **Type**: `string` (date) -- **Format**: `date` +- **Description**: Git branch name for this ticket (auto-generated from type and summary) +- **Type**: `string` +- **Examples**: `feature/FEAT-1234-add-user-auth`, `fix/FIX-5678-login-timeout` -### createdDatetime +### worktree_path -- **Description**: ISO 8601 datetime when the ticket was created -- **Type**: `string` (date-time) -- **Format**: `date-time` -- **Examples**: `2024-12-25T14:30:00Z`, `2024-12-25T09:15:00-05:00` +- **Description**: Filesystem path to the git worktree for this ticket (per-ticket isolation) +- **Type**: `string` +- **Examples**: `/Users/dev/worktrees/op-FEAT-1234` -### startedDatetime +### external_id -- **Description**: ISO 8601 datetime when work began on the ticket (moved to running status) -- **Type**: `string` (date-time) -- **Format**: `date-time` -- **Examples**: `2024-12-25T15:00:00Z` +- **Description**: External issue ID from the kanban provider (e.g., PROJ-123 for Jira, ENG-456 for Linear) +- **Type**: `string` +- **Examples**: `PROJ-123`, `ENG-456` -### completedDatetime +### external_url -- **Description**: ISO 8601 datetime when the ticket was completed -- **Type**: `string` (date-time) -- **Format**: `date-time` -- **Examples**: `2024-12-25T17:30:00Z` +- **Description**: Full URL to the issue in the external provider's web UI +- **Type**: `string` (uri) +- **Format**: `uri` +- **Examples**: `https://example.atlassian.net/browse/PROJ-123` -### branch +### external_provider -- **Description**: Git branch name for this ticket (auto-generated from type and summary) +- **Description**: Provider name for the external issue (e.g., jira, linear) - **Type**: `string` -- **Examples**: `feature/FEAT-1234-add-user-auth`, `fix/FIX-5678-login-timeout` +- **Examples**: `jira`, `linear` + +### step_delegators + +- **Description**: Step name to delegator name mapping. Populated when a step launches; used for bidirectional kanban activity logs. +- **Type**: `object` +- **Examples**: `{"plan":"claude","build":"claude-code"}` + +**Additional Properties**: `string` (Delegator name used for this step) ### sessions @@ -149,12 +156,18 @@ Complete ticket metadata examples: "step": "build", "priority": "P2-medium", "project": "gamesvc", - "createdDatetime": "2024-12-25T14:30:00Z", - "startedDatetime": "2024-12-25T15:00:00Z", "branch": "feature/FEAT-1234-add-user-auth", + "worktree_path": "/Users/dev/worktrees/op-FEAT-1234", + "external_id": "PROJ-123", + "external_url": "https://example.atlassian.net/browse/PROJ-123", + "external_provider": "jira", "sessions": { "plan": "550e8400-e29b-41d4-a716-446655440000", "build": "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + }, + "step_delegators": { + "plan": "claude", + "build": "claude-code" } } ``` @@ -168,9 +181,6 @@ Complete ticket metadata examples: "step": "summarize", "priority": "P2-medium", "project": "operator", - "createdDatetime": "2024-12-25T09:00:00Z", - "startedDatetime": "2024-12-25T10:00:00Z", - "completedDatetime": "2024-12-25T12:30:00Z", "sessions": { "explore": "abc12345-6789-0abc-def0-123456789abc" }, diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index f2151de0..aff5b61a 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -2,15 +2,15 @@ "openapi": "3.1.0", "info": { "title": "Operator API", - "description": "REST API for managing issue types and collections in the Operator system.", + "description": "REST API for managing and running Operator server system.", "contact": { - "name": "gbqr.us", + "name": "untra", "url": "https://github.com/untra/operator" }, "license": { "name": "MIT" }, - "version": "0.2.0" + "version": "0.2.1" }, "paths": { "/api/v1/agents/active": { @@ -185,6 +185,48 @@ } } }, + "/api/v1/alerts": { + "post": { + "tags": [ + "Tickets" + ], + "summary": "Raise an external alert as an investigation (INV) ticket.", + "description": "Creates an investigation through the same embedded-template path as\n[`create`], folding the alert's `source`/`severity` into the ticket values.\nPowers the AGNT `operator-alert` node.", + "operationId": "alerts_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlertRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Investigation created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlertResponse" + } + } + } + }, + "500": { + "description": "Failed to create investigation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/collections": { "get": { "tags": [ @@ -463,6 +505,41 @@ } } }, + "/api/v1/delegators/import-profile": { + "post": { + "tags": [ + "Delegators" + ], + "summary": "Import an `AgentProfile` as a new delegator.", + "description": "The shared-core fields Operator can't model and the opaque `x_agnt` bag are\npreserved on the created delegator so a later export round-trips losslessly.", + "operationId": "delegators_import_profile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentProfile" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Delegator created from profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DelegatorResponse" + } + } + } + }, + "409": { + "description": "Delegator already exists" + } + } + } + }, "/api/v1/delegators/{name}": { "get": { "tags": [ @@ -574,6 +651,42 @@ } } }, + "/api/v1/delegators/{name}/profile": { + "get": { + "tags": [ + "Delegators" + ], + "summary": "Export a delegator as a portable `AgentProfile` (`agent-profile.json`).", + "description": "The shared core plus the Operator-namespaced `x_operator` bag; any opaque\nAGNT (`x_agnt`) and shared-core carry the delegator holds are restored so the\nprofile is a lossless interchange artifact.", + "operationId": "delegators_export_profile", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Delegator name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Agent profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentProfile" + } + } + } + }, + "404": { + "description": "Delegator not found" + } + } + } + }, "/api/v1/health": { "get": { "tags": [ @@ -1015,6 +1128,15 @@ "schema": { "type": "string" } + }, + { + "name": "format", + "in": "query", + "description": "Output format: claude (default) or agnt", + "required": false, + "schema": { + "$ref": "#/components/schemas/WorkflowFormat" + } } ], "responses": { @@ -1466,6 +1588,42 @@ } } }, + "/api/v1/model-servers/kinds/{slug}/models": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "List the models a *provider kind* offers, via a live probe.", + "description": "Resolves to the declared instance of that kind (if the user has one) else a\ntransient instance built from the kind's probe defaults — so the Model\nProviders catalog can show connection state + live models for every supported\nprovider without first declaring one. `reachable` doubles as \"connected\".", + "operationId": "model_servers_kind_models", + "parameters": [ + { + "name": "slug", + "in": "path", + "description": "Model-provider kind slug (e.g. \"anthropic-api\")", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Models offered by the provider", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerModelsResponse" + } + } + } + }, + "404": { + "description": "Unknown provider kind" + } + } + } + }, "/api/v1/model-servers/{name}": { "get": { "tags": [ @@ -1898,6 +2056,48 @@ } } }, + "/api/v1/tickets": { + "post": { + "tags": [ + "Tickets" + ], + "summary": "Create a new ticket from a template and write it to the queue.", + "description": "Reuses the same [`TicketCreator`] the CLI (`operator create`) and MCP\n(`operator_create_ticket`) use, so a ticket created over HTTP is identical to\none created on any other surface. Powers the AGNT `operator-create-ticket`\nnode.", + "operationId": "tickets_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTicketResponse" + } + } + } + }, + "400": { + "description": "Unknown template type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/tickets/{id}": { "get": { "tags": [ @@ -2110,6 +2310,15 @@ "schema": { "type": "string" } + }, + { + "name": "format", + "in": "query", + "description": "Output format: claude (default) or agnt", + "required": false, + "schema": { + "$ref": "#/components/schemas/WorkflowFormat" + } } ], "responses": { @@ -2358,6 +2567,85 @@ } } }, + "AgentProfile": { + "type": "object", + "description": "A canonical cross-tool agent definition (serialized as `agent-profile.json`).\n\nShared core (`name`/`provider`/`model`/`system_prompt`/`skills`/`mcp_servers`/\n`tools`) plus namespaced extension bags. `x_operator` is typed (Operator owns\nthose fields); `x_agnt` and `x_openai` are opaque per-platform pass-throughs.", + "required": [ + "name", + "provider", + "model" + ], + "properties": { + "mcp_servers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "MCP server names. Preserved opaquely across import." + }, + "model": { + "type": "string", + "description": "Model alias or id (maps to [`Delegator::model`])." + }, + "name": { + "type": "string", + "description": "Unique agent name (maps to [`Delegator::name`])." + }, + "provider": { + "type": "string", + "description": "Inference provider / CLI tool (maps to [`Delegator::llm_tool`])." + }, + "remote_agent": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RemoteAgentRef", + "description": "Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...).\n`None` = a locally launchable agent, not bound to a remote platform." + } + ] + }, + "skills": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Named skills. Preserved opaquely across import." + }, + "system_prompt": { + "type": [ + "string", + "null" + ], + "description": "System prompt. Operator has no first-class system prompt, so this is\npreserved opaquely across import (see [`Delegator::unmapped_core`])." + }, + "tools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names. Preserved opaquely across import." + }, + "x_agnt": { + "description": "AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`,\n`creditLimit`, ...). Operator never interprets this — pure pass-through." + }, + "x_openai": { + "description": "OpenAI-owned extension fields, opaque (`instructions`, `tools`,\n`tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a\nsecond platform — never interpreted. This field is the whole per-tool cost\nof adding `OpenAI`: a passthrough bag, no mapping logic." + }, + "x_operator": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/XOperator", + "description": "Operator-owned extension fields (typed). `None` when the agent carries no\nOperator-specific configuration." + } + ] + } + } + }, "AssessTicketResponse": { "type": "object", "description": "Response from creating an ASSESS ticket", @@ -2408,6 +2696,54 @@ } } }, + "CreateAlertRequest": { + "type": "object", + "description": "Request to raise an external alert as an investigation ticket.", + "required": [ + "source", + "message", + "severity" + ], + "properties": { + "message": { + "type": "string", + "description": "The alert message / summary." + }, + "project": { + "type": [ + "string", + "null" + ], + "description": "Optional project the investigation targets." + }, + "severity": { + "type": "string", + "description": "Severity label (e.g. `S1`, `S2`)." + }, + "source": { + "type": "string", + "description": "Where the alert came from (e.g. `pagerduty`, `sentry`)." + } + } + }, + "CreateAlertResponse": { + "type": "object", + "description": "Response after raising an alert.", + "required": [ + "id", + "filename" + ], + "properties": { + "filename": { + "type": "string", + "description": "The investigation ticket filename written to the queue." + }, + "id": { + "type": "string", + "description": "The created investigation ticket's id." + } + } + }, "CreateDelegatorFromToolRequest": { "type": "object", "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.", @@ -2515,6 +2851,17 @@ "name": { "type": "string", "description": "Unique name for the delegator" + }, + "remote_agent": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RemoteAgentRef", + "description": "Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When\nset, the delegator is export-only and cannot be launched locally." + } + ] } } }, @@ -2713,6 +3060,66 @@ } } }, + "CreateTicketRequest": { + "type": "object", + "description": "Request to create a new ticket from a template.", + "required": [ + "template" + ], + "properties": { + "project": { + "type": [ + "string", + "null" + ], + "description": "Project the ticket targets (filled into the template's `project` value)." + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "One-line summary (filled into the template's `summary` value)." + }, + "template": { + "type": "string", + "description": "Template type key (feature, fix, spike, investigation, task)." + }, + "values": { + "type": "object", + "description": "Additional Handlebars values for the template. Explicit `project`/\n`summary` fields take precedence over the same keys here.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "CreateTicketResponse": { + "type": "object", + "description": "Response after creating a ticket.", + "required": [ + "id", + "filename", + "path" + ], + "properties": { + "filename": { + "type": "string", + "description": "The ticket filename written to the queue." + }, + "id": { + "type": "string", + "description": "The created ticket's id (e.g. `FEAT-1234`)." + }, + "path": { + "type": "string", + "description": "The absolute path the ticket was written to." + } + } + }, "DefaultLlmResponse": { "type": "object", "description": "Response with the current default LLM tool and model", @@ -2731,6 +3138,72 @@ } } }, + "DelegatorLaunchConfig": { + "type": "object", + "description": "Launch configuration for a delegator\n\nControls how the delegator launches agents. Optional fields use tri-state\nsemantics: `None` = inherit from global config, `Some(true/false)` = override.", + "properties": { + "create_branch": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to create a git branch for the ticket (None = default behavior)" + }, + "docker": { + "type": [ + "boolean", + "null" + ], + "description": "Run in docker container (None = use global `launch.docker.enabled`)" + }, + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional CLI flags" + }, + "operator_relay": { + "type": [ + "boolean", + "null" + ], + "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)" + }, + "permission_mode": { + "type": [ + "string", + "null" + ], + "description": "Permission mode override" + }, + "prompt_prefix": { + "type": [ + "string", + "null" + ], + "description": "Prompt text to prepend before the generated step prompt" + }, + "prompt_suffix": { + "type": [ + "string", + "null" + ], + "description": "Prompt text to append after the generated step prompt" + }, + "use_worktrees": { + "type": [ + "boolean", + "null" + ], + "description": "Override global `git.use_worktrees` per-delegator (None = use global setting)" + }, + "yolo": { + "type": "boolean", + "description": "Run in YOLO (auto-accept) mode" + } + } + }, "DelegatorLaunchConfigDto": { "type": "object", "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.", @@ -2853,6 +3326,17 @@ "name": { "type": "string", "description": "Unique name" + }, + "remote_agent": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/RemoteAgentRef", + "description": "Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When\nset, the delegator is export-only and cannot be launched locally." + } + ] } } }, @@ -3134,9 +3618,19 @@ "description": "Health check response", "required": [ "status", - "version" + "version", + "directory_name", + "directory_id" ], "properties": { + "directory_id": { + "type": "string", + "description": "Non-reversible fingerprint of the working root's canonical path." + }, + "directory_name": { + "type": "string", + "description": "Top-level directory name of the operator working root (e.g. \"acme\")." + }, "status": { "type": "string" }, @@ -3943,9 +4437,45 @@ "description", "setup_url", "icon", - "is_builtin" + "is_builtin", + "category", + "category_label", + "connectable" ], "properties": { + "brand_icon": { + "type": [ + "string", + "null" + ], + "description": "Brand-icon basename (e.g. \"ollama\") for surfaces that render vendor\nlogos, or `None` to fall back to the `icon` codicon." + }, + "category": { + "type": "string", + "description": "Provider-class slug grouping kinds within the Model Provider vertical\n(\"first-party\" or \"gateway\"). Distinct from `is_builtin`. (Field name\nkept as `category` for wire/TS stability.)" + }, + "category_label": { + "type": "string", + "description": "Human-friendly provider-class group header (e.g. \"First-party\")" + }, + "connectable": { + "type": "boolean", + "description": "Whether operator can probe this provider from built-in defaults (it has a\n`default_base_url`) without the user first declaring an instance." + }, + "default_api_key_env": { + "type": [ + "string", + "null" + ], + "description": "Default env var the probe reads to authenticate (e.g. `ANTHROPIC_API_KEY`),\nor `None` when no key is needed by default." + }, + "default_base_url": { + "type": [ + "string", + "null" + ], + "description": "Default inference base URL used to probe this provider's models when no\ninstance declares one, or `None` for bring-your-own-endpoint kinds." + }, "description": { "type": "string", "description": "One-line connect blurb" @@ -4425,6 +4955,24 @@ } } }, + "RemoteAgentRef": { + "type": "object", + "description": "A declarative reference to a remote, named agent hosted by another platform.\n\n`platform` is the hosting service (`\"agnt\"`, `\"openai\"`) — deliberately\ndistinct from the core `provider`/`llm_tool` (the model or coding CLI). These\nagents are API/memory-native and live on the remote side; Operator has no\nruntime client for them, so a delegator carrying one is **export-only** and\ncannot be launched locally (see the guard in `delegator_resolution`).", + "required": [ + "platform", + "id" + ], + "properties": { + "id": { + "type": "string", + "description": "Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id)." + }, + "platform": { + "type": "string", + "description": "Hosting platform (e.g. `\"agnt\"`, `\"openai\"`)." + } + } + }, "ReviewResponse": { "type": "object", "description": "Response for agent review operations (approve/reject)", @@ -4532,6 +5080,13 @@ }, "description": "Browser-openable actions for this row (links shown in the web UI)." }, + "brand_icon": { + "type": [ + "string", + "null" + ], + "description": "Optional vendor-brand basename (e.g. \"ollama\"). When set, the web UI\nrenders `/icons/{brand_icon}.svg` instead of the semantic `icon`." + }, "depth": { "type": "integer", "format": "int32", @@ -4695,6 +5250,8 @@ "required": [ "status", "version", + "directory_name", + "directory_id", "issuetype_count", "collection_count", "active_collection" @@ -4707,6 +5264,14 @@ "type": "integer", "minimum": 0 }, + "directory_id": { + "type": "string", + "description": "Non-reversible fingerprint of the working root's canonical path." + }, + "directory_name": { + "type": "string", + "description": "Top-level directory name of the operator working root (e.g. \"acme\")." + }, "issuetype_count": { "type": "integer", "minimum": 0 @@ -5417,6 +5982,14 @@ } } }, + "WorkflowFormat": { + "type": "string", + "description": "Which orchestration format `workflow export` should emit.", + "enum": [ + "claude", + "agnt" + ] + }, "WorkflowPreviewResponse": { "type": "object", "description": "Response for a *preview* workflow generated from an issue type alone (no\nconcrete ticket). Used by the UI to visualize an issue type's workflow shape.", @@ -5579,6 +6152,47 @@ "type": "string" } } + }, + "XOperator": { + "type": "object", + "description": "The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator\ncarries that have no shared-core equivalent. AGNT ignores this bag; Operator\nround-trips it losslessly.", + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI." + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfig", + "description": "Launch configuration (permission mode, flags, worktree/docker, prompt\nwrapping, ...)." + } + ] + }, + "model_properties": { + "type": "object", + "description": "Arbitrary model properties (e.g. `reasoning_effort`, sandbox).", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer` (`None` = implicit vendor default)." + } + } } } }, diff --git a/docs/schemas/operator_output.json b/docs/schemas/operator_output.json index 457429a8..4c8df388 100644 --- a/docs/schemas/operator_output.json +++ b/docs/schemas/operator_output.json @@ -94,6 +94,6 @@ "status", "exit_signal" ], - "$id": "https://operator.dev/schemas/operator_output.schema.json", + "$id": "https://operator.untra.io/schemas/operator_output.json", "$comment": "AUTO-GENERATED FROM src/rest/dto.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only operator-output-schema" } \ No newline at end of file diff --git a/docs/schemas/project_analysis.json b/docs/schemas/project_analysis.json index a983e695..3566d2a4 100644 --- a/docs/schemas/project_analysis.json +++ b/docs/schemas/project_analysis.json @@ -914,6 +914,6 @@ ] } }, - "$id": "https://gbqr.us/operator/project-analysis.schema.json", + "$id": "https://operator.untra.io/schemas/project_analysis.json", "$comment": "AUTO-GENERATED FROM src/taxonomy/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema" } \ No newline at end of file diff --git a/icons/anthropic.svg b/icons/anthropic.svg new file mode 100644 index 00000000..c917480d --- /dev/null +++ b/icons/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/icons/google.svg b/icons/google.svg new file mode 100644 index 00000000..2eaf9155 --- /dev/null +++ b/icons/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/icons/ollama.svg b/icons/ollama.svg new file mode 100644 index 00000000..bc368e99 --- /dev/null +++ b/icons/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/icons/openrouter.svg b/icons/openrouter.svg new file mode 100644 index 00000000..83ec807b --- /dev/null +++ b/icons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index cae3db4f..3f9d3728 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -1244,7 +1244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1478,9 +1478,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", diff --git a/shared/types.ts b/shared/types.ts index 5c8a29d2..af3a1fa7 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -405,6 +405,12 @@ export type RestApiConfig = { * Whether the REST API is enabled */ enabled: boolean, +/** + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so + * the server — which reports the project directory name — is not reachable + * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + */ +host: string, /** * Port for the REST API server */ @@ -439,7 +445,7 @@ default_model: string | null, /** * Per-tool overrides for skill directories (keyed by `tool_name`) */ -skill_directory_overrides: { [key in string]?: SkillDirectoriesOverride }, }; +skill_directory_overrides: { [key in string]: SkillDirectoriesOverride }, }; export type DetectedTool = { /** @@ -509,7 +515,7 @@ flags: Array, /** * Environment variables to set when launching */ -env: { [key in string]?: string }, +env: { [key in string]: string }, /** * Whether this provider requires approval gates */ @@ -557,7 +563,7 @@ display_name: string | null, /** * Arbitrary model properties (e.g., `reasoning_effort`, sandbox) */ -model_properties: { [key in string]?: string }, +model_properties: { [key in string]: string }, /** * Optional launch configuration */ @@ -567,7 +573,39 @@ launch_config: DelegatorLaunchConfig | null, * `None` means use the `llm_tool`'s implicit vendor default * (claude → anthropic-api, codex → openai-api, gemini → google-api). */ -model_server: string | null, }; +model_server: string | null, +/** + * Declarative reference to a remote, named agent on another platform + * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). + * + * Export-only: Operator has no runtime client for those platforms, so a + * delegator carrying this CANNOT be launched locally — resolution errors out + * (see `delegator_resolution`). It is stored, listed, serialized into an + * `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + * `--format agnt` workflow export as an `agnt-agent` node. `None` = ordinary, + * locally launchable delegator. + */ +remote_agent?: RemoteAgentRef | null, +/** + * Opaque AGNT-namespaced extension fields, preserved verbatim across an + * `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, + * `assignedWorkflows`, `creditLimit`). Operator never interprets this. + */ +x_agnt?: JsonValue | null, +/** + * Opaque OpenAI-namespaced extension fields, preserved verbatim across an + * `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, + * `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. + */ +x_openai?: JsonValue | null, +/** + * Opaque carry for `AgentProfile` shared-core fields Operator cannot model + * first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an + * import→export round-trip is lossless. Distinct from `x_agnt`: these are + * shared-core fields, not AGNT-specific, so folding them into `x_agnt` would + * corrupt that namespace. Operator never interprets this. + */ +unmapped_core?: JsonValue | null, }; export type DelegatorLaunchConfig = { /** @@ -607,6 +645,88 @@ prompt_suffix: string | null, */ operator_relay: boolean | null, }; +export type AgentProfile = { +/** + * Unique agent name (maps to [`Delegator::name`]). + */ +name: string, +/** + * Inference provider / CLI tool (maps to [`Delegator::llm_tool`]). + */ +provider: string, +/** + * Model alias or id (maps to [`Delegator::model`]). + */ +model: string, +/** + * System prompt. Operator has no first-class system prompt, so this is + * preserved opaquely across import (see [`Delegator::unmapped_core`]). + */ +system_prompt?: string | null, +/** + * Named skills. Preserved opaquely across import. + */ +skills: Array, +/** + * MCP server names. Preserved opaquely across import. + */ +mcp_servers: Array, +/** + * Tool names. Preserved opaquely across import. + */ +tools: Array, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). + * `None` = a locally launchable agent, not bound to a remote platform. + */ +remote_agent?: RemoteAgentRef | null, +/** + * Operator-owned extension fields (typed). `None` when the agent carries no + * Operator-specific configuration. + */ +x_operator?: XOperator | null, +/** + * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, + * `creditLimit`, ...). Operator never interprets this — pure pass-through. + */ +x_agnt?: JsonValue | null, +/** + * OpenAI-owned extension fields, opaque (`instructions`, `tools`, + * `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a + * second platform — never interpreted. This field is the whole per-tool cost + * of adding `OpenAI`: a passthrough bag, no mapping logic. + */ +x_openai?: JsonValue | null, }; + +export type XOperator = { +/** + * Optional display name for UI. + */ +display_name?: string | null, +/** + * Arbitrary model properties (e.g. `reasoning_effort`, sandbox). + */ +model_properties?: { [key in string]: string }, +/** + * Name of a declared `ModelServer` (`None` = implicit vendor default). + */ +model_server?: string | null, +/** + * Launch configuration (permission mode, flags, worktree/docker, prompt + * wrapping, ...). + */ +launch_config?: DelegatorLaunchConfig | null, }; + +export type RemoteAgentRef = { +/** + * Hosting platform (e.g. `"agnt"`, `"openai"`). + */ +platform: string, +/** + * Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). + */ +id: string, }; + export type CollectionPreset = "simple" | "dev_kanban" | "devops_kanban" | "custom"; export type TemplatesConfig = { @@ -654,11 +774,11 @@ export type State = { paused: boolean, agents: Array, completed: Arr /** * Per-project LLM usage statistics */ -project_llm_stats: { [key in string]?: ProjectLlmStats }, +project_llm_stats: { [key in string]: ProjectLlmStats }, /** * Per-project issue type collection preferences (`project_name` -> `collection_name`) */ -project_collection_prefs: { [key in string]?: string }, +project_collection_prefs: { [key in string]: string }, /** * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) */ @@ -781,9 +901,25 @@ review_type: string | null, next_step: string | null, permission_mode: string | export type CollectionResponse = { name: string, description: string, types: Array, is_active: boolean, }; -export type HealthResponse = { status: string, version: string, }; +export type HealthResponse = { status: string, version: string, +/** + * Top-level directory name of the operator working root (e.g. "acme"). + */ +directory_name: string, +/** + * Non-reversible fingerprint of the working root's canonical path. + */ +directory_id: string, }; -export type StatusResponse = { status: string, version: string, issuetype_count: number, collection_count: number, active_collection: string, }; +export type StatusResponse = { status: string, version: string, +/** + * Top-level directory name of the operator working root (e.g. "acme"). + */ +directory_name: string, +/** + * Non-reversible fingerprint of the working root's canonical path. + */ +directory_id: string, issuetype_count: number, collection_count: number, active_collection: string, }; export type SectionDto = { /** @@ -821,6 +957,11 @@ depth: number, label: string, description: string, * Icon hint (e.g. "check", "warning", "tool", "folder"). */ icon: string, +/** + * Optional vendor-brand basename (e.g. "ollama"). When set, the web UI + * renders `/icons/{brand_icon}.svg` instead of the semantic `icon`. + */ +brand_icon: string | null, /** * Health: "green" | "yellow" | "red" | "gray". */ @@ -874,6 +1015,81 @@ suggested_filename: string, */ contents: string, }; +export type WorkflowPreviewResponse = { +/** + * The issue type key the preview was generated from. + */ +issuetype_key: string, +/** + * Suggested filename for saving the preview (`.preview.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source (placeholder ticket values). + */ +contents: string, }; + +export type CreateTicketRequest = { +/** + * Template type key (feature, fix, spike, investigation, task). + */ +template: string, +/** + * Project the ticket targets (filled into the template's `project` value). + */ +project: string | null, +/** + * One-line summary (filled into the template's `summary` value). + */ +summary: string | null, +/** + * Additional Handlebars values for the template. Explicit `project`/ + * `summary` fields take precedence over the same keys here. + */ +values: { [key in string]: string }, }; + +export type CreateTicketResponse = { +/** + * The created ticket's id (e.g. `FEAT-1234`). + */ +id: string, +/** + * The ticket filename written to the queue. + */ +filename: string, +/** + * The absolute path the ticket was written to. + */ +path: string, }; + +export type CreateAlertRequest = { +/** + * Where the alert came from (e.g. `pagerduty`, `sentry`). + */ +source: string, +/** + * The alert message / summary. + */ +message: string, +/** + * Severity label (e.g. `S1`, `S2`). + */ +severity: string, +/** + * Optional project the investigation targets. + */ +project: string | null, }; + +export type CreateAlertResponse = { +/** + * The created investigation ticket's id. + */ +id: string, +/** + * The investigation ticket filename written to the queue. + */ +filename: string, }; + export type SkillEntry = { /** * Tool this skill belongs to (e.g., "claude", "codex") @@ -922,7 +1138,7 @@ display_name: string | null, /** * Arbitrary model properties */ -model_properties: { [key in string]?: string }, +model_properties: { [key in string]: string }, /** * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ @@ -930,7 +1146,12 @@ model_server: string | null, /** * Optional launch configuration */ -launch_config: DelegatorLaunchConfigDto | null, }; +launch_config: DelegatorLaunchConfigDto | null, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + * set, the delegator is export-only and cannot be launched locally. + */ +remote_agent: RemoteAgentRef | null, }; export type DelegatorsResponse = { /** @@ -962,7 +1183,7 @@ display_name: string | null, /** * Arbitrary model properties */ -model_properties: { [key in string]?: string }, +model_properties: { [key in string]: string }, /** * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ @@ -970,7 +1191,12 @@ model_server: string | null, /** * Optional launch configuration */ -launch_config: DelegatorLaunchConfigDto | null, }; +launch_config: DelegatorLaunchConfigDto | null, +/** + * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + * set, the delegator is export-only and cannot be launched locally. + */ +remote_agent: RemoteAgentRef | null, }; export type DelegatorLaunchConfigDto = { /** @@ -980,7 +1206,7 @@ yolo: boolean, /** * Permission mode override */ -permission_mode: string | null, +permission_mode?: string | null, /** * Additional CLI flags */ @@ -988,41 +1214,41 @@ flags: Array, /** * Override global `git.use_worktrees` (None = use global setting) */ -use_worktrees: boolean | null, +use_worktrees?: boolean | null, /** * Whether to create a git branch for the ticket (None = default behavior) */ -create_branch: boolean | null, +create_branch?: boolean | null, /** * Run in docker container (None = use global `launch.docker.enabled`) */ -docker: boolean | null, +docker?: boolean | null, /** * Prompt text to prepend before the generated step prompt */ -prompt_prefix: string | null, +prompt_prefix?: string | null, /** * Prompt text to append after the generated step prompt */ -prompt_suffix: string | null, +prompt_suffix?: string | null, /** * Override global relay auto-inject MCP setting per-delegator (None = use global setting) */ -operator_relay: boolean | null, }; +operator_relay?: boolean | null, }; export type LlmTask = { /** * LLM task ID (e.g., Claude delegate mode task UUID) */ -id: string | null, +id?: string | null, /** * LLM task status: "open" or "resolved" */ -status: string | null, +status?: string | null, /** * List of task IDs that must resolve before this task */ -blocked_by: Array, }; +blocked_by?: Array, }; export type JiraSearchResponse = { /** @@ -1206,7 +1432,7 @@ workingDir?: string, /** * Environment variables to set */ -env?: { [key in string]?: string }, }; +env?: { [key in string]: string }, }; export type VsCodeSendCommandRequest = { /** @@ -1320,7 +1546,7 @@ project: string, /** * Session UUIDs by step name */ -sessions?: { [key in string]?: string }, +sessions?: { [key in string]: string }, /** * Git worktree path if using per-ticket worktrees */ diff --git a/src/agents/activity.rs b/src/agents/activity.rs index 958b56f9..376a28c4 100644 --- a/src/agents/activity.rs +++ b/src/agents/activity.rs @@ -118,7 +118,7 @@ impl TmuxActivityDetector { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) + crate::agents::hex_encode(&hasher.finalize()) } } @@ -225,7 +225,7 @@ impl CmuxActivityDetector { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) + crate::agents::hex_encode(&hasher.finalize()) } fn get_workspace_ref(&self, session_id: &str) -> Option { @@ -329,7 +329,7 @@ impl ZellijActivityDetector { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) + crate::agents::hex_encode(&hasher.finalize()) } fn get_tab_name(&self, session_id: &str) -> Option { diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index 6b1aae54..b7fa6e49 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -341,6 +341,10 @@ mod tests { model_properties: HashMap::default(), model_server: None, launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, } } diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 94aefb78..2d0542a1 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -21,7 +21,7 @@ pub struct AgentContext { /// Error type for delegator resolution failures. #[derive(Debug, thiserror::Error)] -#[allow(clippy::enum_variant_names)] // All failures are "unknown reference to X"; prefix is semantic. +#[allow(clippy::enum_variant_names)] // The `Unknown*` variants share a semantic prefix; not a naming smell. pub enum ResolutionError { #[error("Unknown delegator '{0}'")] UnknownDelegator(String), @@ -29,6 +29,16 @@ pub enum ResolutionError { UnknownProvider(String), #[error("Unknown model_server '{0}'")] UnknownModelServer(String), + /// The delegator declaratively references a remote, named agent on another + /// platform (AGNT, `OpenAI`, ...). Operator has no runtime client for those + /// platforms, so such a delegator is export-only and cannot be resolved into a + /// launchable provider. + #[error("Delegator '{name}' references {platform} agent '{agent_id}' and is export-only; it cannot be launched locally (Operator has no {platform} runtime client)")] + RemoteOnlyDelegator { + name: String, + platform: String, + agent_id: String, + }, } /// Resolve a delegator's `ModelServer`: named lookup if set, else implicit vendor default. @@ -58,6 +68,18 @@ pub(crate) fn delegator_to_provider( config: &Config, d: &Delegator, ) -> Result { + // A delegator that declaratively references a remote agent (AGNT, OpenAI, ...) + // is export-only: Operator cannot spawn it (no runtime client for those + // platforms). Guard at the single resolution choke point so every local-launch + // path (explicit, step-agent, issuetype-agent, default, and the multi-agent + // fan-out) is covered, for every platform. + if let Some(r) = &d.remote_agent { + return Err(ResolutionError::RemoteOnlyDelegator { + name: d.name.clone(), + platform: r.platform.clone(), + agent_id: r.id.clone(), + }); + } let server = resolve_model_server_for_delegator(config, d)?; let env = crate::api::providers::model_server::env_for_server(&server); Ok(LlmProvider { @@ -293,6 +315,10 @@ mod tests { model_properties: std::collections::HashMap::new(), model_server: None, launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, } } @@ -469,6 +495,10 @@ mod tests { prompt_suffix: Some("SUFFIX".to_string()), operator_relay: None, }), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); let options = @@ -482,6 +512,84 @@ mod tests { assert_eq!(options.prompt_suffix.as_deref(), Some("SUFFIX")); } + #[test] + fn resolve_remote_only_delegator_errors() { + let mut config = Config::default(); + let mut d = make_delegator("agnt-researcher", "anthropic", "claude-3-5-sonnet"); + d.remote_agent = Some(crate::config::RemoteAgentRef { + platform: "agnt".to_string(), + id: "Research Assistant".to_string(), + }); + config.delegators.push(d); + + let err = resolve_launch_options( + &config, + Some("agnt-researcher"), + None, + None, + None, + false, + None, + ) + .unwrap_err(); + assert!( + matches!(err, ResolutionError::RemoteOnlyDelegator { .. }), + "explicit remote-only delegator must error, got {err:?}" + ); + } + + #[test] + fn resolve_remote_only_is_platform_agnostic() { + // The guard fires for any platform, not just AGNT — an OpenAI Assistant + // delegator is equally export-only. Proves the generalization. + let mut config = Config::default(); + let mut d = make_delegator("openai-reviewer", "openai", "gpt-4o"); + d.remote_agent = Some(crate::config::RemoteAgentRef { + platform: "openai".to_string(), + id: "asst_abc123".to_string(), + }); + config.delegators.push(d); + + let err = resolve_launch_options( + &config, + Some("openai-reviewer"), + None, + None, + None, + false, + None, + ) + .unwrap_err(); + match err { + ResolutionError::RemoteOnlyDelegator { platform, .. } => assert_eq!(platform, "openai"), + other => panic!("expected RemoteOnlyDelegator for openai, got {other:?}"), + } + } + + #[test] + fn resolve_remote_only_step_agent_errors() { + // The guard sits in the single resolution choke point, so the step-agent + // path errors too — proving the export-only contract holds on every path. + let mut config = Config::default(); + let mut d = make_delegator("agnt-researcher", "anthropic", "claude-3-5-sonnet"); + d.remote_agent = Some(crate::config::RemoteAgentRef { + platform: "agnt".to_string(), + id: "Research Assistant".to_string(), + }); + config.delegators.push(d); + + let ctx = AgentContext { + step_agent: Some("agnt-researcher".to_string()), + issuetype_agent: None, + }; + let err = + resolve_launch_options(&config, None, None, None, None, false, Some(&ctx)).unwrap_err(); + assert!( + matches!(err, ResolutionError::RemoteOnlyDelegator { .. }), + "step-agent remote-only delegator must error, got {err:?}" + ); + } + #[test] fn test_resolve_yolo_passthrough() { let config = Config::default(); diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 78c27869..ef674c6e 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -1497,6 +1497,10 @@ fn add_delegators(config: &mut Config, names: &[&str]) { model_properties: std::collections::HashMap::new(), model_server: None, launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); } } diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 7f2e4a6c..cf908ccc 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -67,6 +67,18 @@ pub use cmux::{ CmuxClient, CmuxError, CmuxWindow, CmuxWorkspace, CmuxWrapper, MockCmuxClient, SystemCmuxClient, }; +/// Lowercase-hex encode bytes (e.g. a SHA-256 digest) without pulling in a +/// dedicated hex crate. Used for content-change detection hashes. +pub(crate) fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut acc, byte| { + let _ = write!(acc, "{byte:02x}"); + acc + }) +} + pub use tmux_config::{generate_status_script, generate_tmux_conf}; // Zellij implementation diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index 2dc9244d..be4599d0 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -532,7 +532,7 @@ impl SessionMonitor { fn hash_content(content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) + crate::agents::hex_encode(&hasher.finalize()) } #[cfg(test)] diff --git a/src/agents/sync.rs b/src/agents/sync.rs index d4a00448..27a05c3e 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -683,7 +683,7 @@ impl TicketSessionSync { if let Ok(content) = self.tmux.capture_pane(session_name, false) { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - let new_hash = format!("{:x}", hasher.finalize()); + let new_hash = crate::agents::hex_encode(&hasher.finalize()); // Compare with stored hash let content_changed = agent.content_hash.as_ref() != Some(&new_hash); @@ -1137,7 +1137,7 @@ mod tests { // Calculate hash of OLD content (different from what's in the session) let mut old_hasher = Sha256::new(); old_hasher.update(b"Old content"); - let old_hash = format!("{:x}", old_hasher.finalize()); + let old_hash = crate::agents::hex_encode(&old_hasher.finalize()); // Add an awaiting agent with old content hash let agent_id = state @@ -1193,7 +1193,7 @@ mod tests { let content = mock.capture_pane("op-FEAT-unchanged", false).unwrap(); let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - let same_hash = format!("{:x}", hasher.finalize()); + let same_hash = crate::agents::hex_encode(&hasher.finalize()); let sync = TicketSessionSync::new(&config, Arc::clone(&mock) as Arc); let mut state = State::load(&config).unwrap(); diff --git a/src/api/providers/model_server/mod.rs b/src/api/providers/model_server/mod.rs index 65975afb..ae6c711f 100644 --- a/src/api/providers/model_server/mod.rs +++ b/src/api/providers/model_server/mod.rs @@ -58,6 +58,47 @@ pub fn env_for_server(server: &ModelServer) -> HashMap { env } +/// The sub-class of the *Model Provider* vertical a [`ModelServerKind`] belongs +/// to. Every kind is a model provider; this groups them by *who makes the +/// models*. +/// +/// Single source of truth for the grouping every surface renders (README +/// badges, docs nav, the REST `/kinds` catalog, the web Model Providers view, +/// and the VS Code section). +/// +/// Distinct from [`ModelServerKind::is_builtin`] — `is_builtin` governs +/// delete-protection / the zero-config implicit default, whereas +/// `provider_class` is about *first-party vendor* vs *gateway/host*. They happen +/// to partition the same way today, but they answer different questions, so both +/// are kept. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelProviderClass { + /// A company that produces its own models, served from its own API + /// (Anthropic, `OpenAI`, Google). + FirstParty, + /// A gateway or host that fronts other parties' models behind one endpoint + /// (`OpenRouter`, ollama, any `OpenAI`-compatible server, LM Studio). + Gateway, +} + +impl ModelProviderClass { + /// Stable wire slug, carried on the REST `/kinds` catalog entry. + pub fn slug(&self) -> &'static str { + match self { + ModelProviderClass::FirstParty => "first-party", + ModelProviderClass::Gateway => "gateway", + } + } + + /// Human-friendly group header shown in catalog UIs. + pub fn display_name(&self) -> &'static str { + match self { + ModelProviderClass::FirstParty => "First-party", + ModelProviderClass::Gateway => "Gateways", + } + } +} + /// A model-server protocol kind. /// /// `OpenAiCompat` is the explicit catch-all for any OpenAI-API-compatible server @@ -69,6 +110,7 @@ pub enum ModelServerKind { OpenAiApi, GoogleApi, Ollama, + OpenRouter, OpenAiCompat, LmStudio, } @@ -77,15 +119,30 @@ impl ModelServerKind { /// The canonical list of supported model-server kinds, in display order. /// /// Single source of truth — every surface derives its catalog from here. - pub const ALL: [ModelServerKind; 6] = [ + pub const ALL: [ModelServerKind; 7] = [ ModelServerKind::AnthropicApi, ModelServerKind::OpenAiApi, ModelServerKind::GoogleApi, ModelServerKind::Ollama, + ModelServerKind::OpenRouter, ModelServerKind::OpenAiCompat, ModelServerKind::LmStudio, ]; + /// Which sub-class of the Model Provider vertical this kind belongs to — + /// drives the grouping across every surface. + pub fn provider_class(&self) -> ModelProviderClass { + match self { + ModelServerKind::AnthropicApi + | ModelServerKind::OpenAiApi + | ModelServerKind::GoogleApi => ModelProviderClass::FirstParty, + ModelServerKind::Ollama + | ModelServerKind::OpenRouter + | ModelServerKind::OpenAiCompat + | ModelServerKind::LmStudio => ModelProviderClass::Gateway, + } + } + /// Stable wire slug — matches the `kind` string stored on /// [`crate::config::ModelServer`] and used in config, the REST catalog, and /// the `ConfigureModelServer` action. @@ -95,6 +152,7 @@ impl ModelServerKind { ModelServerKind::OpenAiApi => "openai-api", ModelServerKind::GoogleApi => "google-api", ModelServerKind::Ollama => "ollama", + ModelServerKind::OpenRouter => "openrouter", ModelServerKind::OpenAiCompat => "openai-compat", ModelServerKind::LmStudio => "lmstudio", } @@ -112,6 +170,7 @@ impl ModelServerKind { ModelServerKind::OpenAiApi => "OpenAI API", ModelServerKind::GoogleApi => "Google Gemini API", ModelServerKind::Ollama => "Ollama", + ModelServerKind::OpenRouter => "OpenRouter", ModelServerKind::OpenAiCompat => "OpenAI-compatible", ModelServerKind::LmStudio => "LM Studio", } @@ -134,6 +193,9 @@ impl ModelServerKind { ModelServerKind::OpenAiApi => "OpenAI or a compatible proxy", ModelServerKind::GoogleApi => "Google Gemini API", ModelServerKind::Ollama => "Local ollama server (ollama serve)", + ModelServerKind::OpenRouter => { + "Hosted gateway to 300+ models (one OpenAI-compatible key)" + } ModelServerKind::OpenAiCompat => "Any OpenAI-compatible server (vllm, groq, …)", ModelServerKind::LmStudio => "LM Studio's local server", } @@ -146,6 +208,7 @@ impl ModelServerKind { ModelServerKind::OpenAiApi => "https://platform.openai.com/api-keys", ModelServerKind::GoogleApi => "https://aistudio.google.com/app/apikey", ModelServerKind::Ollama => "https://ollama.com/download", + ModelServerKind::OpenRouter => "https://openrouter.ai/keys", ModelServerKind::OpenAiCompat => { "https://operator.untra.io/getting-started/model-servers/" } @@ -156,15 +219,37 @@ impl ModelServerKind { /// Codicon hint (rendered as `$(icon)` in VS Code, `codicon-{icon}` on web). pub fn icon(&self) -> &'static str { match self { + // Hosted endpoints (vendor APIs + the OpenRouter gateway). ModelServerKind::AnthropicApi | ModelServerKind::OpenAiApi - | ModelServerKind::GoogleApi => "cloud", + | ModelServerKind::GoogleApi + | ModelServerKind::OpenRouter => "cloud", + // Self-hosted / local servers. ModelServerKind::Ollama | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => { "server" } } } + /// Brand-icon basename for surfaces that render vendor logos, or `None` to + /// fall back to the semantic [`icon`](Self::icon) codicon. + /// + /// One basename feeds every surface: docs map it to + /// `/assets/icons/{b}.svg`, VS Code to the `operator-{b}` `ThemeIcon`, and the + /// web UI to `/icons/{b}.svg` — so the brand set can't drift between them. + /// `openai-api` deliberately stays on a codicon (no first-party logo asset). + pub fn brand_icon(&self) -> Option<&'static str> { + match self { + ModelServerKind::AnthropicApi => Some("anthropic"), + ModelServerKind::GoogleApi => Some("google"), + ModelServerKind::Ollama => Some("ollama"), + ModelServerKind::OpenRouter => Some("openrouter"), + ModelServerKind::OpenAiApi + | ModelServerKind::OpenAiCompat + | ModelServerKind::LmStudio => None, + } + } + /// Path appended to a server's `base_url` to list the models it serves. /// /// The protocol determines the shape of the response — see @@ -174,6 +259,9 @@ impl ModelServerKind { match self { // ollama's native tag list ModelServerKind::Ollama => "/api/tags", + // OpenRouter's documented base already ends in `/api/v1`, so the + // model list is just `/models` relative to it. + ModelServerKind::OpenRouter => "/models", // OpenAI-protocol model list ModelServerKind::OpenAiApi | ModelServerKind::OpenAiCompat @@ -197,6 +285,7 @@ impl ModelServerKind { ModelServerKind::OpenAiApi | ModelServerKind::OpenAiCompat | ModelServerKind::Ollama + | ModelServerKind::OpenRouter | ModelServerKind::LmStudio => "OPENAI_BASE_URL", ModelServerKind::GoogleApi => "GOOGLE_GEMINI_BASE_URL", } @@ -209,10 +298,58 @@ impl ModelServerKind { ModelServerKind::OpenAiApi | ModelServerKind::OpenAiCompat | ModelServerKind::Ollama + | ModelServerKind::OpenRouter | ModelServerKind::LmStudio => "OPENAI_API_KEY", ModelServerKind::GoogleApi => "GEMINI_API_KEY", } } + + /// The default inference base URL used to **probe** a provider for its live + /// model list when no instance declares one. `None` means the provider must + /// be declared with an explicit `base_url` before it can be probed + /// (`openai-compat` / `lmstudio` are bring-your-own-endpoint). + /// + /// Probe-only: this is **never** injected into the agent spawn environment — + /// see [`env_for_server`], which only exports a `base_url` a server sets + /// explicitly, preserving the vendor-default / OAuth launch path for the + /// implicit builtins. + pub fn default_base_url(&self) -> Option<&'static str> { + match self { + ModelServerKind::AnthropicApi => Some("https://api.anthropic.com"), + ModelServerKind::OpenAiApi => Some("https://api.openai.com"), + ModelServerKind::GoogleApi => Some("https://generativelanguage.googleapis.com"), + ModelServerKind::OpenRouter => Some("https://openrouter.ai/api/v1"), + ModelServerKind::Ollama => Some("http://localhost:11434"), + ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => None, + } + } + + /// The default env var the **probe** reads to authenticate when an instance + /// doesn't name one. `None` means no key is needed by default (a local + /// ollama server) or the provider must declare its own. + /// + /// Distinct from [`api_key_env_var`](Self::api_key_env_var), which is the + /// canonical var the *agent CLI* reads at spawn — this is the var the + /// *operator probe* reads from its own environment to list models. + pub fn default_api_key_env(&self) -> Option<&'static str> { + match self { + ModelServerKind::AnthropicApi => Some("ANTHROPIC_API_KEY"), + ModelServerKind::OpenAiApi => Some("OPENAI_API_KEY"), + ModelServerKind::GoogleApi => Some("GEMINI_API_KEY"), + ModelServerKind::OpenRouter => Some("OPENROUTER_API_KEY"), + ModelServerKind::Ollama | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => { + None + } + } + } + + /// Whether operator can probe this provider's models from built-in defaults + /// (i.e. it has a [`default_base_url`](Self::default_base_url)) without the + /// user first declaring an instance. `false` for bring-your-own-endpoint + /// kinds that need an explicit `base_url`. + pub fn connectable_from_defaults(&self) -> bool { + self.default_base_url().is_some() + } } #[cfg(test)] @@ -244,6 +381,93 @@ mod tests { assert_eq!(ModelServerKind::from_slug("nope"), None); } + #[test] + fn test_openrouter_is_an_openai_protocol_model_provider() { + let k = ModelServerKind::OpenRouter; + assert_eq!(k.slug(), "openrouter"); + assert_eq!(k.provider_class(), ModelProviderClass::Gateway); + assert!(!k.is_builtin()); + // Speaks the OpenAI protocol, so codex can target it directly. + assert_eq!(k.base_url_env_var(), "OPENAI_BASE_URL"); + assert_eq!(k.api_key_env_var(), "OPENAI_API_KEY"); + // Its documented base already ends in `/api/v1`. + assert_eq!(k.models_endpoint(), "/models"); + } + + #[test] + fn test_brand_icon_basenames() { + assert_eq!( + ModelServerKind::AnthropicApi.brand_icon(), + Some("anthropic") + ); + assert_eq!(ModelServerKind::GoogleApi.brand_icon(), Some("google")); + assert_eq!(ModelServerKind::Ollama.brand_icon(), Some("ollama")); + assert_eq!(ModelServerKind::OpenRouter.brand_icon(), Some("openrouter")); + // OpenAI API + the generic OpenAI-compatible hosts use codicons. + assert_eq!(ModelServerKind::OpenAiApi.brand_icon(), None); + assert_eq!(ModelServerKind::OpenAiCompat.brand_icon(), None); + // Whatever a kind returns, it's never an empty string. + for kind in ModelServerKind::ALL { + assert!(kind.brand_icon().is_none_or(|b| !b.is_empty())); + } + } + + #[test] + fn test_provider_class_partitions_all_kinds() { + // Every kind has a provider class, and the two classes are a disjoint + // cover of ALL. + let first_party: Vec<_> = ModelServerKind::ALL + .into_iter() + .filter(|k| k.provider_class() == ModelProviderClass::FirstParty) + .map(|k| k.slug()) + .collect(); + let gateways: Vec<_> = ModelServerKind::ALL + .into_iter() + .filter(|k| k.provider_class() == ModelProviderClass::Gateway) + .map(|k| k.slug()) + .collect(); + assert_eq!( + first_party, + vec!["anthropic-api", "openai-api", "google-api"] + ); + assert_eq!( + gateways, + vec!["ollama", "openrouter", "openai-compat", "lmstudio"] + ); + assert_eq!( + first_party.len() + gateways.len(), + ModelServerKind::ALL.len() + ); + } + + #[test] + fn test_default_base_url_and_key_env_for_first_party() { + assert_eq!( + ModelServerKind::AnthropicApi.default_base_url(), + Some("https://api.anthropic.com") + ); + assert_eq!( + ModelServerKind::OpenAiApi.default_base_url(), + Some("https://api.openai.com") + ); + assert_eq!( + ModelServerKind::GoogleApi.default_base_url(), + Some("https://generativelanguage.googleapis.com") + ); + assert_eq!( + ModelServerKind::AnthropicApi.default_api_key_env(), + Some("ANTHROPIC_API_KEY") + ); + assert_eq!( + ModelServerKind::GoogleApi.default_api_key_env(), + Some("GEMINI_API_KEY") + ); + // Bring-your-own-endpoint kinds have no probe defaults. + assert_eq!(ModelServerKind::OpenAiCompat.default_base_url(), None); + assert!(!ModelServerKind::LmStudio.connectable_from_defaults()); + assert!(ModelServerKind::AnthropicApi.connectable_from_defaults()); + } + #[test] fn test_builtins_are_the_three_vendor_apis() { let builtins: Vec<_> = ModelServerKind::ALL @@ -294,6 +518,26 @@ mod tests { assert!(env_for_server(&s).is_empty()); } + #[test] + fn test_probe_defaults_do_not_leak_into_spawn_env() { + // The provider has a probe-only default_base_url, but a builtin server + // declares no base_url — so the spawn env must stay empty. This keeps the + // vendor-default / OAuth launch path intact; defaults are probe-only. + for kind in [ + ModelServerKind::AnthropicApi, + ModelServerKind::OpenAiApi, + ModelServerKind::GoogleApi, + ] { + assert!(kind.default_base_url().is_some()); + let s = server(kind.slug(), None); + assert!( + env_for_server(&s).is_empty(), + "{} leaked probe defaults into spawn env", + kind.slug() + ); + } + } + #[test] fn test_env_for_server_api_key_is_reference_not_value() { let mut s = server("openai-compat", Some("http://gpu:8000")); diff --git a/src/api/providers/model_server/probe.rs b/src/api/providers/model_server/probe.rs index c6f6acaa..16af0058 100644 --- a/src/api/providers/model_server/probe.rs +++ b/src/api/providers/model_server/probe.rs @@ -77,17 +77,24 @@ async fn probe_models_inner(server: &ModelServer) -> Result, Prob let kind = ModelServerKind::from_slug(&server.kind) .ok_or_else(|| ProbeError::UnknownKind(server.kind.clone()))?; + // Base URL: the instance's own, else the kind's probe-only default (lets the + // first-party providers / OpenRouter / ollama be probed without a declared + // base_url). Still NoBaseUrl for bring-your-own-endpoint kinds. let base = server .base_url .as_deref() .filter(|u| !u.is_empty()) + .or_else(|| kind.default_base_url()) .ok_or_else(|| ProbeError::NoBaseUrl(server.name.clone()))?; let url = format!("{}{}", base.trim_end_matches('/'), kind.models_endpoint()); + // API key: read the instance's named env var, else the kind's default probe + // env var (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / …). let api_key = server .api_key_env .as_deref() + .or_else(|| kind.default_api_key_env()) .and_then(|var| std::env::var(var).ok()) .filter(|k| !k.is_empty()); @@ -147,14 +154,86 @@ pub fn parse_models(kind: ModelServerKind, body: &str) -> Result, let models = match kind { ModelServerKind::Ollama => parse_ollama(&value), ModelServerKind::GoogleApi => parse_gemini(&value), + ModelServerKind::OpenRouter => parse_openrouter(&value), // OpenAI-protocol + anthropic both use a top-level `data` array of - // objects keyed by `id`. - _ => parse_openai_like(&value), + // objects keyed by `id`. The kind is threaded through so OpenAI's + // non-text models can be filtered out (see `is_text_model`). + _ => parse_openai_like(kind, &value), }; Ok(models) } +/// Whether a single raw model entry is an LLM text/chat model suitable for an +/// agent model dropdown. +/// +/// Listing endpoints return more than chat models — `OpenAI` and Google mix in +/// embeddings, audio (TTS/Whisper), image, and moderation models that must never +/// surface in a model picker. Each provider exposes a different capability signal +/// (or none), so the rule is per-kind: +/// +/// - Google: authoritative — keep iff `supportedGenerationMethods` advertises +/// `generateContent`. Absent field ⇒ keep (tolerant of API drift / fixtures). +/// - `OpenRouter`: keep iff the architecture's output modalities include `text`. +/// Absent ⇒ keep. +/// - `OpenAI`: no capability field, so classify by id family (deny embeddings / +/// audio / image / moderation; allow the gpt / o-series / chatgpt families; +/// deny anything unrecognized so unknown non-text families stay out). +/// - Anthropic / ollama / openai-compat / lmstudio: pass-through — Anthropic +/// lists only chat models, and BYO/local hosts serve whatever the user runs. +fn is_text_model(kind: ModelServerKind, raw: &Value, id: &str) -> bool { + match kind { + ModelServerKind::GoogleApi => raw + .get("supportedGenerationMethods") + .and_then(Value::as_array) + .map(|methods| { + methods + .iter() + .any(|m| m.as_str() == Some("generateContent")) + }) + // Absent capability field ⇒ keep (don't over-filter on drift). + .unwrap_or(true), + ModelServerKind::OpenRouter => openrouter_outputs_text(raw), + ModelServerKind::OpenAiApi => openai_id_is_text(id), + ModelServerKind::AnthropicApi + | ModelServerKind::Ollama + | ModelServerKind::OpenAiCompat + | ModelServerKind::LmStudio => true, + } +} + +/// `OpenRouter` advertises modalities under `architecture`. Prefer the explicit +/// `output_modalities` array; fall back to the `modality` string +/// (`"text->text"`, `"text+image->text"`); keep when neither is present. +fn openrouter_outputs_text(raw: &Value) -> bool { + let arch = raw.get("architecture"); + if let Some(mods) = arch + .and_then(|a| a.get("output_modalities")) + .and_then(Value::as_array) + { + return mods.iter().any(|m| m.as_str() == Some("text")); + } + if let Some(modality) = arch.and_then(|a| a.get("modality")).and_then(Value::as_str) { + // The output side is the segment after "->" (e.g. "text+image->text"). + let output = modality.split("->").nth(1).unwrap_or(modality); + return output.contains("text"); + } + true +} + +/// `OpenAI`'s `/v1/models` carries no capability field, so classify by id family. +/// Deny embeddings / audio / image / moderation; allow the chat families; deny +/// anything unrecognized so unknown non-text families stay out of pickers. +fn openai_id_is_text(id: &str) -> bool { + let id = id.to_ascii_lowercase(); + const DENY: [&str; 5] = ["embedding", "whisper", "tts", "dall-e", "moderation"]; + if DENY.iter().any(|d| id.contains(d)) { + return false; + } + const ALLOW_PREFIX: [&str; 5] = ["gpt", "o1", "o3", "o4", "chatgpt"]; + ALLOW_PREFIX.iter().any(|p| id.starts_with(p)) +} + /// ollama `/api/tags`: `{ "models": [ { "name": "llama3:latest", ... } ] }`. fn parse_ollama(value: &Value) -> Vec { value @@ -174,7 +253,11 @@ fn parse_ollama(value: &Value) -> Vec { /// `OpenAI` `/v1/models` and Anthropic `/v1/models`: /// `{ "data": [ { "id": "...", "display_name"?: "..." } ] }`. -fn parse_openai_like(value: &Value) -> Vec { +/// +/// Shared by `OpenAI` / Anthropic / openai-compat / lmstudio, so it takes the +/// `kind` to apply the per-protocol text-model filter (only `OpenAI` filters; the +/// others pass through — see [`is_text_model`]). +fn parse_openai_like(kind: ModelServerKind, value: &Value) -> Vec { value .get("data") .and_then(Value::as_array) @@ -182,6 +265,9 @@ fn parse_openai_like(value: &Value) -> Vec { arr.iter() .filter_map(|m| { let id = m.get("id").and_then(Value::as_str)?; + if !is_text_model(kind, m, id) { + return None; + } Some(ModelInfo { id: id.to_string(), display_name: m @@ -195,6 +281,34 @@ fn parse_openai_like(value: &Value) -> Vec { .unwrap_or_default() } +/// `OpenRouter` `/models`: +/// `{ "data": [ { "id": "anthropic/claude-3.5-sonnet", "name": "Anthropic: Claude 3.5 Sonnet", … } ] }`. +/// Same `data`-array shape as `OpenAI`, but the human label lives in `name` +/// (`OpenAI` uses `display_name`), so it needs its own reader to keep the label. +fn parse_openrouter(value: &Value) -> Vec { + value + .get("data") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m.get("id").and_then(Value::as_str)?; + if !is_text_model(ModelServerKind::OpenRouter, m, id) { + return None; + } + Some(ModelInfo { + id: id.to_string(), + display_name: m + .get("name") + .and_then(Value::as_str) + .map(std::string::ToString::to_string), + }) + }) + .collect() + }) + .unwrap_or_default() +} + /// Gemini `/v1beta/models`: /// `{ "models": [ { "name": "models/gemini-1.5-pro", "displayName": "..." } ] }`. fn parse_gemini(value: &Value) -> Vec { @@ -207,6 +321,9 @@ fn parse_gemini(value: &Value) -> Vec { let name = m.get("name").and_then(Value::as_str)?; // Strip the "models/" prefix so the id matches `--model`. let id = name.strip_prefix("models/").unwrap_or(name); + if !is_text_model(ModelServerKind::GoogleApi, m, id) { + return None; + } Some(ModelInfo { id: id.to_string(), display_name: m @@ -253,16 +370,116 @@ mod tests { assert_eq!(models[0].display_name.as_deref(), Some("Claude Opus 4")); } + #[test] + fn test_parse_openrouter_models() { + let body = r#"{"data":[ + {"id":"anthropic/claude-3.5-sonnet","name":"Anthropic: Claude 3.5 Sonnet"}, + {"id":"meta-llama/llama-3-70b-instruct","name":"Meta: Llama 3 70B Instruct"}]}"#; + let models = parse_models(ModelServerKind::OpenRouter, body).unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].id, "anthropic/claude-3.5-sonnet"); + assert_eq!( + models[0].display_name.as_deref(), + Some("Anthropic: Claude 3.5 Sonnet") + ); + assert_eq!(models[1].id, "meta-llama/llama-3-70b-instruct"); + } + #[test] fn test_parse_gemini_strips_models_prefix() { - let body = - r#"{"models":[{"name":"models/gemini-1.5-pro","displayName":"Gemini 1.5 Pro"}]}"#; + let body = r#"{"models":[{"name":"models/gemini-1.5-pro","displayName":"Gemini 1.5 Pro", + "supportedGenerationMethods":["generateContent"]}]}"#; let models = parse_models(ModelServerKind::GoogleApi, body).unwrap(); assert_eq!(models.len(), 1); assert_eq!(models[0].id, "gemini-1.5-pro"); assert_eq!(models[0].display_name.as_deref(), Some("Gemini 1.5 Pro")); } + #[test] + fn test_parse_openai_filters_non_text_models() { + // A realistic /v1/models mix: chat models plus embeddings/audio/image/ + // moderation that must not reach a model picker. + let body = r#"{"object":"list","data":[ + {"id":"gpt-4o","object":"model"}, + {"id":"o3-mini","object":"model"}, + {"id":"chatgpt-4o-latest","object":"model"}, + {"id":"text-embedding-3-small","object":"model"}, + {"id":"whisper-1","object":"model"}, + {"id":"tts-1","object":"model"}, + {"id":"dall-e-3","object":"model"}, + {"id":"omni-moderation-latest","object":"model"}]}"#; + let models = parse_models(ModelServerKind::OpenAiApi, body).unwrap(); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["gpt-4o", "o3-mini", "chatgpt-4o-latest"]); + assert!(models.iter().all(|m| !m.id.contains("text-embedding") + && !m.id.starts_with("whisper") + && !m.id.starts_with("tts") + && !m.id.starts_with("dall-e") + && !m.id.contains("moderation"))); + } + + #[test] + fn test_parse_gemini_filters_by_generation_method() { + // generateContent ⇒ kept; embedding/TTS-only ⇒ dropped. + let body = r#"{"models":[ + {"name":"models/gemini-1.5-pro","supportedGenerationMethods":["generateContent","countTokens"]}, + {"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent"]}, + {"name":"models/gemini-2.5-flash-tts","supportedGenerationMethods":["countTokens"]}]}"#; + let models = parse_models(ModelServerKind::GoogleApi, body).unwrap(); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["gemini-1.5-pro"]); + assert!(models.iter().all(|m| !m.id.contains("embedding"))); + } + + #[test] + fn test_parse_openrouter_filters_non_text_output() { + // Keep a text->text model; drop an image-output model. + let body = r#"{"data":[ + {"id":"anthropic/claude-3.5-sonnet","name":"Claude 3.5 Sonnet", + "architecture":{"output_modalities":["text"]}}, + {"id":"black-forest-labs/flux-1.1-pro","name":"FLUX 1.1 Pro", + "architecture":{"output_modalities":["image"],"modality":"text->image"}}]}"#; + let models = parse_models(ModelServerKind::OpenRouter, body).unwrap(); + let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, ["anthropic/claude-3.5-sonnet"]); + } + + #[test] + fn test_parse_openrouter_keeps_models_without_modality_info() { + // Existing shape with no `architecture` ⇒ keep (don't over-filter). + let body = r#"{"data":[ + {"id":"anthropic/claude-3.5-sonnet","name":"Anthropic: Claude 3.5 Sonnet"}, + {"id":"meta-llama/llama-3-70b-instruct","name":"Meta: Llama 3 70B Instruct"}]}"#; + let models = parse_models(ModelServerKind::OpenRouter, body).unwrap(); + assert_eq!(models.len(), 2); + } + + #[test] + fn test_parse_compat_and_lmstudio_passthrough() { + // BYO/local hosts are not filtered — an "embedding"-looking id is kept, + // guarding against the OpenAI deny-list bleeding into compat kinds. + let body = r#"{"data":[{"id":"nomic-embed-text","object":"model"}, + {"id":"qwen2.5-coder","object":"model"}]}"#; + for kind in [ModelServerKind::OpenAiCompat, ModelServerKind::LmStudio] { + let models = parse_models(kind, body).unwrap(); + assert_eq!( + models.len(), + 2, + "kind {kind:?} should pass through unfiltered" + ); + } + } + + #[test] + fn test_parse_anthropic_passthrough_unfiltered() { + // Anthropic lists only chat models; every entry is kept as-is. + let body = r#"{"data":[ + {"type":"model","id":"claude-opus-4","display_name":"Claude Opus 4"}, + {"type":"model","id":"claude-3-5-haiku-20241022","display_name":"Claude Haiku 3.5"}]}"#; + let models = parse_models(ModelServerKind::AnthropicApi, body).unwrap(); + assert_eq!(models.len(), 2); + } + #[test] fn test_parse_invalid_json_errors() { let err = parse_models(ModelServerKind::Ollama, "not json").unwrap_err(); @@ -277,9 +494,11 @@ mod tests { #[tokio::test] async fn test_probe_no_base_url_is_unreachable() { + // openai-compat is bring-your-own-endpoint: no default_base_url, so an + // instance without a base_url stays NoBaseUrl (no network call). let server = ModelServer { - name: "anthropic-api".into(), - kind: "anthropic-api".into(), + name: "vllm-undeclared".into(), + kind: "openai-compat".into(), base_url: None, api_key_env: None, extra_env: std::collections::HashMap::new(), @@ -289,4 +508,27 @@ mod tests { assert!(!outcome.reachable); assert!(outcome.error.is_some()); } + + #[test] + fn test_probe_url_uses_kind_default_when_instance_has_none() { + // Pure URL-composition check (no network): the first-party providers + // compose their probe URL from the kind's default base + endpoint. + for (kind, want) in [ + ( + ModelServerKind::AnthropicApi, + "https://api.anthropic.com/v1/models", + ), + ( + ModelServerKind::OpenAiApi, + "https://api.openai.com/v1/models", + ), + ( + ModelServerKind::GoogleApi, + "https://generativelanguage.googleapis.com/v1beta/models", + ), + ] { + let base = kind.default_base_url().unwrap().trim_end_matches('/'); + assert_eq!(format!("{base}{}", kind.models_endpoint()), want); + } + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 39edf1f9..f8b07885 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,7 +15,7 @@ use crate::notifications::NotificationService; use crate::relay::hub::RelayHub; #[cfg(unix)] use crate::relay::socket_path::hub_socket_path; -use crate::rest::RestApiServer; +use crate::rest::{ExternalApiProbe, RestApiServer}; use crate::services::{KanbanSyncService, PrMonitorService, PrStatusEvent, TrackedPr}; use crate::ui::create_dialog::CreateDialog; use crate::ui::dialogs::HelpDialog; @@ -109,8 +109,6 @@ pub struct App { pub(crate) update_notification_shown_at: Option, /// Receiver for version check results pub(crate) version_rx: mpsc::UnboundedReceiver, - /// True if REST API port was in use at startup (another instance may be running) - pub(crate) api_port_conflict: bool, /// Relay hub handle (None if hub failed to start or another instance is running) #[cfg(unix)] pub(crate) relay_hub: Option, @@ -314,7 +312,6 @@ impl App { update_available_version: None, update_notification_shown_at: None, version_rx, - api_port_conflict: false, #[cfg(unix)] relay_hub, tmux_client, @@ -339,13 +336,48 @@ impl App { // Always try to start REST API (unless disabled in config) if self.config.rest_api.enabled { - // Check if port is already in use (another operator instance may be running) + // If the port is already in use, probe it: a same-version operator + // serving this same project is adopted as our API (so downstream + // steps proceed); anything else is reported as a clear conflict + // rather than silently blocking. if self.rest_api_server.is_port_in_use().await { - self.api_port_conflict = true; - tracing::warn!( - port = self.config.rest_api.port, - "REST API port is already in use. Another operator instance may be running from this .tickets/ directory." - ); + let port = self.config.rest_api.port; + match self.rest_api_server.probe_external().await { + ExternalApiProbe::AdoptableSameProject { version, .. } => { + tracing::info!( + port, + %version, + "Adopting existing operator API on this port (same version, same project)" + ); + self.rest_api_server.mark_external(); + } + ExternalApiProbe::VersionMismatch { found } => { + let reason = format!( + "Port {port} held by operator v{found} (this is v{}); start it elsewhere or stop the other instance", + env!("CARGO_PKG_VERSION") + ); + tracing::warn!("{reason}"); + self.rest_api_server.mark_conflict(reason); + } + ExternalApiProbe::DifferentProject { found_name } => { + let reason = format!( + "Port {port} serves a different project ('{found_name}'); configure a different rest_api.port" + ); + tracing::warn!("{reason}"); + self.rest_api_server.mark_conflict(reason); + } + ExternalApiProbe::NotOperator => { + let reason = format!("Port {port} is held by a non-operator process"); + tracing::warn!("{reason}"); + self.rest_api_server.mark_conflict(reason); + } + ExternalApiProbe::Unreachable => { + let reason = + format!("Port {port} is in use but not responding to health checks"); + tracing::warn!("{reason}"); + self.rest_api_server.mark_conflict(reason); + } + } } else if let Err(e) = self.rest_api_server.start() { tracing::error!("REST API start failed: {}", e); } diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index bb1249df..6dfb4b76 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -29,18 +29,20 @@ use operator::api::providers::kanban::{ JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, }; use operator::config::{ - AgentsConfig, ApiConfig, CollectionPreset, Config, Delegator, DelegatorLaunchConfig, - DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, LoggingConfig, - NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, - SkillDirectoriesOverride, TemplatesConfig, TmuxConfig, ToolCapabilities, UiConfig, YoloConfig, + 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, }; use operator::queue::LlmTask; use operator::rest::dto::{ - CollectionResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, - CreateStepRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, + CollectionResponse, CreateAlertRequest, CreateAlertResponse, CreateDelegatorRequest, + CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, CreateTicketRequest, + CreateTicketResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanProviderCatalogEntry, SectionDto, SectionRowDto, SkillEntry, SkillsResponse, StatusResponse, StepResponse, - UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, + UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, WorkflowPreviewResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -82,103 +84,116 @@ fn main() { fn generate_typescript() -> String { let mut output = String::from(HEADER); + // ts-rs 12 threads an explicit `Config` through `decl()`; the default + // matches the implicit config used by earlier versions. + let cfg = ts_rs::Config::default(); + // Collect all type declarations let decls: Vec = vec![ // Unified types (src/types/) - Project::decl(), - ProjectRepo::decl(), - StepAttempt::decl(), - AttemptStatus::decl(), - ExecutionProcess::decl(), - ProcessStatus::decl(), - RunReason::decl(), - Session::decl(), + Project::decl(&cfg), + ProjectRepo::decl(&cfg), + StepAttempt::decl(&cfg), + AttemptStatus::decl(&cfg), + ExecutionProcess::decl(&cfg), + ProcessStatus::decl(&cfg), + RunReason::decl(&cfg), + Session::decl(&cfg), // Config types (src/config.rs) - Config::decl(), - AgentsConfig::decl(), - NotificationsConfig::decl(), - QueueConfig::decl(), - PathsConfig::decl(), - UiConfig::decl(), - PanelNamesConfig::decl(), - LaunchConfig::decl(), - DockerConfig::decl(), - YoloConfig::decl(), - TmuxConfig::decl(), - RestApiConfig::decl(), - LlmToolsConfig::decl(), - DetectedTool::decl(), - ToolCapabilities::decl(), - LlmProvider::decl(), - SkillDirectoriesOverride::decl(), - Delegator::decl(), - DelegatorLaunchConfig::decl(), - CollectionPreset::decl(), - TemplatesConfig::decl(), - LoggingConfig::decl(), - ApiConfig::decl(), + Config::decl(&cfg), + AgentsConfig::decl(&cfg), + NotificationsConfig::decl(&cfg), + QueueConfig::decl(&cfg), + PathsConfig::decl(&cfg), + UiConfig::decl(&cfg), + PanelNamesConfig::decl(&cfg), + LaunchConfig::decl(&cfg), + DockerConfig::decl(&cfg), + YoloConfig::decl(&cfg), + TmuxConfig::decl(&cfg), + RestApiConfig::decl(&cfg), + LlmToolsConfig::decl(&cfg), + DetectedTool::decl(&cfg), + ToolCapabilities::decl(&cfg), + LlmProvider::decl(&cfg), + SkillDirectoriesOverride::decl(&cfg), + Delegator::decl(&cfg), + DelegatorLaunchConfig::decl(&cfg), + AgentProfile::decl(&cfg), + XOperator::decl(&cfg), + RemoteAgentRef::decl(&cfg), + CollectionPreset::decl(&cfg), + TemplatesConfig::decl(&cfg), + LoggingConfig::decl(&cfg), + ApiConfig::decl(&cfg), // State types (src/state.rs) - State::decl(), - AgentState::decl(), - CompletedTicket::decl(), + State::decl(&cfg), + AgentState::decl(&cfg), + CompletedTicket::decl(&cfg), // REST DTOs (src/rest/dto.rs) - IssueTypeResponse::decl(), - IssueTypeSummary::decl(), - CreateIssueTypeRequest::decl(), - UpdateIssueTypeRequest::decl(), - FieldResponse::decl(), - CreateFieldRequest::decl(), - StepResponse::decl(), - CreateStepRequest::decl(), - UpdateStepRequest::decl(), - CollectionResponse::decl(), - HealthResponse::decl(), - StatusResponse::decl(), - SectionDto::decl(), - SectionRowDto::decl(), + IssueTypeResponse::decl(&cfg), + IssueTypeSummary::decl(&cfg), + CreateIssueTypeRequest::decl(&cfg), + UpdateIssueTypeRequest::decl(&cfg), + FieldResponse::decl(&cfg), + CreateFieldRequest::decl(&cfg), + StepResponse::decl(&cfg), + CreateStepRequest::decl(&cfg), + UpdateStepRequest::decl(&cfg), + CollectionResponse::decl(&cfg), + HealthResponse::decl(&cfg), + StatusResponse::decl(&cfg), + SectionDto::decl(&cfg), + SectionRowDto::decl(&cfg), // Kanban provider catalog DTO - KanbanProviderCatalogEntry::decl(), - // Workflow export DTO - WorkflowExportResponse::decl(), + KanbanProviderCatalogEntry::decl(&cfg), + // Workflow export DTOs + WorkflowExportResponse::decl(&cfg), + WorkflowPreviewResponse::decl(&cfg), + // Ticket creation + alert DTOs + CreateTicketRequest::decl(&cfg), + CreateTicketResponse::decl(&cfg), + CreateAlertRequest::decl(&cfg), + CreateAlertResponse::decl(&cfg), // Skills DTOs - SkillEntry::decl(), - SkillsResponse::decl(), + SkillEntry::decl(&cfg), + SkillsResponse::decl(&cfg), // Delegator DTOs - DelegatorResponse::decl(), - DelegatorsResponse::decl(), - CreateDelegatorRequest::decl(), - DelegatorLaunchConfigDto::decl(), + DelegatorResponse::decl(&cfg), + DelegatorsResponse::decl(&cfg), + CreateDelegatorRequest::decl(&cfg), + DelegatorLaunchConfigDto::decl(&cfg), // Queue types (src/queue/ticket.rs) - LlmTask::decl(), + LlmTask::decl(&cfg), // Jira API types (src/api/providers/kanban/jira.rs) - JiraSearchResponse::decl(), - JiraIssue::decl(), - JiraIssueFields::decl(), - JiraUser::decl(), - JiraAvatarUrls::decl(), - JiraDescription::decl(), - JiraIssueTypeRef::decl(), - JiraStatusRef::decl(), - JiraPriority::decl(), - JiraProjectStatus::decl(), - JiraStatus::decl(), + JiraSearchResponse::decl(&cfg), + JiraIssue::decl(&cfg), + JiraIssueFields::decl(&cfg), + JiraUser::decl(&cfg), + JiraAvatarUrls::decl(&cfg), + JiraDescription::decl(&cfg), + JiraIssueTypeRef::decl(&cfg), + JiraStatusRef::decl(&cfg), + JiraPriority::decl(&cfg), + JiraProjectStatus::decl(&cfg), + JiraStatus::decl(&cfg), // VSCode extension types (src/agents/vscode_types.rs) - VsCodeSessionInfo::decl(), - VsCodeHealthResponse::decl(), - VsCodeActivityState::decl(), - VsCodeTerminalState::decl(), - VsCodeTerminalCreateOptions::decl(), - VsCodeSendCommandRequest::decl(), - VsCodeSuccessResponse::decl(), - VsCodeExistsResponse::decl(), - VsCodeActivityResponse::decl(), - VsCodeListResponse::decl(), - VsCodeErrorResponse::decl(), - VsCodeTicketStatus::decl(), - VsCodeTicketInfo::decl(), - VsCodeModelOption::decl(), - VsCodeLaunchOptions::decl(), - VsCodeTicketMetadata::decl(), + VsCodeSessionInfo::decl(&cfg), + VsCodeHealthResponse::decl(&cfg), + VsCodeActivityState::decl(&cfg), + VsCodeTerminalState::decl(&cfg), + VsCodeTerminalCreateOptions::decl(&cfg), + VsCodeSendCommandRequest::decl(&cfg), + VsCodeSuccessResponse::decl(&cfg), + VsCodeExistsResponse::decl(&cfg), + VsCodeActivityResponse::decl(&cfg), + VsCodeListResponse::decl(&cfg), + VsCodeErrorResponse::decl(&cfg), + VsCodeTicketStatus::decl(&cfg), + VsCodeTicketInfo::decl(&cfg), + VsCodeModelOption::decl(&cfg), + VsCodeLaunchOptions::decl(&cfg), + VsCodeTicketMetadata::decl(&cfg), ]; // Add export keyword to each declaration diff --git a/src/config.rs b/src/config.rs index dd2a577a..347cdf42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +#[path = "config/agent_profile.rs"] +pub mod agent_profile; #[path = "config/git_config.rs"] pub mod git_config; #[path = "config/kanban.rs"] @@ -9,6 +11,7 @@ pub mod notifications_config; #[path = "config/sessions.rs"] pub mod sessions; +pub use agent_profile::*; pub use git_config::*; pub use kanban::*; pub use llm_tools::*; @@ -273,6 +276,11 @@ pub struct RestApiConfig { /// Whether the REST API is enabled #[serde(default = "default_rest_enabled")] pub enabled: bool, + /// Address the REST API binds to. Defaults to `127.0.0.1` (local only) so + /// the server — which reports the project directory name — is not reachable + /// from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + #[serde(default = "default_rest_host")] + pub host: String, /// Port for the REST API server #[serde(default = "default_rest_port")] pub port: u16, @@ -285,6 +293,10 @@ fn default_rest_enabled() -> bool { true } +fn default_rest_host() -> String { + "127.0.0.1".to_string() +} + fn default_rest_port() -> u16 { 7008 } @@ -293,12 +305,23 @@ impl Default for RestApiConfig { fn default() -> Self { Self { enabled: default_rest_enabled(), + host: default_rest_host(), port: default_rest_port(), cors_origins: Vec::new(), } } } +impl RestApiConfig { + /// Parse the configured `host` into an `IpAddr`, falling back to localhost + /// if it is not a valid IP literal (we never bind a wider scope by accident). + pub fn host_ip(&self) -> std::net::IpAddr { + self.host + .parse() + .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) + } +} + /// Model Context Protocol (MCP) server configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs new file mode 100644 index 00000000..5c94d0bb --- /dev/null +++ b/src/config/agent_profile.rs @@ -0,0 +1,414 @@ +//! A canonical, cross-tool **agent interchange profile** (`agent-profile.json`). +//! +//! Operator's [`Delegator`] and an AGNT.gg *Agent* are the same primitive seen +//! from two sides: a Delegator is CLI/git-native (it spawns a real coding CLI +//! with a permission sandbox), an AGNT Agent is API/memory-native (provider +//! calls + memory). They share a large core. Rather than merge them, this module +//! 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`]. +//! +//! This is the schema half of the remote-agent bridge. There is deliberately +//! **no** runtime client for any remote platform: a profile carrying +//! [`AgentProfile::remote_agent`] is a *declarative* reference — surfaced in the +//! `--format agnt` export when its platform is AGNT, but never executed by +//! Operator (see the launch guard in `delegator_resolution`). + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::llm_tools::{Delegator, DelegatorLaunchConfig, RemoteAgentRef}; + +/// A canonical cross-tool agent definition (serialized as `agent-profile.json`). +/// +/// Shared core (`name`/`provider`/`model`/`system_prompt`/`skills`/`mcp_servers`/ +/// `tools`) plus namespaced extension bags. `x_operator` is typed (Operator owns +/// those fields); `x_agnt` and `x_openai` are opaque per-platform pass-throughs. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct AgentProfile { + /// Unique agent name (maps to [`Delegator::name`]). + pub name: String, + /// Inference provider / CLI tool (maps to [`Delegator::llm_tool`]). + pub provider: String, + /// Model alias or id (maps to [`Delegator::model`]). + pub model: String, + /// System prompt. Operator has no first-class system prompt, so this is + /// preserved opaquely across import (see [`Delegator::unmapped_core`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Named skills. Preserved opaquely across import. + #[serde(default)] + pub skills: Vec, + /// MCP server names. Preserved opaquely across import. + #[serde(default)] + pub mcp_servers: Vec, + /// Tool names. Preserved opaquely across import. + #[serde(default)] + pub tools: Vec, + /// Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). + /// `None` = a locally launchable agent, not bound to a remote platform. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_agent: Option, + /// Operator-owned extension fields (typed). `None` when the agent carries no + /// Operator-specific configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x_operator: Option, + /// AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, + /// `creditLimit`, ...). Operator never interprets this — pure pass-through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x_agnt: Option, + /// OpenAI-owned extension fields, opaque (`instructions`, `tools`, + /// `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a + /// second platform — never interpreted. This field is the whole per-tool cost + /// of adding `OpenAI`: a passthrough bag, no mapping logic. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x_openai: Option, +} + +/// The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator +/// carries that have no shared-core equivalent. AGNT ignores this bag; Operator +/// round-trips it losslessly. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct XOperator { + /// Optional display name for UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Arbitrary model properties (e.g. `reasoning_effort`, sandbox). + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub model_properties: std::collections::HashMap, + /// Name of a declared `ModelServer` (`None` = implicit vendor default). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_server: Option, + /// Launch configuration (permission mode, flags, worktree/docker, prompt + /// wrapping, ...). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_config: Option, +} + +impl XOperator { + /// Whether this bag carries any Operator-specific data worth serializing. + fn is_empty(&self) -> bool { + self.display_name.is_none() + && self.model_properties.is_empty() + && self.model_server.is_none() + && self.launch_config.is_none() + } +} + +/// The shared-core fields Operator cannot model first-class. Stashed verbatim +/// into [`Delegator::unmapped_core`] on import so a later export restores them. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct UnmappedCore { + #[serde(default, skip_serializing_if = "Option::is_none")] + system_prompt: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tools: Vec, +} + +impl UnmappedCore { + fn is_empty(&self) -> bool { + self.system_prompt.is_none() + && self.skills.is_empty() + && self.mcp_servers.is_empty() + && self.tools.is_empty() + } +} + +/// Serialize a [`Delegator`] into an [`AgentProfile`]. +/// +/// Operator-only fields go into `x_operator`; the opaque per-platform bags +/// (`x_agnt`, `x_openai`) and the shared-core carry ([`Delegator::unmapped_core`]) +/// are restored so a profile that *originated* on a remote platform re-exports +/// identically. A delegator authored natively in Operator has no `unmapped_core`, +/// so the shared-core fields export empty (`OPERATOR-GAP`: Operator has no +/// first-class `system_prompt`/`skills`/`tools`). +pub fn delegator_to_profile(d: &Delegator) -> AgentProfile { + let core: UnmappedCore = d + .unmapped_core + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let x_operator = XOperator { + display_name: d.display_name.clone(), + model_properties: d.model_properties.clone(), + model_server: d.model_server.clone(), + launch_config: d.launch_config.clone(), + }; + + AgentProfile { + name: d.name.clone(), + provider: d.llm_tool.clone(), + model: d.model.clone(), + system_prompt: core.system_prompt, + skills: core.skills, + mcp_servers: core.mcp_servers, + tools: core.tools, + remote_agent: d.remote_agent.clone(), + x_operator: if x_operator.is_empty() { + None + } else { + Some(x_operator) + }, + x_agnt: d.x_agnt.clone(), + x_openai: d.x_openai.clone(), + } +} + +/// Import an [`AgentProfile`] into a [`Delegator`]. +/// +/// Shared-core fields Operator can't model (`system_prompt`/`skills`/ +/// `mcp_servers`/`tools`) are stashed into [`Delegator::unmapped_core`] as opaque +/// JSON; the opaque per-platform bags (`x_agnt`, `x_openai`) are passed through. +/// All ensure a subsequent [`delegator_to_profile`] is lossless. +pub fn profile_to_delegator(p: &AgentProfile) -> Delegator { + let x = p.x_operator.clone().unwrap_or_default(); + + let core = UnmappedCore { + system_prompt: p.system_prompt.clone(), + skills: p.skills.clone(), + mcp_servers: p.mcp_servers.clone(), + tools: p.tools.clone(), + }; + let unmapped_core = if core.is_empty() { + None + } else { + serde_json::to_value(&core).ok() + }; + + Delegator { + name: p.name.clone(), + llm_tool: p.provider.clone(), + model: p.model.clone(), + display_name: x.display_name, + model_properties: x.model_properties, + launch_config: x.launch_config, + model_server: x.model_server, + remote_agent: p.remote_agent.clone(), + x_agnt: p.x_agnt.clone(), + x_openai: p.x_openai.clone(), + unmapped_core, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn json(d: &Delegator) -> serde_json::Value { + serde_json::to_value(d).unwrap() + } + + fn profile_json(p: &AgentProfile) -> serde_json::Value { + serde_json::to_value(p).unwrap() + } + + fn full_delegator() -> Delegator { + let mut props = std::collections::HashMap::new(); + props.insert("reasoning_effort".to_string(), "high".to_string()); + Delegator { + name: "claude-opus-auto".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Claude Opus (Auto)".to_string()), + model_properties: props, + launch_config: Some(DelegatorLaunchConfig { + yolo: true, + permission_mode: Some("plan".to_string()), + flags: vec!["--verbose".to_string()], + use_worktrees: Some(true), + create_branch: Some(false), + docker: Some(true), + prompt_prefix: Some("PREFIX".to_string()), + prompt_suffix: Some("SUFFIX".to_string()), + operator_relay: Some(true), + }), + model_server: Some("anthropic-api".to_string()), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + } + } + + #[test] + fn delegator_to_profile_maps_core_fields() { + let d = full_delegator(); + let p = delegator_to_profile(&d); + assert_eq!(p.name, "claude-opus-auto"); + assert_eq!(p.provider, "claude"); + assert_eq!(p.model, "opus"); + } + + #[test] + fn delegator_to_profile_puts_operator_only_fields_in_x_operator() { + let d = full_delegator(); + let p = delegator_to_profile(&d); + let x = p + .x_operator + .expect("operator-only data lives in x_operator"); + assert_eq!(x.display_name.as_deref(), Some("Claude Opus (Auto)")); + assert_eq!(x.model_server.as_deref(), Some("anthropic-api")); + assert_eq!(x.model_properties.get("reasoning_effort").unwrap(), "high"); + assert!(x.launch_config.unwrap().yolo); + // Shared core fields Operator can't model export empty. + assert!(p.system_prompt.is_none()); + assert!(p.skills.is_empty()); + } + + #[test] + fn delegator_with_no_operator_data_has_no_x_operator() { + let d = Delegator { + name: "bare".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: None, + model_server: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }; + let p = delegator_to_profile(&d); + assert!(p.x_operator.is_none()); + } + + #[test] + fn delegator_roundtrip_lossless_for_operator_fields() { + let d = full_delegator(); + let round_tripped = profile_to_delegator(&delegator_to_profile(&d)); + assert_eq!( + json(&d), + json(&round_tripped), + "Delegator -> profile -> Delegator must be lossless" + ); + } + + #[test] + fn profile_roundtrip_preserves_x_agnt_opaque() { + let p = AgentProfile { + name: "agnt-researcher".to_string(), + provider: "anthropic".to_string(), + model: "claude-3-5-sonnet".to_string(), + system_prompt: None, + skills: vec![], + mcp_servers: vec![], + tools: vec![], + remote_agent: Some(RemoteAgentRef { + platform: "agnt".to_string(), + id: "Research Assistant".to_string(), + }), + x_operator: None, + x_agnt: Some(serde_json::json!({ + "memory": { "window": 10, "vectorStore": "pinecone" }, + "assignedWorkflows": ["wf-1", "wf-2"], + "creditLimit": 1000 + })), + x_openai: None, + }; + let round_tripped = delegator_to_profile(&profile_to_delegator(&p)); + assert_eq!( + profile_json(&p), + profile_json(&round_tripped), + "x_agnt must survive profile -> Delegator -> profile byte-for-byte" + ); + } + + #[test] + fn openai_profile_roundtrips_with_x_openai() { + // The structural twin of the x_agnt test, for a second platform — proving + // the per-tool cost is exactly one opaque bag + the generic remote ref. + let p = AgentProfile { + name: "openai-reviewer".to_string(), + provider: "openai".to_string(), + model: "gpt-4o".to_string(), + system_prompt: None, + skills: vec![], + mcp_servers: vec![], + tools: vec![], + remote_agent: Some(RemoteAgentRef { + platform: "openai".to_string(), + id: "asst_abc123".to_string(), + }), + x_operator: None, + x_agnt: None, + x_openai: Some(serde_json::json!({ + "instructions": "You are a careful reviewer.", + "tools": [{ "type": "file_search" }], + "tool_resources": { "file_search": { "vector_store_ids": ["vs_1"] } }, + "metadata": { "team": "platform" } + })), + }; + let round_tripped = delegator_to_profile(&profile_to_delegator(&p)); + assert_eq!( + profile_json(&p), + profile_json(&round_tripped), + "x_openai must survive profile -> Delegator -> profile byte-for-byte" + ); + } + + #[test] + fn profile_roundtrip_preserves_shared_core() { + let p = AgentProfile { + name: "careful-reviewer".to_string(), + provider: "anthropic".to_string(), + model: "claude-3-5-sonnet".to_string(), + system_prompt: Some("You are a careful reviewer.".to_string()), + skills: vec!["rust-testing".to_string()], + mcp_servers: vec!["github".to_string()], + tools: vec!["web-search".to_string()], + remote_agent: None, + x_operator: None, + x_agnt: None, + x_openai: None, + }; + let round_tripped = delegator_to_profile(&profile_to_delegator(&p)); + assert_eq!( + profile_json(&p), + profile_json(&round_tripped), + "shared-core fields Operator can't model must survive round-trip" + ); + } + + #[test] + fn profile_with_remote_agent_survives_roundtrip() { + let p = AgentProfile { + name: "agnt-researcher".to_string(), + provider: "agnt".to_string(), + model: "default".to_string(), + system_prompt: None, + skills: vec![], + mcp_servers: vec![], + tools: vec![], + remote_agent: Some(RemoteAgentRef { + platform: "agnt".to_string(), + id: "Research Assistant".to_string(), + }), + x_operator: None, + x_agnt: None, + x_openai: None, + }; + let d = profile_to_delegator(&p); + let r = d.remote_agent.as_ref().expect("remote_agent preserved"); + assert_eq!(r.platform, "agnt"); + assert_eq!(r.id, "Research Assistant"); + let back = delegator_to_profile(&d); + let rb = back + .remote_agent + .as_ref() + .expect("remote_agent re-exported"); + assert_eq!(rb.platform, "agnt"); + assert_eq!(rb.id, "Research Assistant"); + } +} diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index 45f09328..f60d5551 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -35,6 +35,10 @@ fn test_delegator_serde_roundtrip() { ..Default::default() }), model_server: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }; let json = serde_json::to_string(&delegator).unwrap(); diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index d6c46199..feb439a9 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -125,6 +125,22 @@ pub struct SkillDirectoriesOverride { pub project: Vec, } +/// A declarative reference to a remote, named agent hosted by another platform. +/// +/// `platform` is the hosting service (`"agnt"`, `"openai"`) — deliberately +/// distinct from the core `provider`/`llm_tool` (the model or coding CLI). These +/// agents are API/memory-native and live on the remote side; Operator has no +/// runtime client for them, so a delegator carrying one is **export-only** and +/// cannot be launched locally (see the guard in `delegator_resolution`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct RemoteAgentRef { + /// Hosting platform (e.g. `"agnt"`, `"openai"`). + pub platform: String, + /// Platform-native agent identifier (e.g. an AGNT agent name, an `OpenAI` `asst_…` id). + pub id: String, +} + /// Agent delegator configuration for autonomous ticket launching /// /// A delegator is a named {tool, model} pairing with optional launch configuration @@ -152,6 +168,34 @@ pub struct Delegator { /// (claude → anthropic-api, codex → openai-api, gemini → google-api). #[serde(default)] pub model_server: Option, + /// Declarative reference to a remote, named agent on another platform + /// (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). + /// + /// Export-only: Operator has no runtime client for those platforms, so a + /// delegator carrying this CANNOT be launched locally — resolution errors out + /// (see `delegator_resolution`). It is stored, listed, serialized into an + /// `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + /// `--format agnt` workflow export as an `agnt-agent` node. `None` = ordinary, + /// locally launchable delegator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_agent: Option, + /// Opaque AGNT-namespaced extension fields, preserved verbatim across an + /// `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, + /// `assignedWorkflows`, `creditLimit`). Operator never interprets this. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x_agnt: Option, + /// Opaque OpenAI-namespaced extension fields, preserved verbatim across an + /// `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, + /// `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x_openai: Option, + /// Opaque carry for `AgentProfile` shared-core fields Operator cannot model + /// first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an + /// import→export round-trip is lossless. Distinct from `x_agnt`: these are + /// shared-core fields, not AGNT-specific, so folding them into `x_agnt` would + /// corrupt that namespace. Operator never interprets this. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unmapped_core: Option, } /// A named host that serves models via an inference API. @@ -168,7 +212,7 @@ pub struct Delegator { pub struct ModelServer { /// Unique name (e.g., "ollama-local", "vllm-gpu1") pub name: String, - /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + /// Kind: "ollama", "openrouter", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" pub kind: String, /// Base URL of the inference endpoint (e.g., `http://localhost:11434`). /// `None` for implicit vendor servers means use the SDK default. @@ -206,11 +250,60 @@ pub fn implicit_model_server_for_tool(tool: &str) -> ModelServer { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delegator_deserializes_without_new_fields() { + // Pre-existing config JSON that has none of remote_agent/x_agnt/x_openai/unmapped_core. + let json = r#"{ + "name": "claude-opus", + "llm_tool": "claude", + "model": "opus" + }"#; + let d: Delegator = serde_json::from_str(json).expect("legacy delegator still deserializes"); + assert_eq!(d.name, "claude-opus"); + assert!(d.remote_agent.is_none()); + assert!(d.x_agnt.is_none()); + assert!(d.x_openai.is_none()); + assert!(d.unmapped_core.is_none()); + } + + #[test] + fn delegator_serializes_omits_none_new_fields() { + let d = Delegator { + name: "claude-opus".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + launch_config: None, + model_server: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }; + let v = serde_json::to_value(&d).unwrap(); + assert!( + v.get("remote_agent").is_none(), + "None remote_agent is omitted" + ); + assert!(v.get("x_agnt").is_none(), "None x_agnt is omitted"); + assert!(v.get("x_openai").is_none(), "None x_openai is omitted"); + assert!( + v.get("unmapped_core").is_none(), + "None unmapped_core is omitted" + ); + } +} + /// Launch configuration for a delegator /// /// Controls how the delegator launches agents. Optional fields use tri-state /// semantics: `None` = inherit from global config, `Some(true/false)` = override. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] #[ts(export)] pub struct DelegatorLaunchConfig { /// Run in YOLO (auto-accept) mode diff --git a/src/docs_gen/issuetype_json_schema.rs b/src/docs_gen/issuetype_json_schema.rs index 50ad5164..34ca526c 100644 --- a/src/docs_gen/issuetype_json_schema.rs +++ b/src/docs_gen/issuetype_json_schema.rs @@ -37,7 +37,7 @@ impl DocGenerator for IssuetypeJsonSchemaDocGenerator { obj.insert( "$id".to_string(), serde_json::Value::String( - "https://gbqr.us/operator/issuetype-template.schema.json".to_string(), + "https://operator.untra.io/schemas/issuetype_schema.json".to_string(), ), ); obj.insert( diff --git a/src/docs_gen/llms.rs b/src/docs_gen/llms.rs new file mode 100644 index 00000000..3b9cb238 --- /dev/null +++ b/src/docs_gen/llms.rs @@ -0,0 +1,304 @@ +//! Documentation generator for `llms.txt` (the llmstxt.org standard). +//! +//! Produces a curated, token-efficient site map for LLMs, written to +//! `docs/llms.txt` and served verbatim at . +//! Unlike the other generators this output has **no Jekyll front matter**, so +//! Jekyll copies it byte-for-byte instead of wrapping it in a layout. +//! +//! Section titles and descriptions are read from each section's `index.md` +//! front matter so the file stays in sync with the docs; the grouping/order is +//! editorial and lives here. + +use super::DocGenerator; +use anyhow::Result; +use std::path::Path; + +/// Canonical site URL used to build absolute links (required by llms.txt). +const SITE_URL: &str = "https://operator.untra.io"; + +/// Project name (the required H1). +const TITLE: &str = "Operator!"; + +/// One-line summary rendered as the required blockquote. +const SUMMARY: &str = "Operator! is a Rust TUI for orchestrating Claude Code and other LLM coding agents across multi-repository codebases, driven by kanban-style markdown tickets. It connects to Jira, Linear, and GitHub Projects; launches agents in tmux, cmux, Zellij, VS Code, or Zed sessions; reaches models via Anthropic, OpenAI, Google, OpenRouter, or Ollama; and enforces team workflows."; + +/// Free-form context paragraph after the summary. +const INTRO: &str = "Operator runs from the root of your work directory, discovers projects by LLM marker files (CLAUDE.md / CODEX.md / GEMINI.md) and git repositories, and manages a `.tickets/` queue. It exposes a REST API and an embedded web developer portal."; + +/// A doc section sourced from `docs/{slug}/index.md`. +struct Link { + /// Directory slug; also the URL path segment. + slug: &'static str, + /// Used only when the page's front matter has no `description`. + fallback_desc: &'static str, +} + +/// An editorial grouping of links in the output. +struct Section { + heading: &'static str, + links: &'static [Link], + /// Literal external links appended after the sourced links. + extra: &'static [(&'static str, &'static str, &'static str)], +} + +const SECTIONS: &[Section] = &[ + Section { + heading: "Getting Started", + links: &[ + Link { + slug: "getting-started", + fallback_desc: "", + }, + Link { + slug: "downloads", + fallback_desc: "", + }, + ], + extra: &[], + }, + Section { + heading: "Core Concepts", + links: &[ + Link { + slug: "kanban", + fallback_desc: "", + }, + Link { + slug: "tickets", + fallback_desc: "", + }, + Link { + slug: "issue-types", + fallback_desc: "", + }, + Link { + slug: "agents", + fallback_desc: "", + }, + Link { + slug: "delegators", + fallback_desc: "", + }, + ], + extra: &[], + }, + Section { + heading: "Integrations", + links: &[ + Link { + slug: "llm-tools", + fallback_desc: "", + }, + Link { + slug: "relay", + fallback_desc: "", + }, + ], + extra: &[], + }, + Section { + heading: "Reference", + links: &[ + Link { + slug: "cli", + fallback_desc: "Commands and environment variables.", + }, + Link { + slug: "configuration", + fallback_desc: "TOML configuration structure and options.", + }, + Link { + slug: "schemas", + fallback_desc: "Ticket metadata, issue type, and OpenAPI schema reference.", + }, + Link { + slug: "shortcuts", + fallback_desc: "TUI keyboard shortcuts by context.", + }, + Link { + slug: "taxonomy", + fallback_desc: "Project Kinds across five tiers.", + }, + ], + extra: &[], + }, + Section { + heading: "Optional", + links: &[Link { + slug: "architecture", + fallback_desc: "System design overview.", + }], + extra: &[( + "GitHub Repository", + "https://github.com/untra/operator", + "Source code (Rust, MIT).", + )], + }, +]; + +/// Generates `llms.txt` from the docs section front matter. +pub struct LlmsTxtDocGenerator; + +impl DocGenerator for LlmsTxtDocGenerator { + fn name(&self) -> &'static str { + "llms" + } + + fn source(&self) -> &'static str { + "docs/*/index.md (section front matter)" + } + + fn output_path(&self) -> &'static str { + "llms.txt" + } + + fn generate(&self) -> Result { + let docs_root = Path::new("docs"); + let mut out = String::new(); + + // Auto-gen marker (HTML comment — valid markdown, ignored by llms.txt + // parsers, and not YAML front matter so Jekyll copies the file as-is). + out.push_str("\n"); + out.push_str("\n\n"); + + out.push_str(&format!("# {TITLE}\n\n")); + out.push_str(&format!("> {SUMMARY}\n\n")); + out.push_str(&format!("{INTRO}\n")); + + for section in SECTIONS { + out.push_str(&format!("\n## {}\n", section.heading)); + for link in section.links { + let (title, desc) = read_front_matter(docs_root, link.slug); + let desc = desc.unwrap_or_else(|| link.fallback_desc.to_string()); + let url = format!("{SITE_URL}/{}/", link.slug); + out.push_str(&render_item(&title, &url, &desc)); + } + for (label, url, desc) in section.extra { + out.push_str(&render_item(label, url, desc)); + } + } + + Ok(out) + } +} + +/// Render one list item: `- [Title](url): description` (description optional). +fn render_item(title: &str, url: &str, desc: &str) -> String { + if desc.is_empty() { + format!("- [{title}]({url})\n") + } else { + format!("- [{title}]({url}): {desc}\n") + } +} + +/// Read `title` and `description` from a section's `index.md` front matter. +/// +/// Falls back to a title-cased slug when the page or its `title` is missing. +fn read_front_matter(docs_root: &Path, slug: &str) -> (String, Option) { + let path = docs_root.join(slug).join("index.md"); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + + let mut title = None; + let mut description = None; + let mut in_front_matter = false; + for line in content.lines() { + if line.trim() == "---" { + if in_front_matter { + break; // end of front matter + } + in_front_matter = true; + continue; + } + if !in_front_matter { + continue; + } + if let Some(v) = line.strip_prefix("title:") { + title = Some(unquote(v.trim())); + } else if let Some(v) = line.strip_prefix("description:") { + let v = unquote(v.trim()); + if !v.is_empty() { + description = Some(v); + } + } + } + + (title.unwrap_or_else(|| title_case(slug)), description) +} + +/// Strip a single pair of surrounding single or double quotes. +fn unquote(s: &str) -> String { + let s = s.trim(); + if s.len() >= 2 + && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))) + { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +/// Turn a slug like `getting-started` into `Getting Started`. +fn title_case(slug: &str) -> String { + slug.split('-') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unquote_strips_matching_quotes() { + assert_eq!(unquote("\"Getting Started\""), "Getting Started"); + assert_eq!(unquote("'Relay'"), "Relay"); + assert_eq!(unquote("Kanban Workflow"), "Kanban Workflow"); + assert_eq!(unquote("\"\""), ""); + } + + #[test] + fn test_title_case() { + assert_eq!(title_case("getting-started"), "Getting Started"); + assert_eq!(title_case("cli"), "Cli"); + } + + #[test] + fn test_render_item_with_and_without_desc() { + assert_eq!( + render_item("T", "https://x/", "d"), + "- [T](https://x/): d\n" + ); + assert_eq!(render_item("T", "https://x/", ""), "- [T](https://x/)\n"); + } + + #[test] + fn test_generate_is_spec_shaped() { + let out = LlmsTxtDocGenerator.generate().unwrap(); + + // No YAML front matter — must be served verbatim, not wrapped in a layout. + assert!(!out.starts_with("---")); + assert!(!out.contains("layout:")); + + // Required H1 and blockquote summary. + assert!(out.contains("# Operator!")); + assert!(out.contains("> Operator! is a Rust TUI")); + + // Section headings and absolute links. + assert!(out.contains("## Getting Started")); + assert!(out.contains("(https://operator.untra.io/getting-started/)")); + assert!(out.contains("## Reference")); + assert!(out.contains("(https://github.com/untra/operator)")); + + // Every link is absolute (llms.txt requires fully-qualified URLs). + for line in out.lines().filter(|l| l.starts_with("- [")) { + assert!(line.contains("](https://"), "non-absolute link: {line}"); + } + } +} diff --git a/src/docs_gen/mod.rs b/src/docs_gen/mod.rs index 4cf68a46..6c219978 100644 --- a/src/docs_gen/mod.rs +++ b/src/docs_gen/mod.rs @@ -20,6 +20,7 @@ pub mod issuetype; pub mod issuetype_json_schema; pub mod jira_api; pub mod llm_tools; +pub mod llms; pub mod markdown; pub mod metadata; pub mod openapi; @@ -98,6 +99,7 @@ pub fn generate_all(docs_dir: &Path) -> Result<()> { Box::new(operator_output_schema::OperatorOutputSchemaDocGenerator), Box::new(issuetype_json_schema::IssuetypeJsonSchemaDocGenerator), Box::new(project_analysis_schema::ProjectAnalysisSchemaDocGenerator), + Box::new(llms::LlmsTxtDocGenerator), ]; for generator in generators { diff --git a/src/docs_gen/operator_output_schema.rs b/src/docs_gen/operator_output_schema.rs index 52acb241..d418ffcd 100644 --- a/src/docs_gen/operator_output_schema.rs +++ b/src/docs_gen/operator_output_schema.rs @@ -37,7 +37,7 @@ impl DocGenerator for OperatorOutputSchemaDocGenerator { obj.insert( "$id".to_string(), serde_json::Value::String( - "https://operator.dev/schemas/operator_output.schema.json".to_string(), + "https://operator.untra.io/schemas/operator_output.json".to_string(), ), ); obj.insert( diff --git a/src/docs_gen/project_analysis_schema.rs b/src/docs_gen/project_analysis_schema.rs index 4dde98d2..7a00891a 100644 --- a/src/docs_gen/project_analysis_schema.rs +++ b/src/docs_gen/project_analysis_schema.rs @@ -37,7 +37,7 @@ impl DocGenerator for ProjectAnalysisSchemaDocGenerator { obj.insert( "$id".to_string(), serde_json::Value::String( - "https://gbqr.us/operator/project-analysis.schema.json".to_string(), + "https://operator.untra.io/schemas/project_analysis.json".to_string(), ), ); obj.insert( diff --git a/src/llm/tools/tool_config.schema.json b/src/llm/tools/tool_config.schema.json index 6228258f..c3acc2d0 100644 --- a/src/llm/tools/tool_config.schema.json +++ b/src/llm/tools/tool_config.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://gbqr.us/operator/tool-config.schema.json", + "$id": "https://operator.untra.io/schemas/tool_config.schema.json", "title": "LLM Tool Configuration Schema", "description": "Schema for configuring LLM CLI tools in Operator. Each tool config defines how Operator detects, invokes, and manages an LLM CLI tool like Claude Code, Gemini CLI, or OpenAI Codex.", "type": "object", diff --git a/src/main.rs b/src/main.rs index 1333d319..9a01bd48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -293,14 +293,18 @@ enum Commands { #[derive(Subcommand)] enum WorkflowAction { - /// Export a ticket, rendered against its issuetype, to a Claude Code dynamic workflow (.js) + /// Export a ticket, rendered against its issuetype, to an orchestration workflow Export { /// Ticket id (e.g. FEAT-1234) or path to a ticket markdown file ticket: String, - /// Output path (default: .workflow.js in the current directory; "-" for stdout) + /// Output path (default: derived from the ticket id + format; "-" for stdout) #[arg(short, long)] out: Option, + + /// Output format: claude (Claude Code .js) or agnt (AGNT.gg workflow JSON) + #[arg(long, value_enum, default_value_t)] + format: workflow_gen::WorkflowFormat, }, } @@ -757,7 +761,11 @@ async fn cmd_create( fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { match action { - WorkflowAction::Export { ticket, out } => { + WorkflowAction::Export { + ticket, + out, + format, + } => { // Resolve the ticket (by id via the queue, or as a direct file path). // Resolution is the only edge-specific step; the registry build and // the export itself go through the same shared path as the REST API. @@ -775,8 +783,9 @@ fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { // Same registry loader the REST API (ApiState::new) uses. let registry = startup::templates::load_registry(&config.tickets_path()); - let exported = - workflow_gen::export_workflow_for_ticket(&resolved, ®istry, None, config)?; + let exported = workflow_gen::export_workflow_for_ticket( + &resolved, ®istry, None, config, format, + )?; match out.as_deref() { Some(p) if p == std::path::Path::new("-") => { @@ -799,8 +808,8 @@ fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { fn cmd_docs(_config: &Config, output: Option, only: Option) -> Result<()> { use docs_gen::{ - cli, config, config_schema, issuetype, issuetype_json_schema, jira_api, metadata, openapi, - operator_output_schema, project_analysis_schema, schema_index, shortcuts, startup, + cli, config, config_schema, issuetype, issuetype_json_schema, jira_api, llms, metadata, + openapi, operator_output_schema, project_analysis_schema, schema_index, shortcuts, startup, state_schema, taxonomy, DocGenerator, }; use std::path::PathBuf; @@ -866,9 +875,12 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R project_analysis_schema::ProjectAnalysisSchemaDocGenerator, )] } + Some("llms") => { + vec![Box::new(llms::LlmsTxtDocGenerator)] + } Some(other) => { println!( - "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema" + "Unknown generator: {other}. Available: taxonomy, issuetype, metadata, shortcuts, cli, config, openapi, startup, config-schema, state-schema, schema-index, jira-api, operator-output-schema, issuetype-json-schema, project-analysis-schema, llms" ); return Ok(()); } @@ -890,6 +902,7 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Box::new(operator_output_schema::OperatorOutputSchemaDocGenerator), Box::new(issuetype_json_schema::IssuetypeJsonSchemaDocGenerator), Box::new(project_analysis_schema::ProjectAnalysisSchemaDocGenerator), + Box::new(llms::LlmsTxtDocGenerator), ] } }; diff --git a/src/mcp/descriptor.rs b/src/mcp/descriptor.rs index aab1d3c4..f881282d 100644 --- a/src/mcp/descriptor.rs +++ b/src/mcp/descriptor.rs @@ -5,8 +5,9 @@ //! vscode-extension) and, optionally, the stdio entrypoint command so //! clients can spawn `operator mcp` as a subprocess instead. -use axum::extract::{Host, State}; +use axum::extract::State; use axum::Json; +use axum_extra::extract::Host; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 522efd73..5d75f1f7 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -266,7 +266,7 @@ fn require_write_tools(state: &ApiState) -> Result<(), String> { pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { match name { "operator_health" => { - let resp = routes::health::health().await; + let resp = routes::health::health(State(state.clone())).await; serde_json::to_value(&*resp).map_err(|e| e.to_string()) } "operator_status" => { diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index b9f5bb1f..d4fe9882 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -8,10 +8,11 @@ use std::convert::Infallible; use std::time::Duration; -use axum::extract::{Host, Query, State}; +use axum::extract::{Query, State}; use axum::response::sse::{Event, Sse}; use axum::response::IntoResponse; use axum::Json; +use axum_extra::extract::Host; use serde::Deserialize; use serde_json::json; use tokio::sync::mpsc; diff --git a/src/rest/directory.rs b/src/rest/directory.rs new file mode 100644 index 00000000..91bb90de --- /dev/null +++ b/src/rest/directory.rs @@ -0,0 +1,94 @@ +//! Directory identity for the operator working root. +//! +//! Lets a client decide whether an API already listening on the expected port +//! belongs to the *same* project before adopting it as "connected". We expose: +//! +//! - `directory_name`: the top-level directory name (basename of the working +//! root). This is intentionally human-readable — operator's purpose is to +//! report on the projects/repos under that directory — and is the value the +//! code-projects API also surfaces. +//! - `directory_id`: a non-reversible fingerprint (first 12 hex chars of +//! `SHA-256(canonical absolute path)`). Used *only* for exact same-directory +//! matching, so adoption never cross-wires two repos that share a basename. +//! The full path is never exposed over the wire. + +use std::fmt::Write as _; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +/// Identity of the operator working root: `(directory_name, directory_id)`. +/// +/// The working root is the directory that *contains* the `.tickets` directory +/// (i.e. the directory operator was launched in). `tickets_path` is the +/// `.tickets` directory itself. +pub fn directory_identity(tickets_path: &Path) -> (String, String) { + // Working root = parent of the tickets dir; fall back to the tickets dir + // itself if it has no parent (e.g. a bare relative path). + let root = tickets_path.parent().unwrap_or(tickets_path); + + // Canonicalize for a stable id; fall back to the path as given when it does + // not exist on disk yet (e.g. in tests). + let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + + let directory_name = canonical + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "operator".to_string()); + + let mut hasher = Sha256::new(); + hasher.update(canonical.to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + // First 6 bytes -> 12 hex chars. Enough to make accidental collisions + // between distinct absolute paths negligible without pulling in `hex`. + let mut directory_id = String::with_capacity(12); + for byte in digest.iter().take(6) { + let _ = write!(directory_id, "{byte:02x}"); + } + + (directory_name, directory_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_directory_name_is_parent_basename() { + // /tmp/test does not exist; the helper falls back to the path as given. + let (name, _id) = directory_identity(&PathBuf::from("/home/acme/.tickets")); + assert_eq!(name, "acme"); + } + + #[test] + fn test_directory_id_is_12_hex_chars() { + let (_name, id) = directory_identity(&PathBuf::from("/home/acme/.tickets")); + assert_eq!(id.len(), 12); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_directory_id_is_stable_for_same_path() { + let p = PathBuf::from("/home/acme/.tickets"); + let (_n1, id1) = directory_identity(&p); + let (_n2, id2) = directory_identity(&p); + assert_eq!(id1, id2); + } + + #[test] + fn test_directory_id_differs_for_different_paths() { + // Same basename, different absolute path -> different id (no cross-wiring). + let (n1, id1) = directory_identity(&PathBuf::from("/work/acme/.tickets")); + let (n2, id2) = directory_identity(&PathBuf::from("/clients/acme/.tickets")); + assert_eq!(n1, n2, "basenames intentionally match"); + assert_ne!(id1, id2, "ids must distinguish the two projects"); + } + + #[test] + fn test_directory_id_does_not_contain_path() { + let (_name, id) = directory_identity(&PathBuf::from("/home/secretuser/acme/.tickets")); + assert!(!id.contains("secretuser")); + assert!(!id.contains('/')); + } +} diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index 46ea8055..3f961de2 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -13,6 +13,10 @@ use utoipa::ToSchema; pub struct HealthResponse { pub status: String, pub version: String, + /// Top-level directory name of the operator working root (e.g. "acme"). + pub directory_name: String, + /// Non-reversible fingerprint of the working root's canonical path. + pub directory_id: String, } /// Status response with registry info @@ -21,6 +25,10 @@ pub struct HealthResponse { pub struct StatusResponse { pub status: String, pub version: String, + /// Top-level directory name of the operator working root (e.g. "acme"). + pub directory_name: String, + /// Non-reversible fingerprint of the working root's canonical path. + pub directory_id: String, pub issuetype_count: usize, pub collection_count: usize, pub active_collection: String, diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index bdcee100..f14fb459 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -117,6 +117,10 @@ pub struct DelegatorResponse { /// Optional launch configuration #[serde(skip_serializing_if = "Option::is_none")] pub launch_config: Option, + /// Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + /// set, the delegator is export-only and cannot be launched locally. + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_agent: Option, } /// Request to create a new delegator @@ -141,6 +145,10 @@ pub struct CreateDelegatorRequest { /// Optional launch configuration #[serde(default)] pub launch_config: Option, + /// Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). When + /// set, the delegator is export-only and cannot be launched locally. + #[serde(default)] + pub remote_agent: Option, } /// Launch configuration DTO for delegators @@ -313,6 +321,27 @@ pub struct ModelServerKindEntry { pub icon: String, /// Whether this is an implicit vendor builtin (always present, not deletable) pub is_builtin: bool, + /// Provider-class slug grouping kinds within the Model Provider vertical + /// ("first-party" or "gateway"). Distinct from `is_builtin`. (Field name + /// kept as `category` for wire/TS stability.) + pub category: String, + /// Human-friendly provider-class group header (e.g. "First-party") + pub category_label: String, + /// Brand-icon basename (e.g. "ollama") for surfaces that render vendor + /// logos, or `None` to fall back to the `icon` codicon. + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_icon: Option, + /// Default inference base URL used to probe this provider's models when no + /// instance declares one, or `None` for bring-your-own-endpoint kinds. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_base_url: Option, + /// Default env var the probe reads to authenticate (e.g. `ANTHROPIC_API_KEY`), + /// or `None` when no key is needed by default. + #[serde(skip_serializing_if = "Option::is_none")] + pub default_api_key_env: Option, + /// Whether operator can probe this provider from built-in defaults (it has a + /// `default_base_url`) without the user first declaring an instance. + pub connectable: bool, } /// A single model offered by a server (from a live probe). diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index a597718d..26b6ccc1 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -11,6 +11,7 @@ pub mod configuration; pub mod issue_types; pub mod kanban; pub mod sections; +pub mod tickets; pub mod workflow; pub use agents::*; @@ -18,6 +19,7 @@ pub use configuration::*; pub use issue_types::*; pub use kanban::*; pub use sections::*; +pub use tickets::*; pub use workflow::*; #[cfg(test)] diff --git a/src/rest/dto/sections.rs b/src/rest/dto/sections.rs index c1ecfaa7..a694c27e 100644 --- a/src/rest/dto/sections.rs +++ b/src/rest/dto/sections.rs @@ -48,6 +48,10 @@ pub struct SectionRowDto { pub description: String, /// Icon hint (e.g. "check", "warning", "tool", "folder"). pub icon: String, + /// Optional vendor-brand basename (e.g. "ollama"). When set, the web UI + /// renders `/icons/{brand_icon}.svg` instead of the semantic `icon`. + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_icon: Option, /// Health: "green" | "yellow" | "red" | "gray". pub health: String, /// Browser-openable actions for this row (links shown in the web UI). diff --git a/src/rest/dto/tickets.rs b/src/rest/dto/tickets.rs new file mode 100644 index 00000000..e3f4c794 --- /dev/null +++ b/src/rest/dto/tickets.rs @@ -0,0 +1,67 @@ +//! DTOs for ticket creation and external alerts. +//! +//! These power external automation surfaces (e.g. the AGNT `operator-plugin`), +//! which create tickets and raise investigations over HTTP rather than via the +//! MCP server or CLI. + +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +/// Request to create a new ticket from a template. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateTicketRequest { + /// Template type key (feature, fix, spike, investigation, task). + pub template: String, + /// Project the ticket targets (filled into the template's `project` value). + #[serde(default)] + pub project: Option, + /// One-line summary (filled into the template's `summary` value). + #[serde(default)] + pub summary: Option, + /// Additional Handlebars values for the template. Explicit `project`/ + /// `summary` fields take precedence over the same keys here. + #[serde(default)] + pub values: HashMap, +} + +/// Response after creating a ticket. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateTicketResponse { + /// The created ticket's id (e.g. `FEAT-1234`). + pub id: String, + /// The ticket filename written to the queue. + pub filename: String, + /// The absolute path the ticket was written to. + pub path: String, +} + +/// Request to raise an external alert as an investigation ticket. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateAlertRequest { + /// Where the alert came from (e.g. `pagerduty`, `sentry`). + pub source: String, + /// The alert message / summary. + pub message: String, + /// Severity label (e.g. `S1`, `S2`). + pub severity: String, + /// Optional project the investigation targets. + #[serde(default)] + pub project: Option, +} + +/// Response after raising an alert. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreateAlertResponse { + /// The created investigation ticket's id. + pub id: String, + /// The investigation ticket filename written to the queue. + pub filename: String, +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 19a1ca06..3d47be46 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -18,6 +18,7 @@ use utoipa_axum::router::OpenApiRouter; use utoipa_axum::routes; use utoipa_swagger_ui::SwaggerUi; +pub mod directory; pub mod dto; pub mod error; pub mod openapi; @@ -50,7 +51,7 @@ pub mod web_ui { } pub use openapi::ApiDoc; -pub use server::{ApiSessionInfo, RestApiServer, RestApiStatus}; +pub use server::{ApiSessionInfo, ExternalApiProbe, RestApiServer, RestApiStatus}; pub use state::ApiState; /// Default port for the REST API server @@ -111,7 +112,10 @@ fn documented_router() -> OpenApiRouter { .routes(routes!(routes::projects::assess)) // Ticket endpoints .routes(routes!(routes::tickets::get_one)) + .routes(routes!(routes::tickets::create)) .routes(routes!(routes::tickets::update_status)) + // External alert -> investigation + .routes(routes!(routes::tickets::create_alert)) // Launch endpoints .routes(routes!(routes::launch::launch_ticket)) // Workflow export endpoint @@ -145,6 +149,10 @@ fn documented_router() -> OpenApiRouter { routes::delegators::create )) .routes(routes!(routes::delegators::create_from_tool)) + // AgentProfile interchange (import is a distinct static path; export is a + // static suffix on the `{name}` param path — neither collides with CRUD). + .routes(routes!(routes::delegators::import_profile)) + .routes(routes!(routes::delegators::export_profile)) .routes(routes!( routes::delegators::get_one, routes::delegators::update, @@ -161,6 +169,7 @@ fn documented_router() -> OpenApiRouter { routes::model_servers::create )) .routes(routes!(routes::model_servers::kinds)) + .routes(routes!(routes::model_servers::kind_models)) .routes(routes!(routes::model_servers::models)) .routes(routes!( routes::model_servers::get_one, @@ -177,8 +186,16 @@ fn documented_router() -> OpenApiRouter { /// Built from [`documented_router`] so it always reflects the mounted routes. /// Config-gated MCP transport routes are omitted (they carry no /// `#[utoipa::path]` and only ever exist when `[mcp].http_enabled`). +/// +/// The `info.version` is stamped here from `CARGO_PKG_VERSION` — the compiled +/// release version that CI writes into `Cargo.toml`/`VERSION` on every release. +/// This is the single source of version truth for *every* consumer (served +/// swagger-ui, generated `docs/schemas/openapi.json`, and `ApiDoc::json/yaml`), +/// so the spec version always matches `/api/v1/health` and the published release. pub fn openapi_spec() -> utoipa::openapi::OpenApi { - documented_router().split_for_parts().1 + let mut spec = documented_router().split_for_parts().1; + spec.info.version = env!("CARGO_PKG_VERSION").to_string(); + spec } /// Build the API router with all routes @@ -190,7 +207,7 @@ pub fn build_router(state: ApiState) -> Router { let mcp_enabled = state.config.mcp.http_enabled; - let (mut router, api) = documented_router().split_for_parts(); + let (mut router, _api) = documented_router().split_for_parts(); // MCP transport endpoints — gated by [mcp].http_enabled and intentionally // undocumented (no OpenAPI schema for the SSE/JSON-RPC transport). @@ -211,7 +228,9 @@ pub fn build_router(state: ApiState) -> Router { ) .layer(cors) .with_state(state) - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api)); + // Serve the version-stamped spec (not the raw `_api` half) so swagger-ui + // reports the release version, matching /api/v1/health. + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_spec())); #[cfg(feature = "embed-ui")] let router = router.fallback(web_ui::spa_handler); @@ -222,8 +241,9 @@ 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 host_ip = state.config.rest_api.host_ip(); let app = build_router(state); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::new(host_ip, port); tracing::info!("REST API listening on http://{}", addr); tracing::info!("Swagger UI available at http://{}/swagger-ui", addr); diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index be0d33ff..9a58c212 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -5,8 +5,9 @@ use utoipa::OpenApi; use crate::mcp::descriptor::McpDescriptorResponse; use crate::rest::dto::{ ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, AssessTicketResponse, - CollectionResponse, CreateDelegatorFromToolRequest, CreateDelegatorRequest, CreateFieldRequest, - CreateIssueTypeRequest, CreateModelServerRequest, CreateStepRequest, DefaultLlmResponse, + CollectionResponse, CreateAlertRequest, CreateAlertResponse, CreateDelegatorFromToolRequest, + CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateModelServerRequest, + CreateStepRequest, CreateTicketRequest, CreateTicketResponse, DefaultLlmResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, ExternalIssueTypeSummary, FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, @@ -19,9 +20,11 @@ use crate::rest::dto::{ StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, - ValidateKanbanCredentialsResponse, WorkflowExportResponse, WriteKanbanConfigRequest, - WriteKanbanConfigResponse, + ValidateKanbanCredentialsResponse, WorkflowExportResponse, WorkflowPreviewResponse, + WriteKanbanConfigRequest, WriteKanbanConfigResponse, }; +// AgentProfile interchange types live in `crate::config`, not `rest::dto`. +use crate::config::{AgentProfile, DelegatorLaunchConfig, RemoteAgentRef, XOperator}; use crate::rest::error::ErrorResponse; /// OpenAPI documentation for the Operator REST API @@ -29,11 +32,14 @@ use crate::rest::error::ErrorResponse; #[openapi( info( title = "Operator API", - version = "0.1.6", - description = "REST API for managing issue types and collections in the Operator system.", + // NOTE: no `version` here on purpose. The version is stamped at runtime + // from `CARGO_PKG_VERSION` in `crate::rest::openapi_spec`, the single + // source of truth, so it always matches the published release and + // `/api/v1/health`. A hardcoded literal here would silently go stale. + description = "REST API for managing and running Operator server system.", license(name = "MIT"), contact( - name = "gbqr.us", + name = "untra", url = "https://github.com/untra/operator" ) ), @@ -71,6 +77,11 @@ use crate::rest::error::ErrorResponse; CreateDelegatorRequest, CreateDelegatorFromToolRequest, DelegatorLaunchConfigDto, + // AgentProfile interchange types + AgentProfile, + XOperator, + RemoteAgentRef, + DelegatorLaunchConfig, // Model server types ModelServerResponse, ModelServersResponse, @@ -86,8 +97,14 @@ use crate::rest::error::ErrorResponse; TicketDetailResponse, UpdateTicketStatusRequest, UpdateTicketStatusResponse, + CreateTicketRequest, + CreateTicketResponse, + CreateAlertRequest, + CreateAlertResponse, // Workflow export types WorkflowExportResponse, + WorkflowPreviewResponse, + crate::workflow_gen::WorkflowFormat, // MCP types McpDescriptorResponse, // Queue types @@ -154,22 +171,19 @@ impl ApiDoc { /// /// Sourced from the fully-mounted router via [`crate::rest::openapi_spec`] /// so every live route appears in the spec (the bare `ApiDoc` derive carries - /// only info/components/tags — paths self-register on mount). The version is - /// automatically derived from Cargo.toml to stay in sync. + /// only info/components/tags — paths self-register on mount). `openapi_spec` + /// also stamps `info.version` from `CARGO_PKG_VERSION`, so it stays in sync + /// with the release version and `/api/v1/health`. pub fn json() -> Result { - let mut spec = crate::rest::openapi_spec(); - spec.info.version = env!("CARGO_PKG_VERSION").to_string(); - serde_json::to_string_pretty(&spec) + serde_json::to_string_pretty(&crate::rest::openapi_spec()) } /// Generate the OpenAPI specification as a YAML string. /// - /// The version is automatically derived from Cargo.toml to stay in sync. + /// The version is stamped from `CARGO_PKG_VERSION` by [`crate::rest::openapi_spec`]. #[allow(dead_code)] pub fn yaml() -> Result { - let mut spec = crate::rest::openapi_spec(); - spec.info.version = env!("CARGO_PKG_VERSION").to_string(); - serde_yaml::to_string(&spec) + serde_yaml::to_string(&crate::rest::openapi_spec()) } } @@ -262,4 +276,34 @@ mod tests { "OpenAPI version should match Cargo.toml version ({cargo_version}), but spec contains different version" ); } + + #[test] + fn test_served_openapi_spec_version_matches_cargo() { + // The version served by swagger-ui (`/api-docs/openapi.json`) comes from + // `openapi_spec()`, NOT `ApiDoc::json()`. Guard the served path directly + // so a stale hardcoded literal in the derive can never resurface and make + // swagger-ui disagree with `/api/v1/health`. + let spec = crate::rest::openapi_spec(); + assert_eq!( + spec.info.version, + env!("CARGO_PKG_VERSION"), + "served OpenAPI spec version must match the compiled release version" + ); + } + + #[test] + fn test_version_file_matches_cargo_version() { + // CI computes the release version, then writes it to BOTH `VERSION` and + // `Cargo.toml` before building (see .github/workflows/build.yaml). This + // asserts they never drift, so the version every API surface reports + // (`CARGO_PKG_VERSION`) is exactly the published release version. + let version_file = concat!(env!("CARGO_MANIFEST_DIR"), "/VERSION"); + let file_version = std::fs::read_to_string(version_file) + .expect("VERSION file should exist at the crate root"); + assert_eq!( + file_version.trim(), + env!("CARGO_PKG_VERSION"), + "VERSION file must match Cargo.toml version so the reported API version matches the published release" + ); + } } diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 65b49386..090b061e 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -8,7 +8,10 @@ use axum::{ Json, }; -use crate::config::{Config, Delegator, DelegatorLaunchConfig}; +use crate::config::{ + agent_profile::{delegator_to_profile, profile_to_delegator, AgentProfile}, + Config, Delegator, DelegatorLaunchConfig, +}; use crate::rest::dto::{ CreateDelegatorFromToolRequest, CreateDelegatorRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, @@ -97,6 +100,10 @@ pub async fn create( model_properties: req.model_properties, model_server: req.model_server, launch_config: req.launch_config.map(dto_to_launch_config), + remote_agent: req.remote_agent, + x_agnt: None, + x_openai: None, + unmapped_core: None, }; // Read current config, add delegator, save @@ -186,6 +193,7 @@ fn delegator_to_response(d: &Delegator) -> DelegatorResponse { model_properties: d.model_properties.clone(), model_server: d.model_server.clone(), launch_config: d.launch_config.as_ref().map(launch_config_to_dto), + remote_agent: d.remote_agent.clone(), } } @@ -245,6 +253,10 @@ pub async fn create_from_tool( model_properties: std::collections::HashMap::new(), model_server: req.model_server.clone(), launch_config: req.launch_config.map(dto_to_launch_config), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }; // Save to config @@ -277,10 +289,14 @@ pub async fn update( Path(name): Path, Json(req): Json, ) -> Result, ApiError> { - // Verify the delegator exists - if !state.config.delegators.iter().any(|d| d.name == name) { - return Err(ApiError::NotFound(format!("Delegator '{name}' not found"))); - } + // Verify the delegator exists, and capture its opaque AGNT carry-fields so an + // update through the (AGNT-unaware) request DTO doesn't drop them. + let existing = state + .config + .delegators + .iter() + .find(|d| d.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Delegator '{name}' not found")))?; let updated = Delegator { name: name.clone(), @@ -290,6 +306,10 @@ pub async fn update( model_properties: req.model_properties, model_server: req.model_server, launch_config: req.launch_config.map(dto_to_launch_config), + remote_agent: req.remote_agent, + x_agnt: existing.x_agnt.clone(), + x_openai: existing.x_openai.clone(), + unmapped_core: existing.unmapped_core.clone(), }; // Replace in config and save @@ -304,6 +324,80 @@ pub async fn update( Ok(Json(delegator_to_response(&updated))) } +/// Export a delegator as a portable `AgentProfile` (`agent-profile.json`). +/// +/// The shared core plus the Operator-namespaced `x_operator` bag; any opaque +/// AGNT (`x_agnt`) and shared-core carry the delegator holds are restored so the +/// profile is a lossless interchange artifact. +#[utoipa::path( + operation_id = "delegators_export_profile", + get, + path = "/api/v1/delegators/{name}/profile", + tag = "Delegators", + params( + ("name" = String, Path, description = "Delegator name") + ), + responses( + (status = 200, description = "Agent profile", body = AgentProfile), + (status = 404, description = "Delegator not found") + ) +)] +pub async fn export_profile( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let delegator = state + .config + .delegators + .iter() + .find(|d| d.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Delegator '{name}' not found")))?; + + Ok(Json(delegator_to_profile(delegator))) +} + +/// Import an `AgentProfile` as a new delegator. +/// +/// The shared-core fields Operator can't model and the opaque `x_agnt` bag are +/// preserved on the created delegator so a later export round-trips losslessly. +#[utoipa::path( + operation_id = "delegators_import_profile", + post, + path = "/api/v1/delegators/import-profile", + tag = "Delegators", + request_body = AgentProfile, + responses( + (status = 200, description = "Delegator created from profile", body = DelegatorResponse), + (status = 409, description = "Delegator already exists") + ) +)] +pub async fn import_profile( + State(state): State, + Json(profile): Json, +) -> Result, ApiError> { + if state + .config + .delegators + .iter() + .any(|d| d.name == profile.name) + { + return Err(ApiError::Conflict(format!( + "Delegator '{}' already exists", + profile.name + ))); + } + + let delegator = profile_to_delegator(&profile); + + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + config.delegators.push(delegator.clone()); + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(delegator_to_response(&delegator))) +} + #[cfg(test)] mod tests { use super::*; @@ -331,6 +425,10 @@ mod tests { model_properties: std::collections::HashMap::new(), model_server: None, launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); @@ -364,6 +462,10 @@ mod tests { flags: vec![], ..Default::default() }), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); @@ -396,6 +498,10 @@ mod tests { prompt_suffix: Some("Run tests before finishing.".to_string()), operator_relay: None, }), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); let state = ApiState::new(config, PathBuf::from("/tmp/test")); @@ -432,6 +538,82 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn export_profile_returns_agent_profile() { + let mut config = Config::default(); + config.delegators.push(Delegator { + name: "claude-opus".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: Some("Claude Opus".to_string()), + model_properties: std::collections::HashMap::new(), + model_server: None, + launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let resp = export_profile(State(state), Path("claude-opus".to_string())) + .await + .expect("export ok"); + assert_eq!(resp.name, "claude-opus"); + assert_eq!(resp.provider, "claude"); + assert_eq!(resp.model, "opus"); + assert_eq!( + resp.x_operator.as_ref().unwrap().display_name.as_deref(), + Some("Claude Opus") + ); + } + + #[tokio::test] + async fn import_profile_conflicts_on_existing_name() { + // The happy path calls Config::save() (a fixed global path), so — like the + // create() tests — we only exercise the pre-save conflict branch here. The + // profile→delegator conversion (incl. x_agnt/shared-core preservation) is + // covered by the unit tests in `config::agent_profile`. + let mut config = Config::default(); + config.delegators.push(Delegator { + name: "dup".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + model_server: None, + launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let profile = AgentProfile { + name: "dup".to_string(), + provider: "anthropic".to_string(), + model: "claude-3-5-sonnet".to_string(), + system_prompt: None, + skills: vec![], + mcp_servers: vec![], + tools: vec![], + remote_agent: Some(crate::config::RemoteAgentRef { + platform: "agnt".to_string(), + id: "Research Assistant".to_string(), + }), + x_operator: None, + x_agnt: Some(serde_json::json!({ "creditLimit": 1000 })), + x_openai: None, + }; + + let result = import_profile(State(state), Json(profile)).await; + assert!( + matches!(result, Err(ApiError::Conflict(_))), + "importing a profile whose name already exists must 409" + ); + } + #[test] fn test_dto_round_trips_operator_relay() { let config = DelegatorLaunchConfig { diff --git a/src/rest/routes/health.rs b/src/rest/routes/health.rs index 6f437ce0..21b4c0ff 100644 --- a/src/rest/routes/health.rs +++ b/src/rest/routes/health.rs @@ -2,6 +2,7 @@ use axum::{extract::State, Json}; +use crate::rest::directory::directory_identity; use crate::rest::dto::{HealthResponse, StatusResponse}; use crate::rest::state::ApiState; @@ -15,10 +16,13 @@ use crate::rest::state::ApiState; (status = 200, description = "Service is healthy", body = HealthResponse) ) )] -pub async fn health() -> Json { +pub async fn health(State(state): State) -> Json { + let (directory_name, directory_id) = directory_identity(&state.tickets_path); Json(HealthResponse { status: "ok".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), + directory_name, + directory_id, }) } @@ -33,11 +37,14 @@ pub async fn health() -> Json { ) )] pub async fn status(State(state): State) -> Json { + let (directory_name, directory_id) = directory_identity(&state.tickets_path); let registry = state.registry.read().await; Json(StatusResponse { status: "ok".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), + directory_name, + directory_id, issuetype_count: registry.type_count(), collection_count: registry.collection_count(), active_collection: registry.active_collection_name().to_string(), @@ -50,9 +57,18 @@ mod tests { #[tokio::test] async fn test_health() { - let resp = health().await; + use crate::config::Config; + use std::path::PathBuf; + + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/home/acme/.tickets")); + + let resp = health(State(state)).await; assert_eq!(resp.status, "ok"); assert!(!resp.version.is_empty()); + // Directory identity is derived from the working root (parent of .tickets). + assert_eq!(resp.directory_name, "acme"); + assert_eq!(resp.directory_id.len(), 12); } #[tokio::test] @@ -66,5 +82,6 @@ mod tests { let resp = status(State(state)).await; assert_eq!(resp.status, "ok"); assert!(resp.issuetype_count >= 5); // At least builtins + assert_eq!(resp.directory_id.len(), 12); } } diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 96e76af0..b54684d5 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -517,6 +517,10 @@ mod tests { prompt_suffix: Some("SUFFIX".to_string()), operator_relay: None, }), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); @@ -556,6 +560,10 @@ mod tests { model_properties: std::collections::HashMap::new(), model_server: None, launch_config: Some(crate::config::DelegatorLaunchConfig::default()), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }); let state = ApiState::new(config, PathBuf::from("/tmp/test-launch")); @@ -601,6 +609,10 @@ mod tests { model_properties: std::collections::HashMap::new(), model_server: None, launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, } } @@ -734,6 +746,10 @@ mod tests { prompt_suffix: Some("END".to_string()), operator_relay: None, }), + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, }]); let ctx = AgentContext { step_agent: Some("codex-auto".to_string()), diff --git a/src/rest/routes/model_servers.rs b/src/rest/routes/model_servers.rs index ff83bed0..510a2f3d 100644 --- a/src/rest/routes/model_servers.rs +++ b/src/rest/routes/model_servers.rs @@ -322,11 +322,76 @@ pub async fn kinds() -> Json> { setup_url: k.setup_url().to_string(), icon: k.icon().to_string(), is_builtin: k.is_builtin(), + category: k.provider_class().slug().to_string(), + category_label: k.provider_class().display_name().to_string(), + brand_icon: k.brand_icon().map(str::to_string), + default_base_url: k.default_base_url().map(str::to_string), + default_api_key_env: k.default_api_key_env().map(str::to_string), + connectable: k.connectable_from_defaults(), }) .collect(), ) } +/// List the models a *provider kind* offers, via a live probe. +/// +/// Resolves to the declared instance of that kind (if the user has one) else a +/// transient instance built from the kind's probe defaults — so the Model +/// Providers catalog can show connection state + live models for every supported +/// provider without first declaring one. `reachable` doubles as "connected". +#[utoipa::path( + operation_id = "model_servers_kind_models", + get, + path = "/api/v1/model-servers/kinds/{slug}/models", + tag = "ModelServers", + params( + ("slug" = String, Path, description = "Model-provider kind slug (e.g. \"anthropic-api\")") + ), + responses( + (status = 200, description = "Models offered by the provider", body = ModelServerModelsResponse), + (status = 404, description = "Unknown provider kind") + ) +)] +pub async fn kind_models( + State(state): State, + Path(slug): Path, +) -> Result, ApiError> { + let kind = ModelServerKind::from_slug(&slug) + .ok_or_else(|| ApiError::NotFound(format!("Unknown provider kind '{slug}'")))?; + + // Prefer a user-declared instance of this kind; otherwise probe from the + // kind's built-in defaults (the probe fills in base_url/api_key_env). + let server = state + .config + .model_servers + .iter() + .find(|s| s.kind == slug) + .cloned() + .unwrap_or_else(|| ModelServer { + name: slug.clone(), + kind: slug.clone(), + base_url: None, + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + }); + + let outcome = probe_models(&server).await; + Ok(Json(ModelServerModelsResponse { + server: kind.slug().to_string(), + reachable: outcome.reachable, + models: outcome + .models + .into_iter() + .map(|m| ModelEntry { + id: m.id, + display_name: m.display_name, + }) + .collect(), + error: outcome.error, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -384,23 +449,61 @@ mod tests { let resp = kinds().await; let slugs: Vec<&str> = resp.0.iter().map(|k| k.slug.as_str()).collect(); assert!(slugs.contains(&"ollama")); + assert!(slugs.contains(&"openrouter")); assert!(slugs.contains(&"openai-compat")); assert!(slugs.contains(&"anthropic-api")); - // Vendor APIs are flagged builtin. + // First-party providers are flagged builtin and carry the first-party class. let anthropic = resp.0.iter().find(|k| k.slug == "anthropic-api").unwrap(); assert!(anthropic.is_builtin); + assert_eq!(anthropic.category, "first-party"); + // Probe-connectable from defaults, with the standard key env surfaced. + assert!(anthropic.connectable); + assert_eq!( + anthropic.default_base_url.as_deref(), + Some("https://api.anthropic.com") + ); + assert_eq!( + anthropic.default_api_key_env.as_deref(), + Some("ANTHROPIC_API_KEY") + ); + // Gateways (ollama, openrouter, …) are not builtins. let ollama = resp.0.iter().find(|k| k.slug == "ollama").unwrap(); assert!(!ollama.is_builtin); + assert_eq!(ollama.category, "gateway"); + let openrouter = resp.0.iter().find(|k| k.slug == "openrouter").unwrap(); + assert_eq!(openrouter.category, "gateway"); + assert_eq!(openrouter.category_label, "Gateways"); + // openai-compat is bring-your-own-endpoint: not connectable from defaults. + let compat = resp.0.iter().find(|k| k.slug == "openai-compat").unwrap(); + assert!(!compat.connectable); + // Brand icons: anthropic/google/ollama/openrouter carry one; openai-api + // falls back to a codicon. + assert_eq!(openrouter.brand_icon.as_deref(), Some("openrouter")); + assert_eq!(ollama.brand_icon.as_deref(), Some("ollama")); + assert_eq!(anthropic.brand_icon.as_deref(), Some("anthropic")); + let openai = resp.0.iter().find(|k| k.slug == "openai-api").unwrap(); + assert_eq!(openai.brand_icon, None); } #[tokio::test] - async fn test_models_builtin_without_base_url_is_unreachable() { - let config = Config::default(); + async fn test_models_unreachable_endpoint_reports_error() { + // Probe a declared server pointing at a closed local port — deterministic + // and offline (connection refused), exercising the unreachable path + // without any external network dependency. + let mut config = Config::default(); + config.model_servers.push(ModelServer { + name: "closed-port".into(), + kind: "openai-compat".into(), + base_url: Some("http://127.0.0.1:1".into()), + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + }); let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-models")); - let resp = models(State(state), Path("anthropic-api".to_string())) + let resp = models(State(state), Path("closed-port".to_string())) .await - .expect("builtin resolves"); - assert_eq!(resp.0.server, "anthropic-api"); + .expect("declared server resolves"); + assert_eq!(resp.0.server, "closed-port"); assert!(!resp.0.reachable); assert!(resp.0.models.is_empty()); assert!(resp.0.error.is_some()); @@ -421,4 +524,51 @@ mod tests { let resp = get_one(State(state), Path("nope".to_string())).await; assert!(resp.is_err()); } + + #[tokio::test] + async fn test_kind_models_404_on_unknown_kind() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-km-404")); + let resp = kind_models(State(state), Path("nope".to_string())).await; + assert!(resp.is_err()); + } + + #[tokio::test] + async fn test_kind_models_bring_your_own_endpoint_unreachable_from_defaults() { + // `openai-compat` has no default base_url and no declared instance, so a + // kind-level probe has nowhere to connect — reachable:false, offline. + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-km-byo")); + let resp = kind_models(State(state), Path("openai-compat".to_string())) + .await + .expect("known kind resolves"); + // Response is keyed by the kind slug (not an instance name). + assert_eq!(resp.0.server, "openai-compat"); + assert!(!resp.0.reachable); + assert!(resp.0.models.is_empty()); + assert!(resp.0.error.is_some()); + } + + #[tokio::test] + async fn test_kind_models_prefers_declared_instance() { + // A declared instance of the kind is probed in preference to defaults. + // Point it at a closed local port: deterministic, offline (conn refused). + let mut config = Config::default(); + config.model_servers.push(ModelServer { + name: "my-vllm".into(), + kind: "openai-compat".into(), + base_url: Some("http://127.0.0.1:1".into()), + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + }); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-km-declared")); + let resp = kind_models(State(state), Path("openai-compat".to_string())) + .await + .expect("known kind resolves"); + // Still keyed by the kind slug, and unreachable (the closed port refuses). + assert_eq!(resp.0.server, "openai-compat"); + assert!(!resp.0.reachable); + assert!(resp.0.error.is_some()); + } } diff --git a/src/rest/routes/tickets.rs b/src/rest/routes/tickets.rs index 747917eb..5fe4e8d7 100644 --- a/src/rest/routes/tickets.rs +++ b/src/rest/routes/tickets.rs @@ -3,17 +3,22 @@ //! Provides endpoints for fetching ticket details and updating ticket status. //! These endpoints power the embedded web UI's kanban board and detail drawer. +use std::collections::HashMap; + use axum::{ extract::{Path, State}, Json, }; +use crate::queue::creator::TicketCreator; use crate::queue::{Queue, Ticket}; use crate::rest::dto::{ + CreateAlertRequest, CreateAlertResponse, CreateTicketRequest, CreateTicketResponse, TicketDetailResponse, UpdateTicketStatusRequest, UpdateTicketStatusResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; +use crate::templates::TemplateType; /// Find a ticket across all directories (queue, in-progress, completed) pub(crate) fn find_ticket_anywhere(queue: &Queue, ticket_id: &str) -> Result { @@ -167,6 +172,127 @@ pub async fn update_status( })) } +/// Create a new ticket from a template and write it to the queue. +/// +/// Reuses the same [`TicketCreator`] the CLI (`operator create`) and MCP +/// (`operator_create_ticket`) use, so a ticket created over HTTP is identical to +/// one created on any other surface. Powers the AGNT `operator-create-ticket` +/// node. +#[utoipa::path( + operation_id = "tickets_create", + post, + path = "/api/v1/tickets", + tag = "Tickets", + request_body = CreateTicketRequest, + responses( + (status = 200, description = "Ticket created", body = CreateTicketResponse), + (status = 400, description = "Unknown template type", body = crate::rest::error::ErrorResponse) + ) +)] +pub async fn create( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let CreateTicketRequest { + template, + project, + summary, + mut values, + } = request; + + let template_type = TemplateType::from_key(&template) + .ok_or_else(|| ApiError::BadRequest(format!("Unknown template type: {template}")))?; + + // Explicit fields take precedence over the same keys in `values`. + if let Some(p) = project { + values.insert("project".to_string(), p); + } + if let Some(s) = summary { + values.insert("summary".to_string(), s); + } + + let (ticket, path) = create_ticket_from_values(&state, template_type, values).await?; + + Ok(Json(CreateTicketResponse { + id: ticket.id, + filename: ticket.filename, + path: path.to_string_lossy().into_owned(), + })) +} + +/// Shared ticket-creation core for the REST surface: overlays caller `values` +/// on top of the template's generated defaults (so the ticket gets a real id, +/// branch, etc.), writes it to the queue via the embedded-template +/// [`TicketCreator`], and reloads it. Used by both `create` and `create_alert`. +async fn create_ticket_from_values( + state: &ApiState, + template_type: TemplateType, + values: HashMap, +) -> Result<(Ticket, std::path::PathBuf), ApiError> { + let config = (*state.config).clone(); + let path = tokio::task::spawn_blocking(move || -> Result { + let creator = TicketCreator::new(&config); + let project = values.get("project").cloned().unwrap_or_default(); + // Start from generated defaults (id, created, branch, step, status), + // then overlay the caller's values so explicit fields win. + let mut merged = creator.generate_default_values(template_type, &project); + merged.extend(values); + creator + .create_ticket_headless(template_type, &merged) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))? + .map_err(ApiError::InternalError)?; + + let ticket = Ticket::from_file(&path).map_err(|e| ApiError::InternalError(e.to_string()))?; + Ok((ticket, path)) +} + +/// Raise an external alert as an investigation (INV) ticket. +/// +/// Creates an investigation through the same embedded-template path as +/// [`create`], folding the alert's `source`/`severity` into the ticket values. +/// Powers the AGNT `operator-alert` node. +#[utoipa::path( + operation_id = "alerts_create", + post, + path = "/api/v1/alerts", + tag = "Tickets", + request_body = CreateAlertRequest, + responses( + (status = 200, description = "Investigation created", body = CreateAlertResponse), + (status = 500, description = "Failed to create investigation", body = crate::rest::error::ErrorResponse) + ) +)] +pub async fn create_alert( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let CreateAlertRequest { + source, + message, + severity, + project, + } = request; + + let mut values = HashMap::new(); + values.insert("summary".to_string(), message); + values.insert("source".to_string(), source); + values.insert("severity".to_string(), severity); + if let Some(p) = project { + values.insert("project".to_string(), p); + } + + let (ticket, _path) = + create_ticket_from_values(&state, TemplateType::Investigation, values).await?; + + Ok(Json(CreateAlertResponse { + id: ticket.id, + filename: ticket.filename, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -178,6 +304,12 @@ mod tests { ApiState::new(config, PathBuf::from("/tmp/test-tickets")) } + fn make_state_in(dir: &std::path::Path) -> ApiState { + let mut config = Config::default(); + config.paths.tickets = dir.to_string_lossy().into_owned(); + ApiState::new(config, dir.to_path_buf()) + } + #[test] fn test_valid_statuses() { let valid = ["queued", "running", "awaiting", "done"]; @@ -203,4 +335,58 @@ mod tests { let result = update_status(State(state), Path("FEAT-001".to_string()), Json(request)).await; assert!(result.is_err()); } + + #[tokio::test] + async fn test_create_unknown_template_is_bad_request() { + let state = make_state(); + let request = CreateTicketRequest { + template: "not-a-template".to_string(), + project: None, + summary: None, + values: std::collections::HashMap::new(), + }; + let result = create(State(state), Json(request)).await; + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } + + #[tokio::test] + async fn test_create_ticket_writes_to_queue() { + let temp = tempfile::TempDir::new().unwrap(); + let state = make_state_in(temp.path()); + let request = CreateTicketRequest { + template: "feat".to_string(), + project: Some("gamesvc".to_string()), + summary: Some("Add pagination".to_string()), + values: std::collections::HashMap::new(), + }; + let resp = create(State(state), Json(request)) + .await + .expect("create should succeed") + .0; + assert!(!resp.id.is_empty(), "created ticket has an id"); + assert!(resp.filename.contains(".md"), "filename: {}", resp.filename); + assert!( + std::path::Path::new(&resp.path).exists(), + "ticket file written to {}", + resp.path + ); + } + + #[tokio::test] + async fn test_create_alert_creates_investigation() { + let temp = tempfile::TempDir::new().unwrap(); + let state = make_state_in(temp.path()); + let request = CreateAlertRequest { + source: "pagerduty".to_string(), + message: "500 errors in backend".to_string(), + severity: "S1".to_string(), + project: Some("gamesvc".to_string()), + }; + let resp = create_alert(State(state), Json(request)) + .await + .expect("alert should create an investigation") + .0; + assert!(!resp.id.is_empty(), "investigation has an id"); + assert!(resp.filename.contains(".md"), "filename: {}", resp.filename); + } } diff --git a/src/rest/routes/workflow.rs b/src/rest/routes/workflow.rs index 58c765f9..9ac9627f 100644 --- a/src/rest/routes/workflow.rs +++ b/src/rest/routes/workflow.rs @@ -6,15 +6,25 @@ //! web UI and VS Code extension produce identical output. use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; +use serde::Deserialize; use crate::queue::Queue; use crate::rest::dto::{WorkflowExportResponse, WorkflowPreviewResponse}; use crate::rest::error::ApiError; use crate::rest::routes::tickets::find_ticket_anywhere; use crate::rest::state::ApiState; +use crate::workflow_gen::WorkflowFormat; + +/// Query parameters for the workflow export/preview endpoints. +#[derive(Debug, Default, Deserialize)] +pub struct ExportQuery { + /// Output format: `claude` (default) or `agnt`. + #[serde(default)] + pub format: WorkflowFormat, +} /// Export a ticket to a Claude dynamic workflow. /// @@ -27,7 +37,8 @@ use crate::rest::state::ApiState; path = "/api/v1/tickets/{id}/workflow-export", tag = "Workflow", params( - ("id" = String, Path, description = "Ticket ID (e.g., FEAT-7598)") + ("id" = String, Path, description = "Ticket ID (e.g., FEAT-7598)"), + ("format" = Option, Query, description = "Output format: claude (default) or agnt") ), responses( (status = 200, description = "Generated workflow", body = WorkflowExportResponse), @@ -37,14 +48,20 @@ use crate::rest::state::ApiState; pub async fn export( State(state): State, Path(ticket_id): Path, + Query(query): Query, ) -> Result, ApiError> { let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; let ticket = find_ticket_anywhere(&queue, &ticket_id)?; let registry = state.registry.read().await; - let exported = - crate::workflow_gen::export_workflow_for_ticket(&ticket, ®istry, None, &state.config) - .map_err(|e| ApiError::NotFound(e.to_string()))?; + let exported = crate::workflow_gen::export_workflow_for_ticket( + &ticket, + ®istry, + None, + &state.config, + query.format, + ) + .map_err(|e| ApiError::NotFound(e.to_string()))?; Ok(Json(exported.into())) } @@ -60,7 +77,8 @@ pub async fn export( path = "/api/v1/issuetypes/{key}/workflow-preview", tag = "Workflow", params( - ("key" = String, Path, description = "Issue type key (e.g., FEAT, FIX)") + ("key" = String, Path, description = "Issue type key (e.g., FEAT, FIX)"), + ("format" = Option, Query, description = "Output format: claude (default) or agnt") ), responses( (status = 200, description = "Generated preview workflow", body = WorkflowPreviewResponse), @@ -70,13 +88,14 @@ pub async fn export( pub async fn preview( State(state): State, Path(key): Path, + Query(query): Query, ) -> Result, ApiError> { let registry = state.registry.read().await; let issuetype = registry .get(&key.to_uppercase()) .ok_or_else(|| ApiError::NotFound(format!("Issue type '{key}' not found")))?; - let exported = crate::workflow_gen::export_workflow_for_issuetype(issuetype) + let exported = crate::workflow_gen::export_workflow_for_issuetype(issuetype, query.format) .map_err(|e| ApiError::InternalError(e.to_string()))?; Ok(Json(exported.into())) @@ -88,34 +107,71 @@ mod tests { use crate::config::Config; use std::path::PathBuf; - fn make_state() -> ApiState { + // Each test gets its own tickets path: `ApiState::new` scans/scaffolds the + // registry from that directory, so a shared path races across parallel tests. + fn make_state(name: &str) -> ApiState { ApiState::new( Config::default(), - PathBuf::from("/tmp/test-tickets-workflow"), + PathBuf::from(format!("/tmp/test-tickets-workflow-{name}")), ) } #[tokio::test] async fn export_unknown_ticket_is_not_found() { - let state = make_state(); - let result = export(State(state), Path("NONEXISTENT-999".to_string())).await; + let state = make_state("export-unknown"); + let result = export( + State(state), + Path("NONEXISTENT-999".to_string()), + Query(ExportQuery::default()), + ) + .await; assert!(result.is_err(), "unknown ticket should error"); } #[tokio::test] async fn preview_known_issuetype_returns_workflow() { - let state = make_state(); + let state = make_state("preview-known"); // Lower-case key exercises the to_uppercase() normalization. - let result = preview(State(state), Path("feat".to_string())).await; + let result = preview( + State(state), + Path("feat".to_string()), + Query(ExportQuery::default()), + ) + .await; let body = result.expect("FEAT preview should resolve").0; assert_eq!(body.issuetype_key, "FEAT"); assert!(body.contents.contains("export const meta")); } + #[tokio::test] + async fn preview_agnt_format_returns_json_graph() { + let state = make_state("preview-agnt"); + let result = preview( + State(state), + Path("feat".to_string()), + Query(ExportQuery { + format: WorkflowFormat::Agnt, + }), + ) + .await; + let body = result.expect("FEAT agnt preview should resolve").0; + assert!(body + .suggested_filename + .ends_with(".agnt.preview.workflow.json")); + let v: serde_json::Value = + serde_json::from_str(&body.contents).expect("agnt preview is valid JSON"); + assert!(v["nodes"].is_array()); + } + #[tokio::test] async fn preview_unknown_issuetype_is_not_found() { - let state = make_state(); - let result = preview(State(state), Path("NOPE".to_string())).await; + let state = make_state("preview-unknown"); + let result = preview( + State(state), + Path("NOPE".to_string()), + Query(ExportQuery::default()), + ) + .await; assert!(result.is_err(), "unknown issue type should error"); } } diff --git a/src/rest/server.rs b/src/rest/server.rs index f0b503df..234d48e3 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -60,14 +60,80 @@ pub enum RestApiStatus { Stopped, Starting, Stopping, - Running { port: u16 }, + /// This process owns the server listening on `port`. + Running { + port: u16, + }, + /// Another process already serves a compatible (same version, same project) + /// operator API on `port`; we adopted it instead of starting our own. + RunningExternal { + port: u16, + }, Error(String), } impl RestApiStatus { - /// Returns true if the server is running + /// Returns true if a usable server is available — whether this process owns + /// it (`Running`) or we adopted a compatible external one (`RunningExternal`). pub fn is_running(&self) -> bool { - matches!(self, RestApiStatus::Running { .. }) + matches!( + self, + RestApiStatus::Running { .. } | RestApiStatus::RunningExternal { .. } + ) + } +} + +/// Result of probing an API already listening on the expected port, used to +/// decide whether to adopt it as "connected" or report a clear conflict. +#[derive(Debug, Clone, PartialEq)] +pub enum ExternalApiProbe { + /// A same-version operator API serving the same project — safe to adopt. + AdoptableSameProject { port: u16, version: String }, + /// An operator API of a different version is on the port. + VersionMismatch { found: String }, + /// A same-version operator API serving a *different* project is on the port. + DifferentProject { found_name: String }, + /// Something is listening, but it is not an operator API. + NotOperator, + /// Nothing answered (port held by a process that isn't accepting HTTP, etc.). + Unreachable, +} + +/// Tolerant view of `/api/v1/health` — older or foreign servers may omit fields. +#[derive(Debug, Clone, serde::Deserialize)] +struct ProbeHealth { + #[serde(default)] + version: String, + #[serde(default)] + directory_name: String, + #[serde(default)] + directory_id: String, +} + +/// Decide how to treat an API on the port, given the local identity and the +/// health payload it returned. Pure so the decision is unit-testable. +fn classify_probe( + port: u16, + local_version: &str, + local_directory_id: &str, + health: &ProbeHealth, +) -> ExternalApiProbe { + if health.version.is_empty() { + // Responded, but without a version — not an operator health endpoint. + ExternalApiProbe::NotOperator + } else if health.version != local_version { + ExternalApiProbe::VersionMismatch { + found: health.version.clone(), + } + } else if health.directory_id != local_directory_id { + ExternalApiProbe::DifferentProject { + found_name: health.directory_name.clone(), + } + } else { + ExternalApiProbe::AdoptableSameProject { + port, + version: health.version.clone(), + } } } @@ -128,10 +194,39 @@ impl RestApiServer { /// This is useful for detecting if another operator instance is already running. pub async fn is_port_in_use(&self) -> bool { use std::net::SocketAddr; - let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); + let addr = SocketAddr::new(self.config.rest_api.host_ip(), self.port); tokio::net::TcpListener::bind(addr).await.is_err() } + /// Probe an API already listening on the configured port to decide whether + /// it is a same-version operator serving this project (safe to adopt) or a + /// conflict we should report. Always connects over loopback. + pub async fn probe_external(&self) -> ExternalApiProbe { + let url = format!("http://127.0.0.1:{}/api/v1/health", self.port); + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + { + Ok(c) => c, + Err(_) => return ExternalApiProbe::Unreachable, + }; + + let response = match client.get(&url).send().await { + Ok(r) => r, + Err(_) => return ExternalApiProbe::Unreachable, + }; + + // A 2xx that doesn't deserialize into a health shape is "not operator". + let health = match response.json::().await { + Ok(h) => h, + Err(_) => return ExternalApiProbe::NotOperator, + }; + + let (_local_name, local_id) = + crate::rest::directory::directory_identity(&self.tickets_path); + classify_probe(self.port, env!("CARGO_PKG_VERSION"), &local_id, &health) + } + /// Start the REST API server pub fn start(&self) -> Result<(), String> { if self.is_running() { @@ -146,6 +241,7 @@ impl RestApiServer { *self.api_state.lock().unwrap() = Some(state.clone()); let router = build_router(state); let port = self.port; + let host_ip = self.config.rest_api.host_ip(); let status = self.status.clone(); let tickets_path = self.tickets_path.clone(); let api_state_handle = self.api_state.clone(); @@ -154,7 +250,7 @@ impl RestApiServer { let handle = tokio::spawn(async move { use std::net::SocketAddr; - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::new(host_ip, port); match tokio::net::TcpListener::bind(addr).await { Ok(listener) => { @@ -195,6 +291,19 @@ impl RestApiServer { Ok(()) } + /// Adopt an external server already listening on our port as the active API. + /// Used when a probe found a same-version operator serving this project. + pub fn mark_external(&self) { + *self.status.lock().unwrap() = RestApiStatus::RunningExternal { port: self.port }; + } + + /// Record that the port is occupied by something we cannot adopt (different + /// version, different project, or a non-operator process). Surfaced to the + /// operator via the dashboard's REST API status line. + pub fn mark_conflict(&self, reason: impl Into) { + *self.status.lock().unwrap() = RestApiStatus::Error(reason.into()); + } + /// Stop the REST API server pub fn stop(&self) { // Set stopping status first for visual feedback @@ -244,9 +353,73 @@ mod tests { assert!(!RestApiStatus::Starting.is_running()); assert!(!RestApiStatus::Stopping.is_running()); assert!(RestApiStatus::Running { port: 7008 }.is_running()); + // An adopted external server must also count as running so downstream + // steps (web UI launch, etc.) proceed. + assert!(RestApiStatus::RunningExternal { port: 7008 }.is_running()); assert!(!RestApiStatus::Error("test".to_string()).is_running()); } + fn probe_health(version: &str, dir_id: &str, dir_name: &str) -> ProbeHealth { + ProbeHealth { + version: version.to_string(), + directory_name: dir_name.to_string(), + directory_id: dir_id.to_string(), + } + } + + #[test] + fn test_classify_adoptable_same_version_same_project() { + let h = probe_health("0.2.1", "abc123abc123", "acme"); + let result = classify_probe(7008, "0.2.1", "abc123abc123", &h); + assert_eq!( + result, + ExternalApiProbe::AdoptableSameProject { + port: 7008, + version: "0.2.1".to_string() + } + ); + } + + #[test] + fn test_classify_version_mismatch() { + let h = probe_health("0.3.0", "abc123abc123", "acme"); + let result = classify_probe(7008, "0.2.1", "abc123abc123", &h); + assert_eq!( + result, + ExternalApiProbe::VersionMismatch { + found: "0.3.0".to_string() + } + ); + } + + #[test] + fn test_classify_different_project() { + // Same version, different directory id -> must NOT adopt. + let h = probe_health("0.2.1", "deadbeef0000", "other-repo"); + let result = classify_probe(7008, "0.2.1", "abc123abc123", &h); + assert_eq!( + result, + ExternalApiProbe::DifferentProject { + found_name: "other-repo".to_string() + } + ); + } + + #[test] + fn test_classify_not_operator_when_no_version() { + let h = probe_health("", "", ""); + let result = classify_probe(7008, "0.2.1", "abc123abc123", &h); + assert_eq!(result, ExternalApiProbe::NotOperator); + } + + #[tokio::test] + async fn test_probe_external_unreachable_when_nothing_listening() { + // Port 1 is privileged/closed; nothing answers -> Unreachable. + let config = Config::default(); + let server = RestApiServer::new(config, 1); + assert_eq!(server.probe_external().await, ExternalApiProbe::Unreachable); + } + #[test] fn test_rest_api_server_initial_status() { let config = Config::default(); diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index 67a21341..4ff65d6f 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1452,6 +1452,6 @@ ] } }, - "$id": "https://gbqr.us/operator/issuetype-template.schema.json", + "$id": "https://operator.untra.io/schemas/issuetype_schema.json", "$comment": "AUTO-GENERATED FROM src/templates/schema.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only issuetype-json-schema" } \ No newline at end of file diff --git a/src/schemas/operator_output.schema.json b/src/schemas/operator_output.schema.json index 1b7dd5e2..0b45a609 100644 --- a/src/schemas/operator_output.schema.json +++ b/src/schemas/operator_output.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://operator.dev/schemas/operator_output.schema.json", + "$id": "https://operator.untra.io/schemas/operator_output.json", "title": "OperatorOutput", "description": "Standardized agent output for progress tracking and step transitions. Used by agents to report their status, progress, and recommendations to the Operator system.", "type": "object", diff --git a/src/schemas/project_analysis.schema.json b/src/schemas/project_analysis.schema.json index 5b3f7e33..db104870 100644 --- a/src/schemas/project_analysis.schema.json +++ b/src/schemas/project_analysis.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://gbqr.us/operator/project-analysis.schema.json", + "$id": "https://operator.untra.io/schemas/project_analysis.json", "title": "Project Analysis Schema", "description": "Structured output schema for project assessment analysis. Used by the ASSESS issuetype's analyze step with Claude's --json-schema mode.", "type": "object", diff --git a/src/schemas/ticket_metadata.schema.json b/src/schemas/ticket_metadata.schema.json index 1d5602f0..786f7bfd 100644 --- a/src/schemas/ticket_metadata.schema.json +++ b/src/schemas/ticket_metadata.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://gbqr.us/operator/ticket-metadata.schema.json", + "$id": "https://operator.untra.io/schemas/ticket_metadata.schema.json", "title": "Ticket Metadata Schema", "description": "Schema for operator-tracked ticket metadata in YAML frontmatter. This schema documents the structure of ticket files used by the operator TUI.", "type": "object", @@ -35,33 +35,45 @@ "description": "Target project name (subdirectory in projects root)", "examples": ["gamesvc", "operator", "www", "iac"] }, - "created": { + "branch": { "type": "string", - "format": "date", - "description": "Creation date in YYYY-MM-DD format (legacy, prefer createdDatetime)" + "description": "Git branch name for this ticket (auto-generated from type and summary)", + "examples": ["feature/FEAT-1234-add-user-auth", "fix/FIX-5678-login-timeout"] }, - "createdDatetime": { + "worktree_path": { "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime when the ticket was created", - "examples": ["2024-12-25T14:30:00Z", "2024-12-25T09:15:00-05:00"] + "description": "Filesystem path to the git worktree for this ticket (per-ticket isolation)", + "examples": ["/Users/dev/worktrees/op-FEAT-1234"] }, - "startedDatetime": { + "external_id": { "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime when work began on the ticket (moved to running status)", - "examples": ["2024-12-25T15:00:00Z"] + "description": "External issue ID from the kanban provider (e.g., PROJ-123 for Jira, ENG-456 for Linear)", + "examples": ["PROJ-123", "ENG-456"] }, - "completedDatetime": { + "external_url": { "type": "string", - "format": "date-time", - "description": "ISO 8601 datetime when the ticket was completed", - "examples": ["2024-12-25T17:30:00Z"] + "format": "uri", + "description": "Full URL to the issue in the external provider's web UI", + "examples": ["https://example.atlassian.net/browse/PROJ-123"] }, - "branch": { + "external_provider": { "type": "string", - "description": "Git branch name for this ticket (auto-generated from type and summary)", - "examples": ["feature/FEAT-1234-add-user-auth", "fix/FIX-5678-login-timeout"] + "description": "Provider name for the external issue (e.g., jira, linear)", + "examples": ["jira", "linear"] + }, + "step_delegators": { + "type": "object", + "description": "Step name to delegator name mapping. Populated when a step launches; used for bidirectional kanban activity logs.", + "additionalProperties": { + "type": "string", + "description": "Delegator name used for this step" + }, + "examples": [ + { + "plan": "claude", + "build": "claude-code" + } + ] }, "sessions": { "type": "object", @@ -124,12 +136,18 @@ "step": "build", "priority": "P2-medium", "project": "gamesvc", - "createdDatetime": "2024-12-25T14:30:00Z", - "startedDatetime": "2024-12-25T15:00:00Z", "branch": "feature/FEAT-1234-add-user-auth", + "worktree_path": "/Users/dev/worktrees/op-FEAT-1234", + "external_id": "PROJ-123", + "external_url": "https://example.atlassian.net/browse/PROJ-123", + "external_provider": "jira", "sessions": { "plan": "550e8400-e29b-41d4-a716-446655440000", "build": "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + }, + "step_delegators": { + "plan": "claude", + "build": "claude-code" } }, { @@ -138,9 +156,6 @@ "step": "summarize", "priority": "P2-medium", "project": "operator", - "createdDatetime": "2024-12-25T09:00:00Z", - "startedDatetime": "2024-12-25T10:00:00Z", - "completedDatetime": "2024-12-25T12:30:00Z", "sessions": { "explore": "abc12345-6789-0abc-def0-123456789abc" }, diff --git a/src/types/llm_stats.rs b/src/types/llm_stats.rs index b76c6692..ab5a5d66 100644 --- a/src/types/llm_stats.rs +++ b/src/types/llm_stats.rs @@ -329,16 +329,16 @@ mod tests { #[test] fn test_export_bindings_projectllmstats() { - let _ = ProjectLlmStats::export_to_string(); + let _ = ProjectLlmStats::export_to_string(&ts_rs::Config::default()); } #[test] fn test_export_bindings_llmtoolusage() { - let _ = LlmToolUsage::export_to_string(); + let _ = LlmToolUsage::export_to_string(&ts_rs::Config::default()); } #[test] fn test_export_bindings_llmmodelusage() { - let _ = LlmModelUsage::export_to_string(); + let _ = LlmModelUsage::export_to_string(&ts_rs::Config::default()); } } diff --git a/src/types/pr.rs b/src/types/pr.rs index d7ed96aa..1a49d825 100644 --- a/src/types/pr.rs +++ b/src/types/pr.rs @@ -469,16 +469,16 @@ mod tests { // TypeScript binding tests #[test] fn test_export_bindings_pullrequestinfo() { - let _ = PullRequestInfo::export_to_string(); + let _ = PullRequestInfo::export_to_string(&ts_rs::Config::default()); } #[test] fn test_export_bindings_repoinfo() { - let _ = RepoInfo::export_to_string(); + let _ = RepoInfo::export_to_string(&ts_rs::Config::default()); } #[test] fn test_export_bindings_gitprovider() { - let _ = GitProvider::export_to_string(); + let _ = GitProvider::export_to_string(&ts_rs::Config::default()); } } diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index ad803683..37207c90 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -243,7 +243,7 @@ impl Dashboard { snapshot.env_visual = self.editor_config.visual.clone(); snapshot.mcp_http_status = if self.config.mcp.http_enabled { match &self.rest_api_status { - RestApiStatus::Running { port } => { + RestApiStatus::Running { port } | RestApiStatus::RunningExternal { port } => { crate::ui::status_panel::McpHttpStatus::Mounted { port: *port } } _ => crate::ui::status_panel::McpHttpStatus::NotMounted, diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 3ecd4cf6..56a60c7d 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -333,6 +333,11 @@ fn web_indicator(status: &RestApiStatus, embed_ui: bool) -> Span<'static> { format!(" {label} ●:{port}"), Style::default().fg(Color::Green), ), + // Adopted an external operator API — still usable, marked distinctly. + RestApiStatus::RunningExternal { port } => Span::styled( + format!(" {label} ●:{port} ↗"), + Style::default().fg(Color::Green), + ), RestApiStatus::Starting | RestApiStatus::Stopping => { Span::styled(format!(" {label} ●"), Style::default().fg(Color::Yellow)) } diff --git a/src/ui/sections/config_section.rs b/src/ui/sections/config_section.rs index c9b4817e..dec39ead 100644 --- a/src/ui/sections/config_section.rs +++ b/src/ui/sections/config_section.rs @@ -67,6 +67,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::Check }, + brand_icon: None, is_header: false, actions: ActionSet::primary(if snapshot.working_dir.is_empty() { StatusAction::None @@ -91,6 +92,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::Cross }, + brand_icon: None, is_header: false, actions: if snapshot.config_file_found { ActionSet { @@ -129,6 +131,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::Cross }, + brand_icon: None, is_header: false, actions: ActionSet::primary(if snapshot.tickets_dir_exists { StatusAction::OpenDirectory(snapshot.tickets_dir.clone()) @@ -151,6 +154,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::Cross }, + brand_icon: None, is_header: false, actions: ActionSet { primary: if wrapper.is_connected() { @@ -178,6 +182,7 @@ impl StatusSection for ConfigSection { label: "Wrapper".into(), description: snapshot.wrapper_type.clone(), icon: StatusIcon::Tool, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, @@ -198,6 +203,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::Check }, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, @@ -218,6 +224,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::Check }, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, @@ -238,6 +245,7 @@ impl StatusSection for ConfigSection { } else { StatusIcon::None }, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::OpenUrl("https://operator.untra.io/downloads/".into()), diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs index 99dafaeb..19a9723e 100644 --- a/src/ui/sections/connections_section.rs +++ b/src/ui/sections/connections_section.rs @@ -35,7 +35,7 @@ impl StatusSection for ConnectionsSection { if matches!(snapshot.api_status, RestApiStatus::Starting) { return SectionHealth::Yellow; } - if !matches!(snapshot.api_status, RestApiStatus::Running { .. }) { + if !snapshot.api_status.is_running() { return SectionHealth::Red; } // API is up: Green when at least one protocol (MCP or ACP) is exposed, @@ -51,7 +51,7 @@ impl StatusSection for ConnectionsSection { if matches!(snapshot.api_status, RestApiStatus::Starting) { return "Starting...".into(); } - if !matches!(snapshot.api_status, RestApiStatus::Running { .. }) { + if !snapshot.api_status.is_running() { return "Disconnected".into(); } if self.any_protocol_exposed(snapshot) { @@ -71,6 +71,7 @@ impl StatusSection for ConnectionsSection { label: "Operator".into(), description: format!("Version {}", snapshot.operator_version), icon: StatusIcon::Check, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, @@ -95,6 +96,7 @@ impl StatusSection for ConnectionsSection { } else { StatusIcon::Warning }, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, @@ -107,20 +109,25 @@ impl StatusSection for ConnectionsSection { label: "Operator API".into(), description: match &snapshot.api_status { RestApiStatus::Running { port } => format!(":{port}"), + RestApiStatus::RunningExternal { port } => format!(":{port} (external)"), RestApiStatus::Starting => "Starting...".into(), RestApiStatus::Stopping => "Stopping...".into(), RestApiStatus::Stopped => "Stopped".into(), RestApiStatus::Error(e) => format!("Error: {e}"), }, icon: match &snapshot.api_status { - RestApiStatus::Running { .. } => StatusIcon::Check, + RestApiStatus::Running { .. } | RestApiStatus::RunningExternal { .. } => { + StatusIcon::Check + } RestApiStatus::Starting => StatusIcon::Warning, _ => StatusIcon::Cross, }, + brand_icon: None, is_header: false, actions: ActionSet { primary: match &snapshot.api_status { - RestApiStatus::Running { port } => { + RestApiStatus::Running { port } + | RestApiStatus::RunningExternal { port } => { StatusAction::OpenSwagger { port: *port } } RestApiStatus::Stopped | RestApiStatus::Error(_) => StatusAction::StartApi, @@ -128,7 +135,8 @@ impl StatusSection for ConnectionsSection { }, back: StatusAction::None, special: match &snapshot.api_status { - RestApiStatus::Running { port } => { + RestApiStatus::Running { port } + | RestApiStatus::RunningExternal { port } => { StatusAction::OpenSwagger { port: *port } } _ => StatusAction::None, @@ -156,18 +164,25 @@ impl StatusSection for ConnectionsSection { label: "Web UI".into(), description: match &snapshot.api_status { RestApiStatus::Running { port } => format!(":{port}"), + RestApiStatus::RunningExternal { port } => format!(":{port} (external)"), RestApiStatus::Starting => "Starting...".into(), _ => "API stopped".into(), }, icon: match &snapshot.api_status { - RestApiStatus::Running { .. } => StatusIcon::Check, + RestApiStatus::Running { .. } | RestApiStatus::RunningExternal { .. } => { + StatusIcon::Check + } RestApiStatus::Starting => StatusIcon::Warning, _ => StatusIcon::Cross, }, + brand_icon: None, is_header: false, actions: ActionSet { primary: match &snapshot.api_status { - RestApiStatus::Running { port } => StatusAction::OpenWebUi { port: *port }, + RestApiStatus::Running { port } + | RestApiStatus::RunningExternal { port } => { + StatusAction::OpenWebUi { port: *port } + } RestApiStatus::Stopped | RestApiStatus::Error(_) => StatusAction::StartApi, _ => StatusAction::None, }, @@ -207,6 +222,7 @@ impl StatusSection for ConnectionsSection { (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Check, _ => StatusIcon::Cross, }, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::WriteAndOpenMcpClientConfig { @@ -249,6 +265,7 @@ impl StatusSection for ConnectionsSection { } else { StatusIcon::Cross }, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::WriteAndOpenAcpEditorConfig { diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs index 8deef0dc..7b0a1574 100644 --- a/src/ui/sections/delegator_section.rs +++ b/src/ui/sections/delegator_section.rs @@ -44,6 +44,7 @@ impl StatusSection for DelegatorSection { label: "Add delegator".into(), description: "Edit config to configure a delegator".into(), icon: StatusIcon::Tool, + brand_icon: None, is_header: false, actions: ActionSet::primary(StatusAction::EditFile(snapshot.config_path.clone())), health: SectionHealth::Gray, @@ -71,6 +72,7 @@ impl StatusSection for DelegatorSection { label, description, icon: StatusIcon::Tool, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::None, diff --git a/src/ui/sections/git_section.rs b/src/ui/sections/git_section.rs index 167f6b4f..203a04f3 100644 --- a/src/ui/sections/git_section.rs +++ b/src/ui/sections/git_section.rs @@ -44,6 +44,7 @@ impl StatusSection for GitSection { label: "Configure GitHub".into(), description: "Set up GitHub".into(), icon: StatusIcon::Plug, + brand_icon: None, is_header: false, actions: ActionSet::primary(StatusAction::ConfigureGitProvider { provider: "github".into(), @@ -57,6 +58,7 @@ impl StatusSection for GitSection { label: "Configure GitLab".into(), description: "Set up GitLab".into(), icon: StatusIcon::Plug, + brand_icon: None, is_header: false, actions: ActionSet::primary(StatusAction::ConfigureGitProvider { provider: "gitlab".into(), @@ -76,6 +78,7 @@ impl StatusSection for GitSection { label: "Provider".into(), description: provider.clone(), icon: StatusIcon::Branch, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::None, @@ -105,6 +108,7 @@ impl StatusSection for GitSection { } else { StatusIcon::Warning }, + brand_icon: None, is_header: false, actions: ActionSet::primary(if snapshot.git_token_set { StatusAction::None @@ -125,6 +129,7 @@ impl StatusSection for GitSection { label: "Branch Format".into(), description: fmt.clone(), icon: StatusIcon::Branch, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, @@ -142,6 +147,7 @@ impl StatusSection for GitSection { "Disabled".into() }, icon: StatusIcon::Branch, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, diff --git a/src/ui/sections/issuetype_section.rs b/src/ui/sections/issuetype_section.rs index dbacbf92..f122ef92 100644 --- a/src/ui/sections/issuetype_section.rs +++ b/src/ui/sections/issuetype_section.rs @@ -47,6 +47,7 @@ impl StatusSection for IssueTypeSection { label: it.key.clone(), description: format!("{} · {}", it.name, it.mode), icon: StatusIcon::Tool, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs index 152edb04..d07dde3b 100644 --- a/src/ui/sections/kanban_section.rs +++ b/src/ui/sections/kanban_section.rs @@ -50,6 +50,7 @@ impl StatusSection for KanbanSection { label: provider.provider_type.clone(), description: provider.domain.clone(), icon: StatusIcon::Plug, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::None, @@ -81,6 +82,7 @@ impl StatusSection for KanbanSection { label: format!("Configure {}", provider.display_name()), description: provider.connect_blurb().to_string(), icon: StatusIcon::Plug, + brand_icon: None, is_header: false, actions: ActionSet::primary(StatusAction::ConfigureKanbanProvider { provider: provider.slug().to_string(), diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs index 793b426e..f846e8e2 100644 --- a/src/ui/sections/llm_section.rs +++ b/src/ui/sections/llm_section.rs @@ -50,6 +50,7 @@ impl StatusSection for LlmSection { label: tool.name.clone(), description: tool.version.clone(), icon: StatusIcon::Tool, + brand_icon: None, is_header: false, actions: ActionSet { primary: StatusAction::None, @@ -87,6 +88,7 @@ impl StatusSection for LlmSection { label, description: format!("{}:{}", tool.name, model), icon, + brand_icon: None, is_header: false, actions: ActionSet::primary(StatusAction::SetDefaultLlm { tool_name: tool.name.clone(), diff --git a/src/ui/sections/managed_projects_section.rs b/src/ui/sections/managed_projects_section.rs index 2e871ead..49475938 100644 --- a/src/ui/sections/managed_projects_section.rs +++ b/src/ui/sections/managed_projects_section.rs @@ -55,6 +55,7 @@ impl StatusSection for ManagedProjectsSection { } else { StatusIcon::Warning }, + brand_icon: None, is_header: false, actions: ActionSet::none(), health: SectionHealth::Gray, diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs index c9abcd02..f35faf52 100644 --- a/src/ui/sections/modelserver_section.rs +++ b/src/ui/sections/modelserver_section.rs @@ -1,4 +1,4 @@ -use crate::api::providers::model_server::ModelServerKind; +use crate::api::providers::model_server::{ModelProviderClass, ModelServerKind}; use crate::ui::status_panel::{ ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, StatusSnapshot, TreeRow, @@ -91,6 +91,9 @@ impl StatusSection for ModelServerSection { label, description, icon: StatusIcon::Tool, + brand_icon: ModelServerKind::from_slug(&s.kind) + .and_then(|k| k.brand_icon()) + .map(str::to_string), is_header: false, actions: action, health: if s.user_declared { @@ -106,17 +109,37 @@ impl StatusSection for ModelServerSection { // from ModelServerKind::ALL so the options can't drift from the other // surfaces. The vendor builtins always exist, so they aren't offered here. // Multiple servers of the same kind are allowed, so these are always shown. + // Rows are grouped under a category header (the *Model Provider* vertical) + // so the catalog reads the same way as the README/docs/web surfaces. + let mut last_category: Option = None; for kind in ModelServerKind::ALL { if kind.is_builtin() { continue; } + let category = kind.provider_class(); + if last_category != Some(category) { + rows.push(TreeRow { + section_id: SectionId::ModelServers, + id: format!("category-{}", category.slug()), + depth: 1, + label: category.display_name().to_string(), + description: String::new(), + icon: StatusIcon::Tool, + brand_icon: None, + is_header: true, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }); + last_category = Some(category); + } rows.push(TreeRow { section_id: SectionId::ModelServers, id: format!("add-{}", kind.slug()), - depth: 1, + depth: 2, label: format!("Add {}", kind.display_name()), description: kind.connect_blurb().to_string(), icon: StatusIcon::Tool, + brand_icon: kind.brand_icon().map(str::to_string), is_header: false, actions: ActionSet::primary(StatusAction::ConfigureModelServer { kind: kind.slug().to_string(), @@ -256,13 +279,23 @@ mod tests { let rows = ModelServerSection.children(&snapshot); let add_rows: Vec<&TreeRow> = rows.iter().filter(|r| r.id.starts_with("add-")).collect(); - // ollama, openai-compat, lmstudio — the addable kinds. - assert_eq!(add_rows.len(), 3); + // ollama, openrouter, openai-compat, lmstudio — the addable kinds. + assert_eq!(add_rows.len(), 4); assert!(add_rows .iter() .any(|r| matches!(&r.actions.primary, StatusAction::ConfigureModelServer { kind } if kind == "ollama"))); + assert!(add_rows + .iter() + .any(|r| matches!(&r.actions.primary, StatusAction::ConfigureModelServer { kind } if kind == "openrouter"))); // No add-row for vendor builtins. assert!(!add_rows.iter().any(|r| r.id == "add-anthropic-api")); + + // The addable kinds (all gateways) sit under a "Gateways" header. + let header = rows + .iter() + .find(|r| r.is_header && r.id == "category-gateway") + .expect("gateway provider-class header present"); + assert_eq!(header.label, "Gateways"); } #[test] diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 2e1764d6..e0791593 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -184,6 +184,10 @@ pub struct TreeRow { pub label: String, pub description: String, pub icon: StatusIcon, + /// Optional vendor-brand basename (e.g. "ollama") for surfaces that render + /// logos (the web UI). The TUI ignores this and renders [`icon`](Self::icon) + /// as a semantic ANSI glyph — brand logos can't be drawn in a terminal. + pub brand_icon: Option, pub is_header: bool, pub actions: ActionSet, pub health: SectionHealth, @@ -619,7 +623,9 @@ impl StatusSnapshot { /// Returns the API port if the REST server is running. pub fn api_port(&self) -> Option { match &self.api_status { - RestApiStatus::Running { port } => Some(*port), + RestApiStatus::Running { port } | RestApiStatus::RunningExternal { port } => { + Some(*port) + } _ => None, } } @@ -986,6 +992,7 @@ pub fn build_section_dtos(snapshot: &StatusSnapshot) -> Vec, + edges: Vec, +} + +/// One AGNT workflow node. `config` is the per-node parameter bag the plugin +/// tool reads at execution time. +#[derive(Debug, Serialize)] +struct AgntNode { + id: String, + #[serde(rename = "type")] + node_type: String, + config: Value, +} + +/// A directed edge between two nodes (by `id`). +#[derive(Debug, Serialize)] +struct AgntEdge { + source: String, + target: String, +} + +/// Render `ticket` against `issuetype` into an AGNT workflow JSON string. +/// +/// Deterministic given the same project/filesystem environment: the output +/// never contains wall-clock, `Date.now`, or `Math.random`. +pub fn export_workflow_agnt( + ticket: &Ticket, + issuetype: &IssueType, + pr_config: Option<&PrConfig>, + _pipeline_env: &PipelineEnv, + config: &Config, +) -> Result { + // Reuse the exact variable surface a step prompt sees at launch time, and + // the same step ordering + non-strict Handlebars the `.js` target uses. + let ctx = StepManager::build_ticket_context(ticket, pr_config); + let hbs = handlebars_renderer(); + let steps = ordered_steps(issuetype); + + let mut nodes = Vec::with_capacity(steps.len()); + for step in &steps { + nodes.push(build_node(&hbs, &ctx, step, ticket, config)?); + } + + // Edges follow the `next_step` chain (only when the target resolves to a + // real step). Terminal/unreachable steps are left as islands. + let mut edges = Vec::new(); + for step in &steps { + if let Some(next) = step.next_step.as_deref() { + if issuetype.get_step(next).is_some() { + edges.push(AgntEdge { + source: step.name.clone(), + target: next.to_string(), + }); + } + } + } + + let wf = AgntWorkflow { + name: meta_name(ticket, issuetype), + description: meta_description(issuetype), + nodes, + edges, + }; + serde_json::to_string_pretty(&wf).context("failed to serialize AGNT workflow") +} + +/// Build one `operator-run-step` node from a step. Lossy cases (RAG/MCP +/// sandboxing, fan-out shapes, human review gates) are recorded in `config` +/// rather than dropped: `gap` collects `OPERATOR-GAP` notes, `fanout` summarizes +/// a flattened parallel shape. +fn build_node( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + ticket: &Ticket, + app_config: &Config, +) -> Result { + let mut config = Map::new(); + config.insert("ticket".into(), json!(ticket.id)); + config.insert("step".into(), json!(step.name)); + config.insert("phase".into(), json!(step.display_name())); + + let mut effective_prompt = render(hbs, &step.prompt, ctx)?; + let mut model = step.agent.clone(); + let mut gaps: Vec = Vec::new(); + + match step.step_type { + StepTypeTag::Task => {} + StepTypeTag::Delegator => { + if let Some(cfg) = &step.delegator_config { + model = Some(cfg.delegator.clone()); + if let Some(flavor) = &cfg.prompt_flavor { + let rendered_flavor = render(hbs, flavor, ctx)?; + effective_prompt = format!("{rendered_flavor}\n\n{effective_prompt}"); + } + } + } + StepTypeTag::Classifier => { + let schema = if let Some(explicit) = &step.json_schema { + explicit.clone() + } else if let Some(cfg) = &step.classifier_config { + step_map::classifier_schema(cfg) + } else { + json!({ "type": "object" }) + }; + config.insert("schema".into(), schema); + } + StepTypeTag::Rag => { + gaps.push(format!( + "{GAP_MARKER}: rag step — the workflow sandbox has no filesystem; context sources must be gathered by the agent." + )); + if let Some(cfg) = &step.rag_config { + let srcs: Vec = cfg + .sources + .iter() + .map(|s| json!(describe_rag_source(s))) + .collect(); + if !srcs.is_empty() { + config.insert("contextSources".into(), Value::Array(srcs)); + } + } + } + StepTypeTag::Mcp => { + gaps.push(format!( + "{GAP_MARKER}: mcp step — the workflow sandbox cannot guarantee MCP tool availability." + )); + if let Some(cfg) = &step.mcp_config { + let tools: Vec = cfg + .required_tools + .iter() + .map(|t| match &t.tool { + Some(tool) => json!(format!("{}/{}", t.server, tool)), + None => json!(t.server), + }) + .collect(); + if !tools.is_empty() { + config.insert("requiredTools".into(), Value::Array(tools)); + } + } + } + StepTypeTag::MultiModel + | StepTypeTag::MultiPrompt + | StepTypeTag::Matrixed + | StepTypeTag::Pipeline => { + config.insert("fanout".into(), json!(fanout_description(step))); + gaps.push(format!( + "{GAP_MARKER}: {} fan-out flattened to a single node; the parallel/voting shape runs inside one operator agent launch.", + step_type_label(&step.step_type), + )); + } + } + + // Human review gates have no autonomous AGNT analog (mirrors the `.js` + // judge-loop flattening, but AGNT nodes cannot loop on a human verdict). + if step.requires_review() || step.on_reject.is_some() { + let goto = step + .on_reject + .as_ref() + .map(|r| format!(" (original on_reject -> goto {})", r.goto_step)) + .unwrap_or_default(); + gaps.push(format!( + "{GAP_MARKER}: step '{}' had human review_type={}; AGNT runs autonomously, so the human gate is flattened{goto}.", + step.name, + review_label(&step.review_type), + )); + } + + // If the step's resolved delegator (held in `model`) declaratively references + // an AGNT agent, emit an `agnt-agent` node so AGNT runs the step natively + // rather than calling back into Operator. Operator-only launch_config on such + // a delegator has no analog on the AGNT side, so its presence is gap-marked. + // Only AGNT-hosted remote agents get a native node; other platforms (e.g. + // OpenAI) ride opaquely in the profile and have no AGNT workflow analog. + let agnt_delegator = model + .as_deref() + .and_then(|name| app_config.delegators.iter().find(|d| d.name == name)) + .filter(|d| { + d.remote_agent + .as_ref() + .is_some_and(|r| r.platform == "agnt") + }); + let node_type = if let Some(d) = agnt_delegator { + let agent_name = d + .remote_agent + .as_ref() + .expect("filtered to an agnt remote_agent above") + .id + .clone(); + config.insert("agentName".into(), json!(agent_name)); + if d.launch_config.is_some() { + gaps.push(format!( + "{GAP_MARKER}: delegator '{}' carries operator launch_config (permission mode/flags/worktree/docker) that AGNT agents have no analog for; only the agent reference is exported.", + d.name, + )); + } + AGNT_AGENT_NODE_TYPE + } else { + NODE_TYPE + }; + + config.insert("prompt".into(), json!(effective_prompt)); + if let Some(m) = model { + config.insert("model".into(), json!(m)); + } + if !step.allowed_tools.is_empty() { + config.insert("allowedTools".into(), json!(step.allowed_tools)); + } + if !gaps.is_empty() { + config.insert("gap".into(), json!(gaps.join(" | "))); + } + + Ok(AgntNode { + id: step.name.clone(), + node_type: node_type.to_string(), + config: Value::Object(config), + }) +} + +fn render(hbs: &Handlebars, template: &str, ctx: &Value) -> Result { + hbs.render_template(template, ctx) + .context("failed to render step prompt for AGNT workflow export") +} + +fn review_label(review: &ReviewType) -> &'static str { + match review { + ReviewType::None => "output", + ReviewType::Plan => "plan", + ReviewType::Visual => "visual", + ReviewType::Pr => "pr", + } +} + +fn describe_rag_source(src: &RagSource) -> String { + match src { + RagSource::Glob { pattern } => format!("glob:{pattern}"), + RagSource::File { path } => format!("file:{path}"), + RagSource::Mcp { server, tool, .. } => format!("mcp:{server}/{tool}"), + } +} + +fn step_type_label(t: &StepTypeTag) -> &'static str { + match t { + StepTypeTag::MultiModel => "multi-model", + StepTypeTag::MultiPrompt => "multi-prompt", + StepTypeTag::Matrixed => "matrixed", + StepTypeTag::Pipeline => "pipeline", + _ => "fan-out", + } +} + +/// A human-readable summary of a fan-out step's flattened shape. +fn fanout_description(step: &StepSchema) -> String { + match step.step_type { + StepTypeTag::MultiModel => { + let n = step + .multi_model_config + .as_ref() + .map(|c| c.delegators.len()) + .unwrap_or(0); + format!("multi-model fan-out across {n} delegators") + } + StepTypeTag::MultiPrompt => { + let n = step + .multi_prompt_config + .as_ref() + .map(|c| c.prompt_variations.len()) + .unwrap_or(0); + format!("{n} prompt variations + select") + } + StepTypeTag::Matrixed => { + let cfg = step.matrixed_config.as_ref(); + let d = cfg.map(|c| c.delegators.len()).unwrap_or(0); + let p = cfg.map(|c| c.prompt_variations.len()).unwrap_or(0); + format!("matrixed {d}x{p} (delegators x prompt variations)") + } + StepTypeTag::Pipeline => { + let n = step + .pipeline_config + .as_ref() + .map(|c| c.stages.len()) + .unwrap_or(0); + format!("pipeline over {n} stages") + } + _ => "fan-out".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::issuetypes::IssueTypeRegistry; + use std::collections::HashSet; + + fn registry() -> IssueTypeRegistry { + let mut r = IssueTypeRegistry::new(); + r.load_builtins().expect("load builtins"); + r + } + + fn ticket(ticket_type: &str) -> Ticket { + Ticket { + filename: "20241221-1430-FEAT-gamesvc-test.md".to_string(), + filepath: "/test/path".to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: ticket_type.to_string(), + project: "gamesvc".to_string(), + id: "FEAT-1234".to_string(), + summary: "Add pagination".to_string(), + priority: "P2-medium".to_string(), + status: "queued".to_string(), + step: String::new(), + content: "body".to_string(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: crate::queue::LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + } + } + + fn export(key: &str) -> Value { + let reg = registry(); + let it = reg.get(key).expect("builtin issuetype"); + let out = export_workflow_agnt( + &ticket(key), + it, + None, + &PipelineEnv::default(), + &Config::default(), + ) + .expect("export"); + serde_json::from_str(&out).expect("output parses as JSON") + } + + #[test] + fn agnt_export_parses_and_uses_operator_nodes() { + let reg = registry(); + let feat = reg.get("FEAT").unwrap(); + let v = export("FEAT"); + + assert!(v["name"].is_string(), "missing name"); + assert!(v["description"].is_string(), "missing description"); + + let nodes = v["nodes"].as_array().expect("nodes array"); + assert_eq!(nodes.len(), feat.steps.len(), "one node per step expected"); + for n in nodes { + assert_eq!(n["type"], NODE_TYPE, "every node is an operator-* node"); + assert!(n["id"].as_str().is_some_and(|s| !s.is_empty()), "node id"); + assert!( + n["config"]["prompt"].is_string(), + "node config carries a prompt" + ); + assert_eq!( + n["config"]["ticket"], "FEAT-1234", + "node config carries the ticket id" + ); + } + } + + /// Guards the cross-language contract between this emitter and the + /// `operator-run-step` plugin tool (`agnt-plugin/run-step.js`), which reads + /// `config.ticket`. If the emitter stops writing the keys the tool requires, + /// an exported graph would fail at runtime in AGNT — this catches that. + #[test] + fn agnt_nodes_carry_keys_the_run_step_tool_requires() { + let v = export("FEAT"); + for n in v["nodes"].as_array().unwrap() { + assert!( + n["config"]["ticket"] + .as_str() + .is_some_and(|s| !s.is_empty()), + "node '{}' missing the 'ticket' key the run-step tool requires", + n["id"] + ); + assert!( + n["config"]["step"].as_str().is_some_and(|s| !s.is_empty()), + "node '{}' missing 'step'", + n["id"] + ); + } + } + + #[test] + fn agnt_edges_follow_next_step() { + let reg = registry(); + let feat = reg.get("FEAT").unwrap(); + let v = export("FEAT"); + + let nodes = v["nodes"].as_array().unwrap(); + let ids: HashSet<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); + + let expected = feat + .steps + .iter() + .filter(|s| { + s.next_step + .as_deref() + .and_then(|n| feat.get_step(n)) + .is_some() + }) + .count(); + let edges = v["edges"].as_array().expect("edges array"); + assert_eq!(edges.len(), expected, "one edge per resolving next_step"); + for e in edges { + assert!( + ids.contains(e["source"].as_str().unwrap()), + "edge source is a node" + ); + assert!( + ids.contains(e["target"].as_str().unwrap()), + "edge target is a node" + ); + } + } + + #[test] + fn agnt_export_is_deterministic() { + let reg = registry(); + let feat = reg.get("FEAT").unwrap(); + let a = export_workflow_agnt( + &ticket("FEAT"), + feat, + None, + &PipelineEnv::default(), + &Config::default(), + ) + .unwrap(); + let b = export_workflow_agnt( + &ticket("FEAT"), + feat, + None, + &PipelineEnv::default(), + &Config::default(), + ) + .unwrap(); + assert_eq!(a, b, "export must be byte-identical for identical inputs"); + } + + #[test] + fn agnt_review_step_records_gap() { + let reg = registry(); + let feat = reg.get("FEAT").unwrap(); + let review_steps: Vec<&str> = feat + .steps + .iter() + .filter(|s| s.requires_review() || s.on_reject.is_some()) + .map(|s| s.name.as_str()) + .collect(); + assert!( + !review_steps.is_empty(), + "FEAT should have at least one review-gated step" + ); + + let v = export("FEAT"); + for n in v["nodes"].as_array().unwrap() { + if review_steps.contains(&n["id"].as_str().unwrap()) { + assert!( + n["config"]["gap"].is_string(), + "review-gated step '{}' must record a gap", + n["id"] + ); + } + } + } + + #[test] + fn agnt_multi_model_step_records_fanout() { + let json = r#"{ + "key": "MM", "name": "Multi", "description": "d", + "mode": "autonomous", "glyph": "m", "fields": [], + "steps": [{ + "name": "vote", "outputs": [], "prompt": "decide {{id}}", + "type": "multi_model", + "multi_model_config": { "delegators": ["a", "b"], "voting_strategy": "majority" } + }] + }"#; + let it = IssueType::from_json(json).expect("fixture parses"); + let out = export_workflow_agnt( + &ticket("MM"), + &it, + None, + &PipelineEnv::default(), + &Config::default(), + ) + .expect("export"); + let v: Value = serde_json::from_str(&out).unwrap(); + let node = &v["nodes"][0]; + assert_eq!(node["id"], "vote"); + assert_eq!(node["type"], NODE_TYPE); + assert!( + node["config"]["fanout"].is_string(), + "fan-out step records its flattened shape" + ); + assert!( + node["config"]["gap"].is_string(), + "fan-out flattening is gap-marked" + ); + } + + /// A fixture issuetype with a single step whose `agent` names `delegator`. + fn issuetype_with_step_agent(delegator: &str) -> IssueType { + let json = format!( + r#"{{ + "key": "AG", "name": "Agent", "description": "d", + "mode": "autonomous", "glyph": "a", "fields": [], + "steps": [{{ + "name": "research", "outputs": [], "prompt": "investigate {{{{id}}}}", + "agent": "{delegator}" + }}] + }}"# + ); + IssueType::from_json(&json).expect("fixture parses") + } + + /// A config whose single delegator references a remote agent on `platform`. + fn config_with_remote_delegator(name: &str, platform: &str, id: &str) -> Config { + let mut config = Config::default(); + config.delegators.push(crate::config::Delegator { + name: name.to_string(), + llm_tool: "anthropic".to_string(), + model: "claude-3-5-sonnet".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + model_server: None, + launch_config: None, + remote_agent: Some(crate::config::RemoteAgentRef { + platform: platform.to_string(), + id: id.to_string(), + }), + x_agnt: None, + x_openai: None, + unmapped_core: None, + }); + config + } + + #[test] + fn agnt_export_emits_agnt_agent_node_for_agnt_delegator() { + let it = issuetype_with_step_agent("agnt-researcher"); + let out = export_workflow_agnt( + &ticket("AG"), + &it, + None, + &PipelineEnv::default(), + &config_with_remote_delegator("agnt-researcher", "agnt", "Research Assistant"), + ) + .expect("export"); + let v: Value = serde_json::from_str(&out).unwrap(); + let node = &v["nodes"][0]; + assert_eq!( + node["type"], AGNT_AGENT_NODE_TYPE, + "step bound to an AGNT-referencing delegator must emit an agnt-agent node" + ); + assert_eq!( + node["config"]["agentName"], "Research Assistant", + "the agnt-agent node carries the referenced AGNT agent name" + ); + } + + #[test] + fn agnt_export_skips_agnt_node_for_non_agnt_remote_delegator() { + // An OpenAI remote agent is export-only too, but it has no AGNT workflow + // analog — so the export must NOT emit an agnt-agent node for it. Proves + // the export branch is keyed on platform, not just "is remote". + let it = issuetype_with_step_agent("openai-reviewer"); + let out = export_workflow_agnt( + &ticket("AG"), + &it, + None, + &PipelineEnv::default(), + &config_with_remote_delegator("openai-reviewer", "openai", "asst_abc123"), + ) + .expect("export"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!( + v["nodes"][0]["type"], NODE_TYPE, + "a non-AGNT remote delegator gets no agnt-agent node" + ); + assert!( + v["nodes"][0]["config"].get("agentName").is_none(), + "non-AGNT node carries no agentName" + ); + } + + #[test] + fn agnt_export_keeps_operator_run_step_for_normal_delegator() { + // Same issuetype, but the delegator the step names has no remote reference, + // so the node stays an ordinary operator-run-step. + let it = issuetype_with_step_agent("local-claude"); + let mut config = Config::default(); + config.delegators.push(crate::config::Delegator { + name: "local-claude".to_string(), + llm_tool: "claude".to_string(), + model: "opus".to_string(), + display_name: None, + model_properties: std::collections::HashMap::new(), + model_server: None, + launch_config: None, + remote_agent: None, + x_agnt: None, + x_openai: None, + unmapped_core: None, + }); + let out = export_workflow_agnt(&ticket("AG"), &it, None, &PipelineEnv::default(), &config) + .expect("export"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!( + v["nodes"][0]["type"], NODE_TYPE, + "a normal delegator keeps the operator-run-step node type" + ); + assert!( + v["nodes"][0]["config"].get("agentName").is_none(), + "non-AGNT node carries no agentName" + ); + } +} diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs index 1efe3e06..f295a395 100644 --- a/src/workflow_gen/command.rs +++ b/src/workflow_gen/command.rs @@ -9,33 +9,70 @@ use anyhow::{anyhow, Result}; use super::export::PipelineEnv; -use super::export_workflow; +use super::{export_workflow, export_workflow_agnt, WorkflowFormat}; use crate::config::Config; use crate::issuetypes::{IssueType, IssueTypeRegistry}; use crate::pr_config::PrConfig; use crate::queue::{LlmTask, Ticket}; use crate::templates::schema::ItemSource; -/// The result of exporting a ticket to a Claude dynamic workflow. +/// The result of exporting a ticket to an orchestration workflow. #[derive(Debug, Clone)] pub struct ExportedWorkflow { /// The ticket the workflow was generated from. pub ticket_id: String, /// The issue type key that supplied the step structure. pub issuetype_key: String, - /// A suggested filename for writing the workflow (`.workflow.js`). + /// A suggested filename for writing the workflow. The extension encodes the + /// format: `.workflow.js` (Claude) or `.agnt.workflow.json` (AGNT). pub suggested_filename: String, - /// The generated `.js` workflow source. + /// The generated workflow source (`.js` for Claude, JSON for AGNT). pub contents: String, } -/// Resolve `ticket`'s issue type from `registry` and render it into a Claude -/// dynamic workflow. Errors if the ticket's type has no matching issue type. +/// Suggested filename for a ticket export, per format. +fn ticket_filename(ticket_id: &str, format: WorkflowFormat) -> String { + match format { + WorkflowFormat::Claude => format!("{ticket_id}.workflow.js"), + WorkflowFormat::Agnt => format!("{ticket_id}.agnt.workflow.json"), + } +} + +/// Suggested filename for an issuetype preview export, per format. +fn preview_filename(key: &str, format: WorkflowFormat) -> String { + match format { + WorkflowFormat::Claude => format!("{key}.preview.workflow.js"), + WorkflowFormat::Agnt => format!("{key}.agnt.preview.workflow.json"), + } +} + +/// Render `ticket` against `issuetype` into the requested `format`'s source. +/// +/// `config` is needed only by the AGNT target, which resolves each step's +/// delegator name against `config.delegators` to decide between an +/// `operator-run-step` node and an `agnt-agent` node. The Claude target ignores it. +fn render( + ticket: &Ticket, + issuetype: &IssueType, + pr_config: Option<&PrConfig>, + env: &PipelineEnv, + config: &Config, + format: WorkflowFormat, +) -> Result { + match format { + WorkflowFormat::Claude => export_workflow(ticket, issuetype, pr_config, env), + WorkflowFormat::Agnt => export_workflow_agnt(ticket, issuetype, pr_config, env, config), + } +} + +/// Resolve `ticket`'s issue type from `registry` and render it into the given +/// workflow `format`. Errors if the ticket's type has no matching issue type. pub fn export_workflow_for_ticket( ticket: &Ticket, registry: &IssueTypeRegistry, pr_config: Option<&PrConfig>, config: &Config, + format: WorkflowFormat, ) -> Result { let issuetype = registry.get(&ticket.ticket_type).ok_or_else(|| { anyhow!( @@ -46,12 +83,12 @@ pub fn export_workflow_for_ticket( })?; let env = pipeline_env_for(config, ticket, issuetype); - let contents = export_workflow(ticket, issuetype, pr_config, &env)?; + let contents = render(ticket, issuetype, pr_config, &env, config, format)?; Ok(ExportedWorkflow { ticket_id: ticket.id.clone(), issuetype_key: issuetype.key.clone(), - suggested_filename: format!("{}.workflow.js", ticket.id), + suggested_filename: ticket_filename(&ticket.id, format), contents, }) } @@ -64,16 +101,28 @@ pub fn export_workflow_for_ticket( /// `worktree_path: None` short-circuits any step-output loading, and the /// handlebars renderer runs with strict mode off, so missing variables render /// as empty strings rather than erroring. -pub fn export_workflow_for_issuetype(issuetype: &IssueType) -> Result { +pub fn export_workflow_for_issuetype( + issuetype: &IssueType, + format: WorkflowFormat, +) -> Result { let ticket = preview_ticket(issuetype); - // No config/filesystem context in a preview: environment-dependent - // pipeline item sources (projects/glob) render as symbolic placeholders. - let contents = export_workflow(&ticket, issuetype, None, &PipelineEnv::default())?; + // No config/filesystem context in a preview: environment-dependent pipeline + // item sources (projects/glob) render as symbolic placeholders, and with an + // empty delegator set the AGNT target never resolves an `agnt-agent` node — + // so those nodes appear only in real ticket exports, by design. + let contents = render( + &ticket, + issuetype, + None, + &PipelineEnv::default(), + &Config::default(), + format, + )?; Ok(ExportedWorkflow { ticket_id: ticket.id, issuetype_key: issuetype.key.clone(), - suggested_filename: format!("{}.preview.workflow.js", issuetype.key), + suggested_filename: preview_filename(&issuetype.key, format), contents, }) } @@ -161,9 +210,14 @@ mod tests { #[test] fn exports_known_ticket_type_into_workflow() { - let exported = - export_workflow_for_ticket(&ticket("FEAT"), ®istry(), None, &Config::default()) - .unwrap(); + let exported = export_workflow_for_ticket( + &ticket("FEAT"), + ®istry(), + None, + &Config::default(), + WorkflowFormat::Claude, + ) + .unwrap(); assert_eq!(exported.ticket_id, "FEAT-1234"); assert_eq!(exported.issuetype_key, "FEAT"); assert_eq!(exported.suggested_filename, "FEAT-1234.workflow.js"); @@ -178,10 +232,35 @@ mod tests { ); } + #[test] + fn exports_known_ticket_type_into_agnt_workflow() { + let exported = export_workflow_for_ticket( + &ticket("FEAT"), + ®istry(), + None, + &Config::default(), + WorkflowFormat::Agnt, + ) + .unwrap(); + assert_eq!(exported.suggested_filename, "FEAT-1234.agnt.workflow.json"); + let v: serde_json::Value = + serde_json::from_str(&exported.contents).expect("AGNT export is valid JSON"); + assert!(v["nodes"].is_array(), "AGNT export has nodes"); + assert_eq!( + v["nodes"][0]["type"], "operator-run-step", + "AGNT export uses operator-run-step nodes" + ); + } + #[test] fn errors_when_issuetype_unknown() { - let result = - export_workflow_for_ticket(&ticket("NOPE"), ®istry(), None, &Config::default()); + let result = export_workflow_for_ticket( + &ticket("NOPE"), + ®istry(), + None, + &Config::default(), + WorkflowFormat::Claude, + ); assert!(result.is_err(), "unknown issuetype should error"); } @@ -189,7 +268,7 @@ mod tests { fn issuetype_preview_renders_without_a_ticket() { let reg = registry(); let feat = reg.get("FEAT").expect("FEAT builtin"); - let exported = export_workflow_for_issuetype(feat).unwrap(); + let exported = export_workflow_for_issuetype(feat, WorkflowFormat::Claude).unwrap(); assert_eq!(exported.issuetype_key, "FEAT"); assert_eq!(exported.suggested_filename, "FEAT.preview.workflow.js"); assert!( @@ -210,10 +289,10 @@ mod tests { ); } - /// Regression gate: dump every builtin issue type's preview workflow to - /// `target/workflow-previews/` so the naiveworkflow compiler can be run - /// against them (see the verification step in the plan / docs). Also - /// asserts each preview is non-empty. + /// Regression gate: dump every builtin issue type's preview workflow (both + /// formats) to `target/workflow-previews/` so the naiveworkflow compiler can + /// be run against the `.js` and the AGNT JSON validated. Asserts each + /// preview is non-empty, and that every AGNT preview parses as JSON. #[test] fn dumps_all_builtin_previews_for_compiler_check() { let reg = registry(); @@ -222,14 +301,21 @@ mod tests { .join("workflow-previews"); std::fs::create_dir_all(&dir).expect("create preview dir"); for it in reg.all_types() { - let exported = export_workflow_for_issuetype(it).unwrap(); - assert!( - !exported.contents.trim().is_empty(), - "empty preview for {}", - it.key - ); - std::fs::write(dir.join(&exported.suggested_filename), &exported.contents) - .expect("write preview"); + for format in [WorkflowFormat::Claude, WorkflowFormat::Agnt] { + let exported = export_workflow_for_issuetype(it, format).unwrap(); + assert!( + !exported.contents.trim().is_empty(), + "empty preview for {}", + it.key + ); + if format == WorkflowFormat::Agnt { + serde_json::from_str::(&exported.contents).unwrap_or_else( + |e| panic!("AGNT preview for {} is invalid JSON: {e}", it.key), + ); + } + std::fs::write(dir.join(&exported.suggested_filename), &exported.contents) + .expect("write preview"); + } } } } diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index 908280f5..2f61f9ec 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -46,9 +46,7 @@ pub fn export_workflow( // Reuse the exact variable surface a step prompt sees at launch time. let ctx = StepManager::build_ticket_context(ticket, pr_config); - let mut hbs = Handlebars::new(); - hbs.set_strict_mode(false); - hbs.register_escape_fn(handlebars::no_escape); + let hbs = handlebars_renderer(); let steps = ordered_steps(issuetype); @@ -66,9 +64,19 @@ pub fn export_workflow( Ok(out) } +/// A Handlebars renderer configured the way every workflow emitter needs it: +/// non-strict (missing vars render empty) with escaping disabled (the emitters +/// do their own JS/JSON-literal escaping). Shared by the `.js` and AGNT targets. +pub(super) fn handlebars_renderer() -> Handlebars<'static> { + let mut hbs = Handlebars::new(); + hbs.set_strict_mode(false); + hbs.register_escape_fn(handlebars::no_escape); + hbs +} + /// Order steps by following the `next_step` chain from the first step, then /// appending any steps not reachable that way (in declaration order). -fn ordered_steps(it: &IssueType) -> Vec<&StepSchema> { +pub(super) fn ordered_steps(it: &IssueType) -> Vec<&StepSchema> { let mut order: Vec<&StepSchema> = Vec::new(); let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); @@ -112,13 +120,26 @@ fn provenance_header(ticket: &Ticket, it: &IssueType) -> String { ) } -fn meta_block(ticket: &Ticket, it: &IssueType, steps: &[&StepSchema]) -> String { - let name = if ticket.summary.is_empty() { +/// The workflow's display name: `` (falling back to the +/// issuetype name when the ticket has no summary). Shared by the `.js` `meta` +/// block and the AGNT workflow `name`. +pub(super) fn meta_name(ticket: &Ticket, it: &IssueType) -> String { + if ticket.summary.is_empty() { format!("{} — {}", ticket.id, it.name) } else { format!("{} — {}", ticket.id, ticket.summary) - }; - let description = format!("{}: {}", it.name, it.description); + } +} + +/// The workflow's description: `: `. +/// Shared by the `.js` `meta` block and the AGNT workflow `description`. +pub(super) fn meta_description(it: &IssueType) -> String { + format!("{}: {}", it.name, it.description) +} + +fn meta_block(ticket: &Ticket, it: &IssueType, steps: &[&StepSchema]) -> String { + let name = meta_name(ticket, it); + let description = meta_description(it); let mut phases = String::new(); for step in steps { diff --git a/src/workflow_gen/format.rs b/src/workflow_gen/format.rs new file mode 100644 index 00000000..e950c749 --- /dev/null +++ b/src/workflow_gen/format.rs @@ -0,0 +1,24 @@ +//! The output-format selector for workflow export. +//! +//! Operator can render a ticket+issuetype into more than one orchestration +//! format. `Claude` is the original Claude Code dynamic-workflow `.js`; `Agnt` +//! is the AGNT.gg workflow graph JSON. The same shared code path +//! (`export_workflow_for_ticket`) dispatches on this enum so every surface — CLI, +//! REST, TUI, VS Code — selects a format uniformly. + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Which orchestration format `workflow export` should emit. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ValueEnum, ToSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum WorkflowFormat { + /// Claude Code dynamic workflow (`.js`). + #[default] + Claude, + /// AGNT.gg workflow graph (`.json`). + Agnt, +} diff --git a/src/workflow_gen/mod.rs b/src/workflow_gen/mod.rs index 2f70e06b..c660f802 100644 --- a/src/workflow_gen/mod.rs +++ b/src/workflow_gen/mod.rs @@ -14,13 +14,17 @@ //! judge-agent retry loops marked with `OPERATOR-GAP`. `rag`/`mcp` steps depend //! on capabilities the workflow sandbox lacks and are likewise marked. +mod agnt; mod command; mod export; +mod format; pub mod js; mod step_map; +pub use agnt::export_workflow_agnt; pub use command::{export_workflow_for_issuetype, export_workflow_for_ticket, ExportedWorkflow}; pub use export::export_workflow; +pub use format::WorkflowFormat; /// Marker emitted into generated workflows wherever an operator concept could /// not be faithfully represented. diff --git a/tests/model_server_integration.rs b/tests/model_server_integration.rs new file mode 100644 index 00000000..cfc0d666 --- /dev/null +++ b/tests/model_server_integration.rs @@ -0,0 +1,287 @@ +//! Live model-listing integration tests for the cloud model providers. +//! +//! These drive the real [`probe_models`] path against each provider's +//! model-listing endpoint — the same call the REST `/model-servers/.../models` +//! routes use to populate model dropdowns. Listing models consumes **no +//! inference tokens**, so these are cheap to run. +//! +//! ## Environment variables +//! +//! Keyed tests are **skipped** when their key is absent, so `cargo test` stays +//! green for contributors without credentials. Each var is read by the probe via +//! the instance's `api_key_env` (distinct from the operator's own runtime vars so +//! test keys never collide with a developer's live `ANTHROPIC_API_KEY`, etc.): +//! +//! - `OPERATOR_ANTHROPIC_API_KEY` — +//! - `OPERATOR_OPENAI_API_KEY` — +//! - `OPERATOR_GEMINI_API_KEY` — +//! - `OPERATOR_OPENROUTER_API_KEY` — (optional) +//! +//! The `openrouter_keyless` test needs **no key** — `OpenRouter`'s `/models` list +//! is public — so it runs on every CI run as the always-on baseline. It skips +//! gracefully (rather than failing) if the network is unreachable. +//! +//! ## Running +//! +//! ```bash +//! # Keyless baseline only (needs network): +//! cargo test --test model_server_integration openrouter_keyless +//! +//! # A specific keyed provider (needs the key exported): +//! cargo test --test model_server_integration anthropic -- --nocapture --test-threads=1 +//! ``` + +use operator::api::providers::model_server::{probe_models, ProbeOutcome}; +use operator::config::ModelServer; +use std::env; +use tokio::sync::OnceCell; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/// Build a probe target for a kind. `base_url: None` lets the probe fall back to +/// the kind's `default_base_url`; `api_key_env` points the probe at the +/// `OPERATOR_*` test var (else the kind's default probe var). +fn server_for(kind: &str, api_key_env: Option<&str>) -> ModelServer { + ModelServer { + name: kind.to_string(), + kind: kind.to_string(), + base_url: None, + api_key_env: api_key_env.map(str::to_string), + extra_env: std::collections::HashMap::new(), + display_name: None, + } +} + +fn configured(var: &str) -> bool { + env::var(var).map(|s| !s.is_empty()).unwrap_or(false) +} + +/// Skip the test body (with a notice) when the provider's key isn't configured. +macro_rules! skip_if_unconfigured { + ($var:expr, $provider:expr) => { + if !configured($var) { + eprintln!("Skipping {}: {} not set", $provider, $var); + return; + } + }; +} + +/// Assertions shared by every keyed provider: reachable, non-empty, and every +/// model carries a non-empty id (the wire shape dropdowns rely on). +fn assert_listable(outcome: &ProbeOutcome, provider: &str) { + assert!( + outcome.reachable, + "{provider} should be reachable; error: {:?}", + outcome.error + ); + assert!( + !outcome.models.is_empty(), + "{provider} returned an empty model list" + ); + assert!( + outcome.models.iter().all(|m| !m.id.is_empty()), + "{provider} returned a model with an empty id" + ); +} + +// Cache one live probe per provider for the whole test run. +static ANTHROPIC: OnceCell = OnceCell::const_new(); +static OPENAI: OnceCell = OnceCell::const_new(); +static GOOGLE: OnceCell = OnceCell::const_new(); +static OPENROUTER: OnceCell = OnceCell::const_new(); + +async fn anthropic_outcome() -> ProbeOutcome { + ANTHROPIC + .get_or_init(|| probe_models_owned("anthropic-api", "OPERATOR_ANTHROPIC_API_KEY")) + .await + .clone() +} + +async fn openai_outcome() -> ProbeOutcome { + OPENAI + .get_or_init(|| probe_models_owned("openai-api", "OPERATOR_OPENAI_API_KEY")) + .await + .clone() +} + +async fn google_outcome() -> ProbeOutcome { + GOOGLE + .get_or_init(|| probe_models_owned("google-api", "OPERATOR_GEMINI_API_KEY")) + .await + .clone() +} + +async fn openrouter_outcome() -> ProbeOutcome { + OPENROUTER + .get_or_init(|| probe_models_owned("openrouter", "OPERATOR_OPENROUTER_API_KEY")) + .await + .clone() +} + +async fn probe_models_owned(kind: &'static str, api_key_env: &'static str) -> ProbeOutcome { + probe_models(&server_for(kind, Some(api_key_env))).await +} + +// ─── Keyless OpenRouter baseline (no secret, runs every CI run) ─────────────── + +mod openrouter_keyless { + use super::*; + + #[tokio::test] + async fn test_public_models_list_is_text_filtered() { + // No api_key_env and no OPENROUTER_API_KEY needed — the list is public. + let outcome = probe_models(&server_for("openrouter", None)).await; + + if !outcome.reachable { + // Treat a network/outage failure as a skip so offline `cargo test` + // stays green; CI normally has connectivity. + eprintln!( + "Skipping keyless OpenRouter test: not reachable ({:?})", + outcome.error + ); + return; + } + + assert!( + !outcome.models.is_empty(), + "public OpenRouter list should be non-empty" + ); + assert!( + outcome.models.iter().any(|m| { + let id = m.id.to_ascii_lowercase(); + id.contains("claude") || id.contains("gpt") || id.contains("gemini") + }), + "OpenRouter list should include a recognizable text model" + ); + } +} + +// ─── Keyed per-provider tests ───────────────────────────────────────────────── + +mod anthropic { + use super::*; + + #[tokio::test] + async fn test_lists_text_models() { + skip_if_unconfigured!("OPERATOR_ANTHROPIC_API_KEY", "Anthropic"); + let outcome = anthropic_outcome().await; + assert_listable(&outcome, "Anthropic"); + assert!( + outcome.models.iter().any(|m| m.id.contains("claude")), + "Anthropic list should contain a claude model" + ); + } +} + +mod openai { + use super::*; + + #[tokio::test] + async fn test_lists_text_models() { + skip_if_unconfigured!("OPERATOR_OPENAI_API_KEY", "OpenAI"); + let outcome = openai_outcome().await; + assert_listable(&outcome, "OpenAI"); + assert!( + outcome.models.iter().any(|m| m.id.contains("gpt")), + "OpenAI list should contain a gpt model" + ); + } + + #[tokio::test] + async fn test_excludes_non_text_models() { + skip_if_unconfigured!("OPERATOR_OPENAI_API_KEY", "OpenAI"); + let outcome = openai_outcome().await; + assert!( + outcome.models.iter().all(|m| { + let id = m.id.to_ascii_lowercase(); + !id.contains("embedding") + && !id.starts_with("whisper") + && !id.starts_with("tts") + && !id.starts_with("dall-e") + && !id.contains("moderation") + }), + "OpenAI list should be filtered to text models, got: {:?}", + outcome.models.iter().map(|m| &m.id).collect::>() + ); + } +} + +mod google { + use super::*; + + #[tokio::test] + async fn test_lists_text_models() { + skip_if_unconfigured!("OPERATOR_GEMINI_API_KEY", "Google"); + let outcome = google_outcome().await; + assert_listable(&outcome, "Google"); + assert!( + outcome.models.iter().any(|m| m.id.contains("gemini")), + "Google list should contain a gemini model" + ); + } + + #[tokio::test] + async fn test_excludes_embedding_models() { + skip_if_unconfigured!("OPERATOR_GEMINI_API_KEY", "Google"); + let outcome = google_outcome().await; + assert!( + outcome.models.iter().all(|m| !m.id.contains("embedding")), + "Google list should exclude embedding models, got: {:?}", + outcome.models.iter().map(|m| &m.id).collect::>() + ); + } +} + +mod openrouter_keyed { + use super::*; + + #[tokio::test] + async fn test_lists_text_models() { + skip_if_unconfigured!("OPERATOR_OPENROUTER_API_KEY", "OpenRouter"); + let outcome = openrouter_outcome().await; + assert_listable(&outcome, "OpenRouter"); + assert!( + outcome.models.iter().any(|m| { + let id = m.id.to_ascii_lowercase(); + id.contains("claude") || id.contains("gpt") || id.contains("gemini") + }), + "OpenRouter list should include a recognizable text model" + ); + } +} + +// ─── Cross-provider consistency ─────────────────────────────────────────────── + +/// Every configured + reachable provider returns the same uniform +/// `ModelInfo { id, display_name }` shape — a non-empty id for each model — so a +/// single dropdown component can render all of them. Skips when nothing is +/// configured. +#[tokio::test] +async fn test_all_providers_return_same_model_shape() { + let mut checked = 0usize; + + for (var, provider, outcome) in [ + ( + "OPERATOR_ANTHROPIC_API_KEY", + "Anthropic", + anthropic_outcome().await, + ), + ("OPERATOR_OPENAI_API_KEY", "OpenAI", openai_outcome().await), + ("OPERATOR_GEMINI_API_KEY", "Google", google_outcome().await), + ( + "OPERATOR_OPENROUTER_API_KEY", + "OpenRouter", + openrouter_outcome().await, + ), + ] { + if !configured(var) || !outcome.reachable { + continue; + } + assert_listable(&outcome, provider); + checked += 1; + } + + if checked == 0 { + eprintln!("Skipping consistency check: no providers configured/reachable"); + } +} diff --git a/ui/public/icons/anthropic.svg b/ui/public/icons/anthropic.svg new file mode 100644 index 00000000..c917480d --- /dev/null +++ b/ui/public/icons/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/ui/public/icons/google.svg b/ui/public/icons/google.svg new file mode 100644 index 00000000..2eaf9155 --- /dev/null +++ b/ui/public/icons/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/ui/public/icons/ollama.svg b/ui/public/icons/ollama.svg new file mode 100644 index 00000000..bc368e99 --- /dev/null +++ b/ui/public/icons/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/ui/public/icons/openrouter.svg b/ui/public/icons/openrouter.svg new file mode 100644 index 00000000..83ec807b --- /dev/null +++ b/ui/public/icons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index b8dca6cc..69c2146b 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -21,6 +21,14 @@ import type { Config } from '@operator/bindings/Config'; import type { AgentDetailResponse } from '@operator/bindings/AgentDetailResponse'; import type { WorkflowExportResponse } from '@operator/bindings/WorkflowExportResponse'; import type { WorkflowPreviewResponse } from '@operator/bindings/WorkflowPreviewResponse'; +import type { ModelServerKindEntry } from '@operator/bindings/ModelServerKindEntry'; +import type { ModelServerModelsResponse } from '@operator/bindings/ModelServerModelsResponse'; +import type { ModelServersResponse } from '@operator/bindings/ModelServersResponse'; +import type { ModelServerResponse } from '@operator/bindings/ModelServerResponse'; +import type { CreateModelServerRequest } from '@operator/bindings/CreateModelServerRequest'; +import type { DelegatorsResponse } from '@operator/bindings/DelegatorsResponse'; +import type { DelegatorResponse } from '@operator/bindings/DelegatorResponse'; +import type { CreateDelegatorRequest } from '@operator/bindings/CreateDelegatorRequest'; export type { HealthResponse, @@ -45,6 +53,14 @@ export type { AgentDetailResponse, WorkflowExportResponse, WorkflowPreviewResponse, + ModelServerKindEntry, + ModelServerModelsResponse, + ModelServersResponse, + ModelServerResponse, + CreateModelServerRequest, + DelegatorsResponse, + DelegatorResponse, + CreateDelegatorRequest, }; export class ApiError extends Error { @@ -226,6 +242,46 @@ export class OperatorApi { }); } + // --- Model providers --- + + /** The catalog of supported model providers (kinds). */ + listProviderKinds(): Promise { + return request(this.base, '/api/v1/model-servers/kinds'); + } + + /** Live models for a provider kind (probes declared instance or defaults). */ + providerModels(slug: string): Promise { + return request(this.base, `/api/v1/model-servers/kinds/${encodeURIComponent(slug)}/models`); + } + + /** Declared servers + implicit builtins. */ + listModelServers(): Promise { + return request(this.base, '/api/v1/model-servers'); + } + + /** Connect a gateway provider by declaring an instance. */ + createModelServer(req: CreateModelServerRequest): Promise { + return request(this.base, '/api/v1/model-servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + } + + // --- Delegators --- + + listDelegators(): Promise { + return request(this.base, '/api/v1/delegators'); + } + + createDelegator(req: CreateDelegatorRequest): Promise { + return request(this.base, '/api/v1/delegators', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + } + // --- Workflow export --- /** Export a ticket (rendered against its issue type) to a Claude dynamic workflow (.js). */ diff --git a/ui/src/components/BrandIcon.tsx b/ui/src/components/BrandIcon.tsx new file mode 100644 index 00000000..97e95f33 --- /dev/null +++ b/ui/src/components/BrandIcon.tsx @@ -0,0 +1,23 @@ +// Renders a vendor brand logo as a 16px inline image. `name` is a brand +// basename (e.g. "ollama", "openrouter") served from `ui/public/icons/`, set by +// the backend `ModelServerKind::brand_icon()` and carried on `SectionRowDto`'s +// `brand_icon` field. Unlike `ConceptIcon` (a recolorable codicon font glyph), +// brand logos are full-color SVGs, so they don't inherit `currentColor`. + +interface BrandIconProps { + name: string; + className?: string; +} + +export function BrandIcon({ name, className }: BrandIconProps) { + return ( + + ); +} diff --git a/ui/src/components/SectionCard.tsx b/ui/src/components/SectionCard.tsx index 530e3556..0636ab8a 100644 --- a/ui/src/components/SectionCard.tsx +++ b/ui/src/components/SectionCard.tsx @@ -6,6 +6,7 @@ import { Link } from 'react-router-dom'; import type { SectionDto } from '../api-client'; import { CONCEPTS } from '../concepts'; +import { BrandIcon } from './BrandIcon'; import styles from './SectionCard.module.css'; export function SectionCard({ section }: { section: SectionDto }) { @@ -53,6 +54,7 @@ export function SectionCard({ section }: { section: SectionDto }) { style={{ paddingLeft: `${Math.max(0, row.depth - 1) * 1.25}rem` }} > + {row.brand_icon && } {row.label} {row.description && {row.description}} {row.actions.length > 0 && ( diff --git a/ui/src/concepts.ts b/ui/src/concepts.ts index f68bb38c..7b8bb7c9 100644 --- a/ui/src/concepts.ts +++ b/ui/src/concepts.ts @@ -81,10 +81,10 @@ export const CONCEPTS: Record = { 'model-servers': { key: 'model-servers', icon: 'server', - label: 'Model Servers', - route: '/model-servers', + label: 'Model Providers', + route: '/model-providers', docsUrl: `${DOCS_BASE}/getting-started/model-servers/`, - summary: 'Configured model servers backing the LLM tools.', + summary: 'Connect model providers and list their models for delegators.', }, git: { key: 'git', diff --git a/ui/src/main.tsx b/ui/src/main.tsx index efacdc14..bb3dd798 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -12,6 +12,7 @@ import { QueuePage } from './routes/QueuePage'; import { StatusPage } from './routes/StatusPage'; import { SectionPage } from './routes/SectionPage'; import { AgentDetailPage } from './routes/AgentDetailPage'; +import { ModelProvidersPage } from './routes/ModelProvidersPage'; const host = createBrowserHost(); @@ -26,7 +27,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/routes/ModelProvidersPage.module.css b/ui/src/routes/ModelProvidersPage.module.css new file mode 100644 index 00000000..f65716c0 --- /dev/null +++ b/ui/src/routes/ModelProvidersPage.module.css @@ -0,0 +1,182 @@ +.page { + padding: 1rem 1.25rem 3rem; + max-width: 920px; +} + +.loading, +.empty { + padding: 1rem 1.25rem; + color: var(--text-muted); +} + +.error { + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + border-radius: 4px; + background: var(--danger-bg); + color: var(--danger); + font-size: 0.85rem; +} + +.notice { + margin: 0.5rem 0; + padding: 0.5rem 0.75rem; + border-radius: 4px; + background: var(--success-bg); + color: var(--success); + font-size: 0.85rem; +} + +.group { + margin-top: 1.75rem; +} + +.groupHeading { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 0.15rem; + color: var(--text); +} + +.groupBlurb { + margin: 0 0 0.6rem; + color: var(--text-muted); + font-size: 0.82rem; +} + +.providerList, +.delegatorList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.providerRow, +.delegatorRow { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.75rem; + background: var(--surface); +} + +.providerIcon { + display: inline-flex; + width: 16px; + justify-content: center; +} + +.providerName { + font-weight: 600; + font-size: 0.88rem; + min-width: 9rem; +} + +.providerDesc { + flex: 1; + color: var(--text-muted); + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} +.connected { + background: var(--success); +} +.disconnected { + background: var(--text-muted); +} +.checking { + background: var(--warning); +} + +.connText { + font-size: 0.78rem; + color: var(--text-muted); + min-width: 5rem; +} + +.connectBtn, +.submitBtn { + border: 1px solid var(--border); + background: var(--surface-alt); + color: var(--text); + border-radius: 4px; + padding: 0.25rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.connectBtn:hover, +.submitBtn:hover { + border-color: var(--text-muted); +} + +.submitBtn { + align-self: flex-start; + padding: 0.4rem 0.9rem; + font-size: 0.85rem; +} +.submitBtn:disabled { + opacity: 0.6; + cursor: default; +} + +.hint { + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-mono, monospace); +} + +.form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 460px; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.fieldLabel { + font-size: 0.78rem; + color: var(--text-muted); +} + +.select, +.input { + padding: 0.4rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text); + font-size: 0.85rem; +} + +.delegatorName { + font-weight: 600; + font-size: 0.86rem; + min-width: 10rem; +} + +.delegatorMeta { + color: var(--text-muted); + font-size: 0.8rem; + font-family: var(--font-mono, monospace); +} diff --git a/ui/src/routes/ModelProvidersPage.tsx b/ui/src/routes/ModelProvidersPage.tsx new file mode 100644 index 00000000..ab1d6b1c --- /dev/null +++ b/ui/src/routes/ModelProvidersPage.tsx @@ -0,0 +1,364 @@ +// The Model Providers view — distinct from the LLM Tools (Coding Agents) page. +// Lists every supported provider (first-party vendors + gateways), shows each +// one's connection state (a live /models probe), and lets you create a delegator +// by picking a connected provider + one of its live models. + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { OperatorApi } from '../api-client'; +import type { + ModelServerKindEntry, + ModelServerModelsResponse, + Config, + DelegatorResponse, +} from '../api-client'; +import { useHost } from '../host'; +import { CONCEPTS } from '../concepts'; +import { PageHeader } from '../components/PageHeader'; +import { BrandIcon } from '../components/BrandIcon'; +import { ConceptIcon } from '../components/ConceptIcon'; +import styles from './ModelProvidersPage.module.css'; + +const CONCEPT = CONCEPTS['model-servers']; + +/** Live connection probe per provider slug. `undefined` = still loading. */ +type ProbeMap = Record; + +export function ModelProvidersPage() { + const host = useHost(); + const [api] = useState(() => new OperatorApi(host)); + const [kinds, setKinds] = useState([]); + const [probes, setProbes] = useState({}); + const [detectedTools, setDetectedTools] = useState([]); + const [delegators, setDelegators] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const refreshDelegators = useCallback(() => { + api + .listDelegators() + .then((r) => setDelegators(r.delegators)) + .catch(() => {/* non-fatal */}); + }, [api]); + + // Load the catalog + detected tools, then probe each provider for connection. + useEffect(() => { + let cancelled = false; + Promise.all([api.listProviderKinds(), api.getConfiguration()]) + .then(([catalog, config]: [ModelServerKindEntry[], Config]) => { + if (cancelled) return; + setKinds(catalog); + setDetectedTools(config.llm_tools.detected.map((t) => t.name)); + // Probe each provider concurrently; fill the map as results land. + for (const k of catalog) { + api + .providerModels(k.slug) + .then((r) => !cancelled && setProbes((p) => ({ ...p, [k.slug]: r }))) + .catch( + () => + !cancelled && + setProbes((p) => ({ + ...p, + [k.slug]: { server: k.slug, reachable: false, models: [], error: 'probe failed' }, + })), + ); + } + }) + .catch((e) => !cancelled && setError(e instanceof Error ? e.message : 'Failed to load')) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [api]); + + useEffect(refreshDelegators, [refreshDelegators]); + + const connectGateway = async (kind: ModelServerKindEntry) => { + setError(null); + try { + await api.createModelServer({ + name: kind.slug, + kind: kind.slug, + base_url: kind.default_base_url ?? null, + api_key_env: kind.default_api_key_env ?? null, + extra_env: {}, + display_name: kind.display_name, + }); + setNotice(`Declared "${kind.slug}". Re-probing…`); + const r = await api.providerModels(kind.slug); + setProbes((p) => ({ ...p, [kind.slug]: r })); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to connect provider'); + } + }; + + const firstParty = useMemo(() => kinds.filter((k) => k.category === 'first-party'), [kinds]); + const gateways = useMemo(() => kinds.filter((k) => k.category === 'gateway'), [kinds]); + + if (loading) return
Loading model providers…
; + + return ( +
+ + + {error &&
{error}
} + {notice &&
{notice}
} + + + + + { + setNotice(`Created delegator "${name}".`); + refreshDelegators(); + }} + onError={setError} + /> + +
+

Delegators

+ {delegators.length === 0 ? ( +

No delegators yet.

+ ) : ( +
    + {delegators.map((d) => ( +
  • + {d.display_name ?? d.name} + + {d.llm_tool}:{d.model} + {d.model_server ? ` @ ${d.model_server}` : ''} + +
  • + ))} +
+ )} +
+
+ ); +} + +function connectionLabel(probe: ModelServerModelsResponse | undefined): { + state: 'connected' | 'disconnected' | 'checking'; + text: string; +} { + if (probe === undefined) return { state: 'checking', text: 'checking…' }; + if (probe.reachable) return { state: 'connected', text: `${probe.models.length} models` }; + return { state: 'disconnected', text: 'not connected' }; +} + +function ProviderGroup({ + heading, + blurb, + kinds, + probes, + onConnect, +}: { + heading: string; + blurb: string; + kinds: ModelServerKindEntry[]; + probes: ProbeMap; + onConnect: (k: ModelServerKindEntry) => void; +}) { + if (kinds.length === 0) return null; + return ( +
+

{heading}

+

{blurb}

+
    + {kinds.map((k) => { + const probe = probes[k.slug]; + const conn = connectionLabel(probe); + return ( +
  • + + {k.brand_icon ? : } + + {k.display_name} + {k.description} + + {conn.text} + {conn.state === 'disconnected' && k.connectable && !k.is_builtin && ( + + )} + {conn.state === 'disconnected' && k.default_api_key_env && ( + set {k.default_api_key_env} + )} + {!k.connectable && ( + + needs base_url + + )} +
  • + ); + })} +
+
+ ); +} + +function CreateDelegatorForm({ + api, + kinds, + probes, + detectedTools, + onCreated, + onError, +}: { + api: OperatorApi; + kinds: ModelServerKindEntry[]; + probes: ProbeMap; + detectedTools: string[]; + onCreated: (name: string) => void; + onError: (msg: string) => void; +}) { + const [tool, setTool] = useState(''); + const [provider, setProvider] = useState(''); + const [model, setModel] = useState(''); + const [name, setName] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Default the tool once detection lands. + useEffect(() => { + if (!tool && detectedTools.length > 0) setTool(detectedTools[0]); + }, [detectedTools, tool]); + + const probe = provider ? probes[provider] : undefined; + const liveModels = probe?.reachable ? probe.models : []; + + const submit = async () => { + if (!tool || !provider || !model) { + onError('Pick a tool, a provider, and a model.'); + return; + } + setSubmitting(true); + try { + const delegatorName = name.trim() || `${tool}-${model}`; + await api.createDelegator({ + name: delegatorName, + llm_tool: tool, + model, + display_name: null, + model_properties: {}, + model_server: provider, + launch_config: null, + remote_agent: null, + }); + setName(''); + setModel(''); + onCreated(delegatorName); + } catch (e) { + onError(e instanceof Error ? e.message : 'Failed to create delegator'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+

Create delegator

+

+ Pair an llm tool with a connected provider and one of its live models. +

+
+ + + + + + + + + +
+
+ ); +} diff --git a/vscode-extension/images/icons/dist/operator-icons.css b/vscode-extension/images/icons/dist/operator-icons.css index 5df7186f..1b09c5dc 100644 --- a/vscode-extension/images/icons/dist/operator-icons.css +++ b/vscode-extension/images/icons/dist/operator-icons.css @@ -1,6 +1,6 @@ @font-face { font-family: "operator-icons"; - src: url("./operator-icons.woff?fcc8d5950361d91fb09f1e205e9c5b3b") format("woff"); + src: url("./operator-icons.woff?6c3ab21a9c63b13425d5ccd88817371e") format("woff"); } i[class^="opi-"]:before, i[class*=" opi-"]:before { @@ -29,33 +29,45 @@ i[class^="opi-"]:before, i[class*=" opi-"]:before { .opi-tmux:before { content: "\f105"; } -.opi-notification:before { +.opi-openrouter:before { content: "\f106"; } -.opi-linear:before { +.opi-ollama:before { content: "\f107"; } -.opi-gitlab:before { +.opi-notification:before { content: "\f108"; } -.opi-github:before { +.opi-linear:before { content: "\f109"; } -.opi-gemini:before { +.opi-google:before { content: "\f10a"; } -.opi-codex:before { +.opi-gitlab:before { content: "\f10b"; } -.opi-coder:before { +.opi-github:before { content: "\f10c"; } -.opi-cmux:before { +.opi-gemini:before { content: "\f10d"; } -.opi-claude:before { +.opi-codex:before { content: "\f10e"; } -.opi-atlassian:before { +.opi-coder:before { content: "\f10f"; } +.opi-cmux:before { + content: "\f110"; +} +.opi-claude:before { + content: "\f111"; +} +.opi-atlassian:before { + content: "\f112"; +} +.opi-anthropic:before { + content: "\f113"; +} diff --git a/vscode-extension/images/icons/dist/operator-icons.json b/vscode-extension/images/icons/dist/operator-icons.json index 42e41bfa..338ab2fc 100644 --- a/vscode-extension/images/icons/dist/operator-icons.json +++ b/vscode-extension/images/icons/dist/operator-icons.json @@ -4,14 +4,18 @@ "webhook": 61699, "vscode": 61700, "tmux": 61701, - "notification": 61702, - "linear": 61703, - "gitlab": 61704, - "github": 61705, - "gemini": 61706, - "codex": 61707, - "coder": 61708, - "cmux": 61709, - "claude": 61710, - "atlassian": 61711 + "openrouter": 61702, + "ollama": 61703, + "notification": 61704, + "linear": 61705, + "google": 61706, + "gitlab": 61707, + "github": 61708, + "gemini": 61709, + "codex": 61710, + "coder": 61711, + "cmux": 61712, + "claude": 61713, + "atlassian": 61714, + "anthropic": 61715 } \ No newline at end of file diff --git a/vscode-extension/images/icons/dist/operator-icons.woff b/vscode-extension/images/icons/dist/operator-icons.woff index 6db006ca..17a79dda 100644 Binary files a/vscode-extension/images/icons/dist/operator-icons.woff and b/vscode-extension/images/icons/dist/operator-icons.woff differ diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index d50e12a7..ba960a14 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "operator-terminals", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "operator-terminals", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -21,34 +21,34 @@ "@eslint/js": "^10.0.0", "@istanbuljs/nyc-config-typescript": "^1.0.2", "@stylistic/eslint-plugin-ts": "^4.4.1", - "@types/glob": "^8.1.0", + "@types/glob": "^9.0.0", "@types/mocha": "^10.0.6", "@types/node": "25.x", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@types/sinon": "^17.0.3", + "@types/sinon": "^21.0.1", "@types/vscode": "^1.120.0", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.3.8", - "@vscode/vsce": "^3.9.1", + "@vscode/test-electron": "^3.0.0", + "@vscode/vsce": "^3.9.2", "c8": "^11.0.0", "css-loader": "^7.1.4", - "eslint": "^10.3.0", + "eslint": "^10.4.1", "fantasticon": "^4.1.0", "glob": "^13.0.6", "mocha": "^11.7.5", "nyc": "^18.0.0", - "ovsx": "^0.10.0", + "ovsx": "^1.0.1", "sinon": "^22.0.0", "source-map-support": "^0.5.21", "style-loader": "^4.0.0", - "ts-loader": "^9.5.7", + "ts-loader": "^9.6.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.3", + "typescript-eslint": "^8.61.1", "webpack": "^5.106.2", - "webpack-cli": "^7.0.2" + "webpack-cli": "^7.0.3" }, "engines": { "vscode": "^1.120.0" @@ -100,9 +100,9 @@ } }, "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -119,9 +119,9 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", - "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", "dev": true, "license": "MIT", "dependencies": { @@ -203,22 +203,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz", - "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.13.0.tgz", + "integrity": "sha512-Ea23x0U8XNFY+qJ9T44zO2BbY+AHdb+WdjmYnx36OhJ/KO+PGU5pmsNHf1DCElYX+6wyVRJz1HFeCPC/cHbRug==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.6.2" + "@azure/msal-common": "16.8.0" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "16.6.2", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz", - "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==", + "version": "16.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.8.0.tgz", + "integrity": "sha512-5S4RHOcInL2Nu2U217tDZbWGI6StMfcWCrA7TWvWdJmXQ+cYrrIqr84AsN62fGh2MDBysiBJPt6CfWceJfloEA==", "dev": true, "license": "MIT", "engines": { @@ -226,13 +226,13 @@ } }, "node_modules/@azure/msal-node": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz", - "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==", + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.4.tgz", + "integrity": "sha512-rpBUg9dA8UpC2WiFt3KeDKVQmmmVrfxdRnW+F1ebgou/jX/0tAvYuonaq5RUo8OaqzOrj4x/HaI8DmY56RXZ2Q==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.6.2", + "@azure/msal-common": "16.8.0", "jsonwebtoken": "^9.0.0" }, "engines": { @@ -543,21 +543,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -566,9 +566,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -837,9 +837,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2163,14 +2163,14 @@ "license": "MIT" }, "node_modules/@types/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-9.0.0.tgz", + "integrity": "sha512-00UxlRaIUvYm4R4W9WYkN8/J+kV8fmOQ7okeH6YFtGWFMt3odD45tpG5yA5wnL7HE6lLgjaTW5n14ju2hl2NNA==", + "deprecated": "This is a stub types definition. glob provides its own type definitions, so you do not need this installed.", "dev": true, "license": "MIT", "dependencies": { - "@types/minimatch": "^5.1.2", - "@types/node": "*" + "glob": "*" } }, "node_modules/@types/istanbul-lib-coverage": { @@ -2187,13 +2187,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -2202,9 +2195,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2231,9 +2224,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.29", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", - "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2277,9 +2270,9 @@ } }, "node_modules/@types/sinon": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", - "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz", + "integrity": "sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2301,17 +2294,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2324,22 +2317,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.0", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "engines": { @@ -2355,14 +2348,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "engines": { @@ -2377,14 +2370,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2395,9 +2388,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -2412,15 +2405,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2437,9 +2430,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -2451,16 +2444,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2479,16 +2472,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2503,13 +2496,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2534,9 +2527,9 @@ } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", - "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", "dev": true, "license": "MIT", "dependencies": { @@ -2740,9 +2733,9 @@ } }, "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", - "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.0.0.tgz", + "integrity": "sha512-TY5mC7aAjxSLDXsyjhrG8cJHgc/HLdiE5lvtW7hABYQrY24Qwozzr5UoO3HiuAM4Hzz4b7K/eZlwrCILj94CcA==", "dev": true, "license": "MIT", "dependencies": { @@ -2753,13 +2746,13 @@ "semver": "^7.6.2" }, "engines": { - "node": ">=16" + "node": ">=22" } }, "node_modules/@vscode/vsce": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", - "integrity": "sha512-MPn5p+DoudI+3GfJSpAZZraE1lgLv0LcwbH3+xy7RgEhty3UIkmUMUA+5jPTDaxXae00AnX5u77FxGM8FhfKKA==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", "dev": true, "license": "MIT", "dependencies": { @@ -2775,13 +2768,13 @@ "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", - "glob": "^11.0.0", + "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", - "minimatch": "^3.0.3", + "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", @@ -2948,104 +2941,6 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@vscode/vsce/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -3242,9 +3137,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -3511,9 +3406,9 @@ "optional": true }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3997,9 +3892,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -4329,13 +4224,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -4793,9 +4681,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.373", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz", + "integrity": "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==", "dev": true, "license": "ISC" }, @@ -4843,9 +4731,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", + "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5062,18 +4950,21 @@ } }, "node_modules/eslint": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", - "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5461,16 +5352,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5651,17 +5532,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -6049,9 +5930,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6657,9 +6538,9 @@ } }, "node_modules/istanbul-lib-processinfo": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", - "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.1.tgz", + "integrity": "sha512-s3mX05h5wGZeScG6XnOanygPh4SJu5ujMc9YbvpnLGXWy1cRiGbp0NdVcjHxgoZt3WfQppfBsa0y+gWdYJ2pGQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6667,8 +6548,7 @@ "cross-spawn": "^7.0.3", "istanbul-lib-coverage": "^3.2.0", "p-map": "^3.0.0", - "rimraf": "^6.1.3", - "uuid": "^8.3.2" + "rimraf": "^6.1.3" }, "engines": { "node": "20 || >=22" @@ -6826,10 +6706,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7912,9 +7802,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { @@ -8459,9 +8349,9 @@ } }, "node_modules/ovsx": { - "version": "0.10.12", - "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-0.10.12.tgz", - "integrity": "sha512-WwMj1iQDvCk02029oxPnkFXsPrHZ+WzmoNW5pJ8JGepHtL30i2JE4s3C3wqzQqj6a35vx2hp0gV3TdfefGmvMg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-1.0.1.tgz", + "integrity": "sha512-dNIVv/33z/wURvH1wAw2PRicVM5CdQB3BRL3WhEeFEFMDUkKoujlBPpqMr/meIpf4xDYUEfRwFRBiDzs38vQvw==", "dev": true, "license": "EPL-2.0", "dependencies": { @@ -8723,9 +8613,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8939,9 +8829,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", "dev": true, "license": "MIT", "dependencies": { @@ -9205,9 +9095,9 @@ } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, "node_modules/react-transition-group": { @@ -9630,9 +9520,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -9703,15 +9593,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -10558,9 +10448,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -10679,9 +10569,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", - "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10799,9 +10689,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -10847,9 +10737,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -10893,9 +10783,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10909,8 +10799,14 @@ "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, "node_modules/ts-loader/node_modules/source-map": { @@ -11080,16 +10976,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", - "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11132,9 +11028,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -11252,17 +11148,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -11310,13 +11195,12 @@ } }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -11371,17 +11255,16 @@ } }, "node_modules/webpack-cli": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.2.tgz", - "integrity": "sha512-dB0R4T+C/8YuvM+fabdvil6QE44/ChDXikV5lOOkrUeCkW5hTJv2pGLE3keh+D5hjYw8icBaJkZzpFoaHV4T+g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.0.3.tgz", + "integrity": "sha512-2E2C6A1e2El7791zQgTH7LPIuwLjRliow9OHS/qlJc9pwhZlCoL/uiwqd/1WSlXT83wJfmfDbkcqHXuXoPJZ3g==", "dev": true, "license": "MIT", "dependencies": { - "@discoveryjs/json-ext": "^1.0.0", + "@discoveryjs/json-ext": "^1.1.0", "commander": "^14.0.3", "cross-spawn": "^7.0.6", "envinfo": "^7.14.0", - "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", @@ -11807,13 +11690,12 @@ } }, "node_modules/yauzl": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.1.tgz", - "integrity": "sha512-RNPCUkiE/ZgO4w8i9U5yDQVHaFDdnzaFANElRvpJteCspvmv2VqrRb9lvS6odVD+jqI/zDsxAHJVsafpcheVQQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", "pend": "~1.2.0" }, "engines": { diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 86a0ba1b..8c36dd27 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -462,63 +462,91 @@ "description": "Atlassian (Jira) provider icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F10D" + "fontCharacter": "\\F112" } }, "operator-linear": { "description": "Linear provider icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F106" + "fontCharacter": "\\F109" } }, "operator-claude": { "description": "Claude LLM tool icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F10C" + "fontCharacter": "\\F111" } }, "operator-codex": { "description": "Codex LLM tool icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F10A" + "fontCharacter": "\\F10E" } }, "operator-gemini": { "description": "Gemini LLM tool icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F109" + "fontCharacter": "\\F10D" + } + }, + "operator-google": { + "description": "Google (Gemini API) model-server icon", + "default": { + "fontPath": "images/icons/dist/operator-icons.woff", + "fontCharacter": "\\F10A" + } + }, + "operator-anthropic": { + "description": "Anthropic (Claude API) model-server icon", + "default": { + "fontPath": "images/icons/dist/operator-icons.woff", + "fontCharacter": "\\F113" + } + }, + "operator-ollama": { + "description": "Ollama model-provider icon", + "default": { + "fontPath": "images/icons/dist/operator-icons.woff", + "fontCharacter": "\\F107" + } + }, + "operator-openrouter": { + "description": "OpenRouter model-provider icon", + "default": { + "fontPath": "images/icons/dist/operator-icons.woff", + "fontCharacter": "\\F106" } }, "operator-github": { "description": "GitHub provider icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F108" + "fontCharacter": "\\F10C" } }, "operator-gitlab": { "description": "GitLab provider icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F107" + "fontCharacter": "\\F10B" } }, "operator-tmux": { "description": "tmux session wrapper icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F104" + "fontCharacter": "\\F105" } }, "operator-cmux": { "description": "cmux session wrapper icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F10B" + "fontCharacter": "\\F110" } }, "operator-zellij": { @@ -532,7 +560,7 @@ "description": "VS Code session wrapper icon", "default": { "fontPath": "images/icons/dist/operator-icons.woff", - "fontCharacter": "\\F103" + "fontCharacter": "\\F104" } } } @@ -566,33 +594,33 @@ "@istanbuljs/nyc-config-typescript": "^1.0.2", "@eslint/js": "^10.0.0", "@stylistic/eslint-plugin-ts": "^4.4.1", - "@types/glob": "^8.1.0", + "@types/glob": "^9.0.0", "@types/mocha": "^10.0.6", "@types/node": "25.x", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@types/sinon": "^17.0.3", + "@types/sinon": "^21.0.1", "@types/vscode": "^1.120.0", - "@typescript-eslint/eslint-plugin": "^8.59.3", - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/eslint-plugin": "^8.61.1", + "@typescript-eslint/parser": "^8.61.1", "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.3.8", - "@vscode/vsce": "^3.9.1", + "@vscode/test-electron": "^3.0.0", + "@vscode/vsce": "^3.9.2", "c8": "^11.0.0", "css-loader": "^7.1.4", - "eslint": "^10.3.0", + "eslint": "^10.4.1", "fantasticon": "^4.1.0", "glob": "^13.0.6", "mocha": "^11.7.5", "nyc": "^18.0.0", - "ovsx": "^0.10.0", + "ovsx": "^1.0.1", "sinon": "^22.0.0", "source-map-support": "^0.5.21", "style-loader": "^4.0.0", - "ts-loader": "^9.5.7", + "ts-loader": "^9.6.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.3", + "typescript-eslint": "^8.61.1", "webpack": "^5.106.2", - "webpack-cli": "^7.0.2" + "webpack-cli": "^7.0.3" } } diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index e7e9ad10..cddde505 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -32,6 +32,13 @@ import type { SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, WorkflowExportResponse, + ModelServerKindEntry, + ModelServerModelsResponse, + ModelServerResponse, + CreateModelServerRequest, + DelegatorsResponse, + DelegatorResponse, + CreateDelegatorRequest, } from './generated'; // Re-export generated types for consumers @@ -55,6 +62,13 @@ export type { WriteKanbanConfigResponse, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, + ModelServerKindEntry, + ModelServerModelsResponse, + ModelServerResponse, + CreateModelServerRequest, + DelegatorsResponse, + DelegatorResponse, + CreateDelegatorRequest, }; /** @@ -729,4 +743,57 @@ export class OperatorApiClient { return (await response.json()) as SetKanbanSessionEnvResponse; } + + // --- Model providers --- + + private async getJson(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`); + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + return (await response.json()) as T; + } + + private async postJson(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const error = (await response.json().catch(() => ({ + error: 'unknown', + message: `HTTP ${response.status}: ${response.statusText}`, + }))) as ApiError; + throw new Error(error.message); + } + return (await response.json()) as T; + } + + /** The catalog of supported model providers (kinds). */ + async listProviderKinds(): Promise { + return this.getJson('/api/v1/model-servers/kinds'); + } + + /** Live models for a provider kind (declared instance or kind defaults). */ + async providerModels(slug: string): Promise { + return this.getJson(`/api/v1/model-servers/kinds/${encodeURIComponent(slug)}/models`); + } + + /** Connect a gateway provider by declaring an instance. */ + async createModelServer(req: CreateModelServerRequest): Promise { + return this.postJson('/api/v1/model-servers', req); + } + + async listDelegators(): Promise { + return this.getJson('/api/v1/delegators'); + } + + async createDelegator(req: CreateDelegatorRequest): Promise { + return this.postJson('/api/v1/delegators', req); + } } diff --git a/vscode-extension/src/config-panel.ts b/vscode-extension/src/config-panel.ts index a3440238..8ecdc3bb 100644 --- a/vscode-extension/src/config-panel.ts +++ b/vscode-extension/src/config-panel.ts @@ -19,6 +19,7 @@ import { resolveWorkingDirectory, } from './config-paths'; import { OperatorApiClient, discoverApiUrl } from './api-client'; +import type { CreateDelegatorRequest } from './generated'; /** Message types from the webview */ interface WebviewMessage { @@ -30,6 +31,7 @@ interface WebviewMessage { interface WebviewConfig { config_path: string; working_directory: string; + config_exists: boolean; config: Record; } @@ -117,6 +119,13 @@ export class ConfigPanel { `; } + /** Resolve the running operator REST base URL from the current workspace. */ + private async apiUrl(): Promise { + const workDir = resolveWorkingDirectory(); + const ticketsDir = workDir ? path.join(workDir, '.tickets') : undefined; + return discoverApiUrl(ticketsDir); + } + private async _handleMessage(message: WebviewMessage): Promise { switch (message.type) { case 'ready': @@ -311,6 +320,7 @@ export class ConfigPanel { config: { config_path: configPath || '', working_directory: resolveWorkingDirectory(), + config_exists: Boolean(configPath), config: { llm_tools: { detected: tools, providers: [], detection_complete: true } }, }, }); @@ -350,6 +360,10 @@ export class ConfigPanel { void vscode.env.openExternal(vscode.Uri.parse(message.url as string)); break; + case 'openWalkthrough': + await vscode.commands.executeCommand('operator.openWalkthrough'); + break; + case 'openFile': { const filePath = message.filePath as string; if (filePath) { @@ -393,6 +407,82 @@ export class ConfigPanel { break; } + case 'getModelProviders': { + try { + const client = new OperatorApiClient(await this.apiUrl()); + const [kinds, delegatorsResp] = await Promise.all([ + client.listProviderKinds(), + client.listDelegators(), + ]); + void this._panel.webview.postMessage({ + type: 'modelProvidersLoaded', + kinds, + delegators: delegatorsResp.delegators, + }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'modelProvidersError', + error: err instanceof Error ? err.message : 'Failed to load model providers', + }); + } + break; + } + + case 'probeProvider': { + const slug = message.slug as string; + try { + const client = new OperatorApiClient(await this.apiUrl()); + const result = await client.providerModels(slug); + void this._panel.webview.postMessage({ type: 'providerProbed', slug, result }); + } catch { + void this._panel.webview.postMessage({ + type: 'providerProbed', + slug, + result: { server: slug, reachable: false, models: [], error: 'probe failed' }, + }); + } + break; + } + + case 'connectProvider': { + const slug = message.slug as string; + try { + const client = new OperatorApiClient(await this.apiUrl()); + const kinds = await client.listProviderKinds(); + const kind = kinds.find((k) => k.slug === slug); + await client.createModelServer({ + name: slug, + kind: slug, + base_url: kind?.default_base_url ?? null, + api_key_env: kind?.default_api_key_env ?? null, + extra_env: {}, + display_name: kind?.display_name ?? null, + }); + const result = await client.providerModels(slug); + void this._panel.webview.postMessage({ type: 'providerProbed', slug, result }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'modelProvidersError', + error: err instanceof Error ? err.message : 'Failed to connect provider', + }); + } + break; + } + + case 'createDelegator': { + try { + const client = new OperatorApiClient(await this.apiUrl()); + const created = await client.createDelegator(message.request as CreateDelegatorRequest); + void this._panel.webview.postMessage({ type: 'delegatorCreated', name: created.name }); + } catch (err) { + void this._panel.webview.postMessage({ + type: 'modelProvidersError', + error: err instanceof Error ? err.message : 'Failed to create delegator', + }); + } + break; + } + case 'getExternalIssueTypes': { const provider = message.provider as string; const projectKey = message.projectKey as string; @@ -475,6 +565,119 @@ interface TomlConfig { [key: string]: unknown; } +// ─── Kanban Providers ─────────────────────────────────────────────────── +// +// The canonical list of kanban providers lives in the Rust +// `KanbanProviderType::ALL` catalog and is projected into the generated +// `KanbanConfig` type (one keyed sub-table per provider). This table mirrors +// that catalog for the webview's config write path. Every provider in the +// generated schema MUST have an entry here — `config-panel.test.ts` enforces +// it so a new provider can't be added to the schema without being wired up. + +/** Per-provider metadata for the kanban config write path. */ +export interface KanbanProviderMeta { + /** + * The form field whose value renames the provider's HashMap key — the Jira + * domain, Linear workspace slug, or GitHub owner login. + */ + instanceKeyField: string; + /** Placeholder instance key used before the user names a real instance. */ + defaultInstanceKey: string; +} + +/** + * Canonical kanban providers keyed by their lowercase slug (matching the + * generated `KanbanConfig` keys and `KanbanProviderType::slug()`). + */ +export const KANBAN_PROVIDERS: Record = { + jira: { instanceKeyField: 'domain', defaultInstanceKey: 'your-org.atlassian.net' }, + linear: { instanceKeyField: 'team_id', defaultInstanceKey: 'default-team' }, + github: { instanceKeyField: 'owner', defaultInstanceKey: 'your-org' }, +}; + +/** Slugs of every supported kanban provider, in catalog order. */ +export const KANBAN_PROVIDER_SLUGS: string[] = Object.keys(KANBAN_PROVIDERS); + +/** Project-level fields written into the first project sub-table by shorthand. */ +const KANBAN_PROJECT_LEVEL_KEYS = [ + 'sync_statuses', + 'collection_name', + 'sync_user_id', + 'type_mappings', +]; + +/** + * Apply a single field update to a kanban provider's sub-table (mutates + * `kanban`). Shared by every provider so adding a provider is a one-line entry + * in {@link KANBAN_PROVIDERS} — no new branch here or in the message handler. + * + * @param kanban the `[kanban]` table from the parsed config + * @param slug provider slug (`jira`, `linear`, `github`, …) + * @param key field key emitted by the webview (`enabled`, `api_key_env`, + * the instance-key field, `project_key`, a project-level key, or + * a `projects..` path) + */ +export function applyKanbanProviderField( + kanban: TomlConfig, + slug: string, + key: string, + value: unknown +): void { + const meta = KANBAN_PROVIDERS[slug]; + if (!meta) { + throw new Error(`Unknown kanban provider: ${slug}`); + } + + if (!kanban[slug]) { kanban[slug] = {}; } + const providerMap = kanban[slug] as TomlConfig; + + const instanceKeys = Object.keys(providerMap); + const instanceKey = instanceKeys[0] ?? meta.defaultInstanceKey; + if (!providerMap[instanceKey]) { providerMap[instanceKey] = {}; } + const ws = providerMap[instanceKey] as TomlConfig; + + if (key === meta.instanceKeyField && typeof value === 'string' && value !== instanceKey) { + // Rename the instance key (e.g. the Jira domain / GitHub owner) + const existing = providerMap[instanceKey]; + delete providerMap[instanceKey]; + providerMap[value] = existing; + } else if (key === 'project_key') { + // Rename (or create) the first project sub-table + if (!ws.projects) { ws.projects = {}; } + const projects = ws.projects as TomlConfig; + const oldKeys = Object.keys(projects); + if (oldKeys.length > 0 && oldKeys[0]) { + const oldProject = projects[oldKeys[0]]; + delete projects[oldKeys[0]]; + projects[value as string] = oldProject; + } else { + projects[value as string] = { sync_user_id: '' }; + } + } else if (KANBAN_PROJECT_LEVEL_KEYS.includes(key)) { + // Write to the first project sub-table + if (!ws.projects) { ws.projects = {}; } + const projects = ws.projects as TomlConfig; + const projectKeys = Object.keys(projects); + const projectKey = projectKeys[0] ?? 'default'; + if (!projects[projectKey]) { projects[projectKey] = {}; } + (projects[projectKey] as TomlConfig)[key] = value; + } else if (key.startsWith('projects.')) { + // Multi-project writes: projects.{projectKey}.{field} + const parts = key.split('.'); + if (parts.length >= 3 && parts[1]) { + const pKey = parts[1]; + const field = parts.slice(2).join('.'); + if (!ws.projects) { ws.projects = {}; } + const projects = ws.projects as TomlConfig; + if (!projects[pKey]) { projects[pKey] = { sync_user_id: '' }; } + (projects[pKey] as TomlConfig)[field] = value; + } + } else { + // Instance-level scalar (enabled, api_key_env, email, …) + ws[key] = value; + } +} + /** Read config.toml and return as WebviewConfig (snake_case, no transformation) */ async function readConfig(): Promise { const configPath = getResolvedConfigPath(); @@ -489,8 +692,12 @@ async function readConfig(): Promise { } } + // The config is "present" only when the file exists with non-empty content. + // An empty or missing file means the user hasn't run setup yet. + const configExists = raw.trim().length > 0; + let parsed: TomlConfig = {}; - if (raw.trim()) { + if (configExists) { const { parse } = await importSmolToml(); parsed = parse(raw); } @@ -499,6 +706,7 @@ async function readConfig(): Promise { return { config_path: configPath || '', working_directory: workDir, + config_exists: configExists, config: parsed, }; } @@ -530,6 +738,20 @@ async function writeConfigField( parsed = parse(raw); } + // Kanban providers share one write path so every provider in + // KANBAN_PROVIDERS — current and future — is handled identically. This + // covers `kanban.jira`, `kanban.linear`, `kanban.github`, and any provider + // added to the catalog later. + if (section.startsWith('kanban.')) { + if (!parsed.kanban) { parsed.kanban = {}; } + applyKanbanProviderField( + parsed.kanban as TomlConfig, + section.slice('kanban.'.length), + key, + value + ); + } + // Apply the update based on section switch (section) { case 'primary': @@ -558,101 +780,7 @@ async function writeConfigField( (parsed.llm_tools as TomlConfig)[key] = value; break; - case 'kanban.jira': { - if (!parsed.kanban) { parsed.kanban = {}; } - const kanban = parsed.kanban as TomlConfig; - if (!kanban.jira) { kanban.jira = {}; } - const jira = kanban.jira as TomlConfig; - - // Get existing domain or create placeholder - const jiraKeys = Object.keys(jira); - const domain = jiraKeys[0] ?? 'your-org.atlassian.net'; - - if (!jira[domain]) { jira[domain] = {}; } - const ws = jira[domain] as TomlConfig; - - if (key === 'domain' && typeof value === 'string' && value !== domain) { - // Rename the domain key - const existing = jira[domain]; - delete jira[domain]; - jira[value] = existing; - } else if (key === 'project_key') { - // Handle project key under projects sub-table - if (!ws.projects) { ws.projects = {}; } - const projects = ws.projects as TomlConfig; - const oldKeys = Object.keys(projects); - if (oldKeys.length > 0 && oldKeys[0]) { - const oldProject = projects[oldKeys[0]]; - delete projects[oldKeys[0]]; - projects[value as string] = oldProject; - } else { - projects[value as string] = { sync_user_id: '' }; - } - } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { - // Write to the first project sub-table - if (!ws.projects) { ws.projects = {}; } - const projects = ws.projects as TomlConfig; - const projectKeys = Object.keys(projects); - const projectKey = projectKeys[0] ?? 'default'; - if (!projects[projectKey]) { projects[projectKey] = {}; } - (projects[projectKey] as TomlConfig)[key] = value; - } else if (key.startsWith('projects.')) { - // Multi-project writes: kanban.jira + projects.{projectKey}.{field} - const parts = key.split('.'); - if (parts.length >= 3 && parts[1]) { - const pKey = parts[1]; - const field = parts.slice(2).join('.'); - if (!ws.projects) { ws.projects = {}; } - const projects = ws.projects as TomlConfig; - if (!projects[pKey]) { projects[pKey] = { sync_user_id: '' }; } - (projects[pKey] as TomlConfig)[field] = value; - } - } else { - ws[key] = value; - } - break; - } - - case 'kanban.linear': { - if (!parsed.kanban) { parsed.kanban = {}; } - const kanban = parsed.kanban as TomlConfig; - if (!kanban.linear) { kanban.linear = {}; } - const linear = kanban.linear as TomlConfig; - - const linearKeys = Object.keys(linear); - const teamId = linearKeys[0] ?? 'default-team'; - - if (!linear[teamId]) { linear[teamId] = {}; } - const ws = linear[teamId] as TomlConfig; - - if (key === 'team_id' && typeof value === 'string' && value !== teamId) { - const existing = linear[teamId]; - delete linear[teamId]; - linear[value] = existing; - } else if (key === 'sync_statuses' || key === 'collection_name' || key === 'sync_user_id' || key === 'type_mappings') { - // Write to the first project sub-table - if (!ws.projects) { ws.projects = {}; } - const projects = ws.projects as TomlConfig; - const projectKeys = Object.keys(projects); - const projectKey = projectKeys[0] ?? 'default'; - if (!projects[projectKey]) { projects[projectKey] = {}; } - (projects[projectKey] as TomlConfig)[key] = value; - } else if (key.startsWith('projects.')) { - // Multi-project writes: kanban.linear + projects.{projectKey}.{field} - const parts = key.split('.'); - if (parts.length >= 3 && parts[1]) { - const pKey = parts[1]; - const field = parts.slice(2).join('.'); - if (!ws.projects) { ws.projects = {}; } - const projects = ws.projects as TomlConfig; - if (!projects[pKey]) { projects[pKey] = { sync_user_id: '' }; } - (projects[pKey] as TomlConfig)[field] = value; - } - } else { - ws[key] = value; - } - break; - } + // kanban.* providers are handled by applyKanbanProviderField above. case 'git': if (!parsed.git) { parsed.git = {}; } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 8852ee4b..7bb7ea07 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -834,8 +834,21 @@ async function startOperatorServerCommand(ctx: CommandContext): Promise { const apiClient = new OperatorApiClient(); try { - await apiClient.health(); - void vscode.window.showInformationMessage('Operator is already running'); + const health = await apiClient.health(); + const localVersion = await getOperatorVersion(operatorPath); + if (localVersion && health.version && health.version !== localVersion) { + // The port is held by a different operator version — adopting it could + // mix incompatible client/server APIs. Warn instead of silently starting. + void vscode.window.showWarningMessage( + `A different Operator version (v${health.version}) is already running on this port; ` + + `this binary is v${localVersion}. Stop the other instance or set a different ` + + `port (operator.apiUrl) before starting your own server.` + ); + return; + } + void vscode.window.showInformationMessage( + health.version ? `Operator is already running (v${health.version})` : 'Operator is already running' + ); return; } catch { // Not running, proceed to start @@ -911,9 +924,10 @@ async function showCreateMenu(ctx: CommandContext): Promise { } function openCreateDelegator(ctx: CommandContext, tool?: string, model?: string): void { - // Delegators are agent config — kept in the extension webview's agents section. + // Delegators bind a model provider to an llm tool, so they live in the Model + // Providers section (distinct from the Coding Agents / llm-tools section). ConfigPanel.createOrShow(ctx.extensionContext.extensionUri); - ConfigPanel.navigateTo('section-agents', { + ConfigPanel.navigateTo('section-model-providers', { action: 'createDelegator', tool, model, diff --git a/vscode-extension/src/launch-dialog.ts b/vscode-extension/src/launch-dialog.ts index d9d65b03..c26eeeba 100644 --- a/vscode-extension/src/launch-dialog.ts +++ b/vscode-extension/src/launch-dialog.ts @@ -10,8 +10,15 @@ import * as vscode from 'vscode'; import { LaunchOptions, TicketInfo, ModelOption } from './types'; import type { DelegatorResponse } from './generated/DelegatorResponse'; import type { DelegatorsResponse } from './generated/DelegatorsResponse'; +import type { ModelServerModelsResponse } from './generated/ModelServerModelsResponse'; import { discoverApiUrl } from './api-client'; +/** + * Provider kind probed for the model fallback list when no delegators exist. + * Matches the hardcoded Claude aliases this fallback replaces. + */ +const FALLBACK_MODEL_KIND = 'anthropic-api'; + interface TicketPickItem extends vscode.QuickPickItem { ticket: TicketInfo; } @@ -42,34 +49,74 @@ async function fetchDelegators( } /** - * Build delegator QuickPick items from API response. - * Includes an "Auto" default and falls back to hardcoded models when empty. + * Live text models for the no-delegator fallback, probed from a connected + * provider. Returns `null` when the daemon is unreachable or the provider isn't + * connected, so callers fall back to the hardcoded aliases. + * + * The probe already filters to LLM text models, so every id is dropdown-ready. + */ +async function fetchFallbackModels( + ticketsDir: string | undefined +): Promise { + try { + const apiUrl = await discoverApiUrl(ticketsDir); + const response = await fetch( + `${apiUrl}/api/v1/model-servers/kinds/${FALLBACK_MODEL_KIND}/models` + ); + if (!response.ok) { + return null; + } + const data = (await response.json()) as ModelServerModelsResponse; + if (!data.reachable || data.models.length === 0) { + return null; + } + return data.models.map((m) => ({ + label: m.id, + description: m.display_name ?? undefined, + delegatorName: undefined, + // Runtime-free model id; the daemon's launch route accepts any string. + model: m.id as ModelOption, + })); + } catch { + return null; + } +} + +/** Hardcoded Claude aliases — the offline / not-connected safety net. */ +function hardcodedModelItems(): DelegatorPickItem[] { + return [ + { + label: 'sonnet', + description: 'Claude Sonnet (recommended)', + delegatorName: undefined, + model: 'sonnet', + }, + { + label: 'opus', + description: 'Claude Opus (most capable)', + delegatorName: undefined, + model: 'opus', + }, + { + label: 'haiku', + description: 'Claude Haiku (fastest)', + delegatorName: undefined, + model: 'haiku', + }, + ]; +} + +/** + * Build the model/delegator pick list. When delegators exist, list them (plus an + * "Auto" default). When none exist, prefer live probed models, falling back to + * hardcoded aliases — resolved by [`resolveFallbackItems`] before this is called. */ function buildDelegatorItems( - delegators: DelegatorResponse[] + delegators: DelegatorResponse[], + fallback?: DelegatorPickItem[] | null ): DelegatorPickItem[] { if (delegators.length === 0) { - // Fallback: hardcoded Claude models - return [ - { - label: 'sonnet', - description: 'Claude Sonnet (recommended)', - delegatorName: undefined, - model: 'sonnet', - }, - { - label: 'opus', - description: 'Claude Opus (most capable)', - delegatorName: undefined, - model: 'opus', - }, - { - label: 'haiku', - description: 'Claude Haiku (fastest)', - delegatorName: undefined, - model: 'haiku', - }, - ]; + return fallback && fallback.length > 0 ? fallback : hardcodedModelItems(); } const items: DelegatorPickItem[] = [ @@ -102,9 +149,11 @@ export async function showLaunchOptionsDialog( hasExistingSession: boolean, ticketsDir?: string ): Promise { - // Fetch delegators from API + // Fetch delegators from API; when none, probe live models for the fallback. const delegators = await fetchDelegators(ticketsDir); - const delegatorItems = buildDelegatorItems(delegators); + const fallback = + delegators.length === 0 ? await fetchFallbackModels(ticketsDir) : null; + const delegatorItems = buildDelegatorItems(delegators, fallback); const delegatorChoice = await vscode.window.showQuickPick(delegatorItems, { title: `Launch ${ticket.id}: Select Delegator`, @@ -190,7 +239,9 @@ export async function showQuickDelegatorPicker( ticketsDir?: string ): Promise | undefined> { const delegators = await fetchDelegators(ticketsDir); - const items = buildDelegatorItems(delegators); + const fallback = + delegators.length === 0 ? await fetchFallbackModels(ticketsDir) : null; + const items = buildDelegatorItems(delegators, fallback); const choice = await vscode.window.showQuickPick(items, { title: 'Select Delegator', diff --git a/vscode-extension/src/sections/connections-section.ts b/vscode-extension/src/sections/connections-section.ts index 055238f3..06d591ca 100644 --- a/vscode-extension/src/sections/connections-section.ts +++ b/vscode-extension/src/sections/connections-section.ts @@ -16,6 +16,7 @@ export class ConnectionsSection implements StatusSection { private webhookStatus: WebhookStatus = { running: false }; private apiStatus: ApiStatus = { connected: false }; private operatorVersion: string | undefined; + private localDirectoryName: string | undefined; private mcpRegistered: boolean = false; private wrapperType: string = 'vscode'; private webUiAvailable: boolean = false; @@ -37,10 +38,17 @@ export class ConnectionsSection implements StatusSection { } async check(ctx: SectionContext): Promise { + // Top-level name of this workspace (parent of the .tickets dir), used to + // confirm an already-running API serves THIS project before adopting it. + this.localDirectoryName = ctx.ticketsDir + ? path.basename(path.dirname(ctx.ticketsDir)) + : undefined; + // Resolve the local binary version first so the API health check can compare + // against it (adopt only on version equality). + await this.checkOperatorVersion(ctx); await Promise.allSettled([ this.checkWebhookStatus(ctx), this.checkApiStatus(ctx), - this.checkOperatorVersion(ctx), this.checkWrapperType(ctx), ]); this.mcpRegistered = isMcpServerRegistered(); @@ -132,13 +140,57 @@ export class ConnectionsSection implements StatusSection { try { const response = await fetch(`${apiUrl}/api/v1/health`); if (response.ok) { - const health = await response.json() as { version?: string }; - const port = new URL(apiUrl).port; + const health = await response.json() as { + version?: string; + directory_name?: string; + }; + const portStr = new URL(apiUrl).port; + const port = portStr ? parseInt(portStr, 10) : 7008; + const found = health.version; + const expected = this.operatorVersion; + + // Same version is required to consider this API "ours". A different + // version stays gated with a clear warning rather than connecting + if (expected && found && found !== expected) { + this.apiStatus = { + connected: false, + port, + url: apiUrl, + mismatch: { + kind: 'version', + detail: `running v${found}, this binary is v${expected}`, + }, + }; + this.webUiAvailable = false; + return false; + } + + // Same project is required too (best-effort by directory name). Prevents + // adopting a same-version server that serves a different repo on this port. + if ( + this.localDirectoryName && + health.directory_name && + health.directory_name !== this.localDirectoryName + ) { + this.apiStatus = { + connected: false, + port, + url: apiUrl, + mismatch: { + kind: 'project', + detail: `serves '${health.directory_name}', this workspace is '${this.localDirectoryName}'`, + }, + }; + this.webUiAvailable = false; + return false; + } + this.apiStatus = { connected: true, - version: health.version || sessionVersion, - port: port ? parseInt(port, 10) : 7008, + version: found || sessionVersion, + port, url: apiUrl, + directoryName: health.directory_name, }; await this.checkWebUi(apiUrl); return true; @@ -224,27 +276,48 @@ export class ConnectionsSection implements StatusSection { } // 3. API Connection - const apiItem = this.apiStatus.connected - ? new StatusItem({ - label: 'API', - description: this.apiStatus.url || 'Connected', - icon: 'pass', - tooltip: `Operator REST API at ${this.apiStatus.url}`, - sectionId: this.sectionId, - }) - : new StatusItem({ - label: 'API', - description: configuredBoth ? 'Disconnected' : 'Not Ready', - icon: 'error', - tooltip: configuredBoth - ? 'Click to start Operator API server' - : 'Complete configuration first', - command: configuredBoth ? { - command: 'operator.startOperatorServer', - title: 'Start Operator Server', - } : undefined, - sectionId: this.sectionId, - }); + let apiItem: StatusItem; + if (this.apiStatus.connected) { + apiItem = new StatusItem({ + label: 'API', + description: this.apiStatus.url || 'Connected', + icon: 'pass', + tooltip: this.apiStatus.directoryName + ? `Operator REST API at ${this.apiStatus.url} (project '${this.apiStatus.directoryName}')` + : `Operator REST API at ${this.apiStatus.url}`, + sectionId: this.sectionId, + }); + } else if (this.apiStatus.mismatch) { + // An API answered on the port but is not adoptable as ours. + const mismatch = this.apiStatus.mismatch; + apiItem = new StatusItem({ + label: 'API', + description: mismatch.kind === 'version' ? 'Version mismatch' : 'Different project', + icon: 'warning', + tooltip: + `An Operator API on this port ${mismatch.detail}. ` + + 'Stop the other instance or configure a different port (operator.apiUrl), then start your own server.', + command: { + command: 'operator.startOperatorServer', + title: 'Start Operator Server', + }, + sectionId: this.sectionId, + }); + } else { + apiItem = new StatusItem({ + label: 'API', + description: configuredBoth ? 'Disconnected' : 'Not Ready', + icon: 'error', + tooltip: configuredBoth + ? 'Click to start Operator API server' + : 'Complete configuration first', + command: configuredBoth ? { + command: 'operator.startOperatorServer', + title: 'Start Operator Server', + } : undefined, + sectionId: this.sectionId, + }); + } // 4. Web UI const webUiItem = this.webUiAvailable diff --git a/vscode-extension/src/sections/modelserver-section.ts b/vscode-extension/src/sections/modelserver-section.ts index 270b6670..4bc425e3 100644 --- a/vscode-extension/src/sections/modelserver-section.ts +++ b/vscode-extension/src/sections/modelserver-section.ts @@ -16,6 +16,15 @@ interface ModelServerState { models: Record; } +/** + * Resolve a row's icon: the brand ThemeIcon (`operator-{brand}`) when the kind + * carries a `brand_icon` basename, else the semantic codicon fallback. Mirrors + * the PROVIDER_ICONS pattern used by the git/kanban sections. + */ +function iconForKind(brand: string | null | undefined, fallback: string): string { + return brand ? `operator-${brand}` : fallback; +} + export class ModelServerSection implements StatusSection { readonly sectionId: SectionId = 'model-servers'; readonly prerequisites: SectionId[] = ['llm']; @@ -119,10 +128,18 @@ export class ModelServerSection implements StatusSection { const probe = this.state.models[server.name]; const hasModels = !!probe && probe.reachable && probe.models.length > 0; + // Brand the server row from its kind (looked up in the shared catalog), + // falling back to the declared/builtin codicon. + const kindEntry = this.state.kinds.find((k) => k.slug === server.kind); + const serverIcon = iconForKind( + kindEntry?.brand_icon, + server.user_declared ? 'server' : 'circle-outline', + ); + items.push(new StatusItem({ label, description: descriptionParts.join(' · '), - icon: server.user_declared ? 'server' : 'circle-outline', + icon: serverIcon, tooltip: this.buildTooltip(server, probe), collapsibleState: hasModels ? vscode.TreeItemCollapsibleState.Collapsed @@ -135,12 +152,23 @@ export class ModelServerSection implements StatusSection { // Per-kind "Setup " rows from the shared kinds endpoint (non-builtin // kinds only — vendor builtins always exist). Each links to the kind's - // credential/setup page so the user can obtain what they need. + // credential/setup page so the user can obtain what they need. Rows are + // grouped under a category header (the *Model Provider* vertical) so the + // catalog reads the same way as the README/docs/web surfaces. + let lastCategory: string | undefined; for (const kind of this.state.kinds.filter((k) => !k.is_builtin)) { + if (kind.category !== lastCategory) { + items.push(new StatusItem({ + label: kind.category_label, + icon: 'list-tree', + sectionId: this.sectionId, + })); + lastCategory = kind.category; + } items.push(new StatusItem({ label: `Setup ${kind.display_name}`, description: kind.description, - icon: kind.icon, + icon: iconForKind(kind.brand_icon, kind.icon), tooltip: `${kind.description}\nSetup: ${kind.setup_url}`, command: { command: 'vscode.open', diff --git a/vscode-extension/src/sections/types.ts b/vscode-extension/src/sections/types.ts index d7d5672b..a81fdfad 100644 --- a/vscode-extension/src/sections/types.ts +++ b/vscode-extension/src/sections/types.ts @@ -58,6 +58,8 @@ export interface ApiStatus { version?: string; port?: number; url?: string; + directoryName?: string; + mismatch?: { kind: 'version' | 'project'; detail: string }; } /** Internal state for the Configuration section */ diff --git a/vscode-extension/test/suite/config-panel.test.ts b/vscode-extension/test/suite/config-panel.test.ts new file mode 100644 index 00000000..7fc09dd1 --- /dev/null +++ b/vscode-extension/test/suite/config-panel.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for config-panel.ts kanban provider handling. + * + * The canonical list of kanban providers lives in the Rust + * `KanbanProviderType::ALL` catalog and is projected into the generated + * `KanbanConfig` TypeScript type (keys: jira, linear, github, …). These tests + * guard the invariant that the webview config write path supports EVERY + * provider in that catalog — so a future provider can't be added to the schema + * without also being wired into `config-panel.ts`. + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { readFileSync } from 'fs'; +import { + KANBAN_PROVIDERS, + KANBAN_PROVIDER_SLUGS, + applyKanbanProviderField, +} from '../../src/config-panel'; + +// __dirname in compiled code is out/test/suite, so go up 3 levels to the +// extension root, then into the generated types. +const KANBAN_CONFIG_TYPE = path.join( + __dirname, + '..', + '..', + '..', + 'src', + 'generated', + 'KanbanConfig.ts' +); + +/** + * Extract the provider keys from the generated `KanbanConfig` type. Each + * provider is rendered by ts-rs as a `slug: { [key in string]?: XConfig }` + * field, so we scan for those top-level keys. + */ +function generatedProviderSlugs(): string[] { + const source = readFileSync(KANBAN_CONFIG_TYPE, 'utf-8'); + const slugs: string[] = []; + const re = /^(\w+):\s*\{\s*\[key in string\]/gm; + let match: RegExpExecArray | null; + while ((match = re.exec(source)) !== null) { + slugs.push(match[1]!); + } + return slugs; +} + +suite('Config Panel Kanban Providers', () => { + // --------------------------------------------------------------------- + // Future-provider tripwire: the canonical write table must cover every + // provider in the generated schema. Add a provider to the Rust catalog, + // regenerate types, and this fails until config-panel.ts handles it. + // --------------------------------------------------------------------- + test('every generated KanbanConfig provider has a write-path entry', () => { + const generated = generatedProviderSlugs(); + assert.ok( + generated.length >= 3, + `Expected to parse provider slugs from KanbanConfig.ts, got [${generated.join(', ')}]` + ); + + for (const slug of generated) { + assert.ok( + Object.prototype.hasOwnProperty.call(KANBAN_PROVIDERS, slug), + `Kanban provider "${slug}" exists in the generated config schema but ` + + `is missing from KANBAN_PROVIDERS in config-panel.ts. Add an entry ` + + `so the webview can read/write its config.` + ); + } + }); + + test('KANBAN_PROVIDER_SLUGS matches the generated schema exactly', () => { + const generated = generatedProviderSlugs().sort(); + const known = [...KANBAN_PROVIDER_SLUGS].sort(); + assert.deepStrictEqual(known, generated); + }); + + test('canonical catalog includes jira, linear, and github', () => { + for (const slug of ['jira', 'linear', 'github']) { + assert.ok( + KANBAN_PROVIDER_SLUGS.includes(slug), + `Expected "${slug}" in KANBAN_PROVIDER_SLUGS` + ); + } + }); + + // --------------------------------------------------------------------- + // Write-path behavior: every provider must round-trip scalar, instance-key, + // and project-level field writes into the kanban sub-table. + // --------------------------------------------------------------------- + suite('applyKanbanProviderField round-trips for every provider', () => { + for (const slug of KANBAN_PROVIDER_SLUGS) { + const meta = KANBAN_PROVIDERS[slug]!; + + test(`${slug}: scalar field writes under the default instance key`, () => { + const kanban: Record = {}; + applyKanbanProviderField(kanban, slug, 'enabled', true); + applyKanbanProviderField(kanban, slug, 'api_key_env', 'MY_TOKEN'); + + const providerMap = kanban[slug] as Record; + assert.ok(providerMap, `Expected kanban.${slug} table to exist`); + const instance = providerMap[meta.defaultInstanceKey] as Record; + assert.ok(instance, `Expected default instance "${meta.defaultInstanceKey}"`); + assert.strictEqual(instance.enabled, true); + assert.strictEqual(instance.api_key_env, 'MY_TOKEN'); + }); + + test(`${slug}: instance-key field renames the provider map key`, () => { + const kanban: Record = {}; + applyKanbanProviderField(kanban, slug, 'enabled', true); + applyKanbanProviderField(kanban, slug, meta.instanceKeyField, 'renamed-instance'); + + const providerMap = kanban[slug] as Record; + assert.ok( + Object.prototype.hasOwnProperty.call(providerMap, 'renamed-instance'), + `Expected ${slug} instance key to be renamed to "renamed-instance"` + ); + assert.ok( + !Object.prototype.hasOwnProperty.call(providerMap, meta.defaultInstanceKey), + `Expected old instance key "${meta.defaultInstanceKey}" to be gone` + ); + }); + + test(`${slug}: project-scoped field writes into a project sub-table`, () => { + const kanban: Record = {}; + applyKanbanProviderField(kanban, slug, 'projects.PROJ.sync_user_id', 'user-123'); + + const providerMap = kanban[slug] as Record; + const instance = providerMap[meta.defaultInstanceKey] as Record; + const projects = instance.projects as Record; + const proj = projects.PROJ as Record; + assert.ok(proj, `Expected project "PROJ" sub-table for ${slug}`); + assert.strictEqual(proj.sync_user_id, 'user-123'); + }); + } + }); + + test('unknown provider slug throws rather than silently dropping the write', () => { + assert.throws( + () => applyKanbanProviderField({}, 'notaprovider', 'enabled', true), + /Unknown kanban provider/ + ); + }); +}); diff --git a/vscode-extension/webview-ui/App.tsx b/vscode-extension/webview-ui/App.tsx index 79a213b2..0fe834dc 100644 --- a/vscode-extension/webview-ui/App.tsx +++ b/vscode-extension/webview-ui/App.tsx @@ -119,6 +119,10 @@ export function App() { postMessage({ type: 'openFile', filePath }); }, []); + const handleStartSetup = useCallback(() => { + postMessage({ type: 'openWalkthrough' }); + }, []); + const handleValidateJira = useCallback( (domain: string, email: string, apiToken: string) => { setValidatingJira(true); @@ -159,6 +163,7 @@ export function App() { onUpdate={handleUpdate} onBrowseFolder={handleBrowseFolder} onOpenFile={handleOpenFile} + onStartSetup={handleStartSetup} onValidateJira={handleValidateJira} onValidateLinear={handleValidateLinear} onDetectTools={handleDetectTools} @@ -191,6 +196,7 @@ function mergeWithDefaults(incoming: WebviewConfig): WebviewConfig { return { config_path: incoming.config_path || defaults.config_path, working_directory: incoming.working_directory || defaults.working_directory, + config_exists: incoming.config_exists ?? defaults.config_exists, config: deepMerge(defaults.config, incoming.config), }; } diff --git a/vscode-extension/webview-ui/components/ConfigPage.tsx b/vscode-extension/webview-ui/components/ConfigPage.tsx index 5c7bb468..c97f4fea 100644 --- a/vscode-extension/webview-ui/components/ConfigPage.tsx +++ b/vscode-extension/webview-ui/components/ConfigPage.tsx @@ -7,6 +7,7 @@ import { OperatorBrand } from './OperatorBrand'; import { LinkOutCard } from './LinkOutCard'; import { PrimaryConfigSection } from './sections/PrimaryConfigSection'; import { CodingAgentsSection } from './sections/CodingAgentsSection'; +import { ModelProvidersSection } from './sections/ModelProvidersSection'; import { KanbanProvidersSection } from './sections/KanbanProvidersSection'; import { GitRepositoriesSection } from './sections/GitRepositoriesSection'; import type { @@ -23,6 +24,7 @@ interface ConfigPageProps { onUpdate: (section: string, key: string, value: unknown) => void; onBrowseFolder: (field: string) => void; onOpenFile: (filePath: string) => void; + onStartSetup: () => void; onValidateJira: (domain: string, email: string, apiToken: string) => void; onValidateLinear: (apiKey: string) => void; onDetectTools: () => void; @@ -43,6 +45,7 @@ export function ConfigPage({ onUpdate, onBrowseFolder, onOpenFile, + onStartSetup, onValidateJira, onValidateLinear, onDetectTools, @@ -59,11 +62,15 @@ export function ConfigPage({ }: ConfigPageProps) { const scrollRef = useRef(null); const hasWorkDir = Boolean(config.working_directory); + // When no config file exists yet (or it's empty), nudge the user into the + // setup walkthrough instead of trying to open a non-existent config.toml. + const needsSetup = !config.config_exists; const navItems: NavItem[] = useMemo(() => [ { id: 'section-primary', label: 'Workspace Configuration' }, { id: 'section-kanban', label: 'Kanban Providers', disabled: !hasWorkDir }, { id: 'section-agents', label: 'Coding Agents', disabled: !hasWorkDir }, + { id: 'section-model-providers', label: 'Model Providers', disabled: !apiReachable }, { id: 'section-git', label: 'Git Version Control', disabled: !hasWorkDir }, { id: 'section-projects', label: 'Operator Managed Projects', disabled: !apiReachable }, ], [hasWorkDir, apiReachable]); @@ -85,21 +92,37 @@ export function ConfigPage({ Settings - + ) : ( + + color: '#66AA99', + '&:hover': { + borderColor: '#66AA99', + backgroundColor: 'rgba(102, 170, 153, 0.08)', + }, + }} + > + edit config.toml + + )} + t.name)} + apiReachable={apiReachable} + /> ; + +export function ModelProvidersSection({ detectedTools, apiReachable }: ModelProvidersSectionProps) { + const [kinds, setKinds] = useState([]); + const [probes, setProbes] = useState({}); + const [delegators, setDelegators] = useState([]); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const load = useCallback(() => { + if (apiReachable) {postMessage({ type: 'getModelProviders' });} + }, [apiReachable]); + + useEffect(() => { + const cleanup = onMessage((msg: ExtensionToWebviewMessage) => { + switch (msg.type) { + case 'modelProvidersLoaded': + setKinds(msg.kinds); + setDelegators(msg.delegators); + for (const k of msg.kinds) {postMessage({ type: 'probeProvider', slug: k.slug });} + break; + case 'providerProbed': + setProbes((p) => ({ ...p, [msg.slug]: msg.result })); + break; + case 'delegatorCreated': + setNotice(`Created delegator "${msg.name}".`); + load(); + break; + case 'modelProvidersError': + setError(msg.error); + break; + } + }); + return cleanup; + }, [load]); + + useEffect(load, [load]); + + const firstParty = useMemo(() => kinds.filter((k) => k.category === 'first-party'), [kinds]); + const gateways = useMemo(() => kinds.filter((k) => k.category === 'gateway'), [kinds]); + + return ( + + + + Connect model providers (distinct from the coding-agent CLIs) and create + delegators from their live models. See the{' '} + + model providers documentation + + . + + + {!apiReachable && ( + + Start the operator API to connect providers and list models. + + )} + {error && ( + setError(null)}> + {error} + + )} + {notice && ( + setNotice(null)}> + {notice} + + )} + + + + + + + + Delegators + + {delegators.length === 0 ? ( + + No delegators yet. + + ) : ( + + {delegators.map((d) => ( + + {d.display_name ?? d.name}{' '} + + {d.llm_tool}:{d.model} + {d.model_server ? ` @ ${d.model_server}` : ''} + + + ))} + + )} + + ); +} + +function connection(probe: ModelServerModelsResponse | undefined): { + color: 'success' | 'default' | 'warning'; + label: string; +} { + if (probe === undefined) {return { color: 'warning', label: 'checking…' };} + if (probe.reachable) {return { color: 'success', label: `connected · ${probe.models.length}` };} + return { color: 'default', label: 'not connected' }; +} + +function ProviderGroup({ + heading, + kinds, + probes, +}: { + heading: string; + kinds: ModelServerKindEntry[]; + probes: ProbeMap; +}) { + if (kinds.length === 0) {return null;} + return ( + + + {heading} + + + {kinds.map((k) => { + const probe = probes[k.slug]; + const conn = connection(probe); + return ( + + {k.brand_icon && BRAND_ICONS.includes(k.brand_icon) && ( + + )} + + {k.display_name} + + + {conn.label === 'not connected' && k.connectable && !k.is_builtin && ( + + )} + {conn.label === 'not connected' && k.default_api_key_env && ( + + set {k.default_api_key_env} + + )} + {!k.connectable && ( + + needs base_url + + )} + + ); + })} + + + ); +} + +function CreateDelegatorForm({ + kinds, + probes, + detectedTools, +}: { + kinds: ModelServerKindEntry[]; + probes: ProbeMap; + detectedTools: string[]; +}) { + const [tool, setTool] = useState(''); + const [provider, setProvider] = useState(''); + const [model, setModel] = useState(''); + const [name, setName] = useState(''); + + useEffect(() => { + if (!tool && detectedTools.length > 0) {setTool(detectedTools[0]);} + }, [detectedTools, tool]); + + const probe = provider ? probes[provider] : undefined; + const liveModels = probe?.reachable ? probe.models : []; + + const submit = () => { + if (!tool || !provider || !model) {return;} + postMessage({ + type: 'createDelegator', + request: { + name: name.trim() || `${tool}-${model}`, + llm_tool: tool, + model, + display_name: null, + model_properties: {}, + model_server: provider, + launch_config: null, + remote_agent: null, + }, + }); + setName(''); + setModel(''); + }; + + return ( + + + Create delegator — pair a tool with a connected provider and a live model. + + + + LLM tool + + + + + Provider + + + + {liveModels.length > 0 ? ( + + Model + + + ) : ( + setModel(e.target.value)} + /> + )} + + setName(e.target.value)} + /> + + + + + ); +} diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index 57fc0963..86e8a701 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -101,6 +101,7 @@ const DEFAULT_CONFIG: Config = { }, rest_api: { enabled: false, + host: '127.0.0.1', port: 7008, cors_origins: [], }, @@ -140,5 +141,6 @@ const DEFAULT_CONFIG: Config = { export const DEFAULT_WEBVIEW_CONFIG: WebviewConfig = { config_path: '', working_directory: '', + config_exists: false, config: DEFAULT_CONFIG, }; diff --git a/vscode-extension/webview-ui/types/messages.ts b/vscode-extension/webview-ui/types/messages.ts index 0219bd7c..80f1d05f 100644 --- a/vscode-extension/webview-ui/types/messages.ts +++ b/vscode-extension/webview-ui/types/messages.ts @@ -3,14 +3,26 @@ import type { IssueTypeSummary } from '../../src/generated/IssueTypeSummary'; import type { IssueTypeResponse } from '../../src/generated/IssueTypeResponse'; import type { CollectionResponse } from '../../src/generated/CollectionResponse'; import type { ExternalIssueTypeSummary } from '../../src/generated/ExternalIssueTypeSummary'; +import type { ModelServerKindEntry } from '../../src/generated/ModelServerKindEntry'; +import type { ModelServerModelsResponse } from '../../src/generated/ModelServerModelsResponse'; +import type { DelegatorResponse } from '../../src/generated/DelegatorResponse'; +import type { CreateDelegatorRequest } from '../../src/generated/CreateDelegatorRequest'; // Re-export generated types for consumers export type { IssueTypeSummary, IssueTypeResponse, CollectionResponse, ExternalIssueTypeSummary }; +export type { + ModelServerKindEntry, + ModelServerModelsResponse, + DelegatorResponse, + CreateDelegatorRequest, +}; /** Wrapper that pairs the generated Config with extension metadata */ export interface WebviewConfig { config_path: string; working_directory: string; + /** True only when the config file exists on disk with non-empty content. */ + config_exists: boolean; config: Config; } @@ -47,6 +59,7 @@ export type WebviewToExtensionMessage = | { type: 'detectLlmTools' } | { type: 'openExternal'; url: string } | { type: 'openFile'; filePath: string } + | { type: 'openWalkthrough' } | { type: 'checkApiHealth' } | { type: 'getProjects' } | { type: 'assessProject'; projectName: string } @@ -59,7 +72,11 @@ export type WebviewToExtensionMessage = | { type: 'createIssueType'; request: import('../../src/generated/CreateIssueTypeRequest').CreateIssueTypeRequest } | { type: 'updateIssueType'; key: string; request: import('../../src/generated/UpdateIssueTypeRequest').UpdateIssueTypeRequest } | { type: 'deleteIssueType'; key: string } - | { type: 'openOperatorUi'; route: 'issuetypes' | 'projects' }; + | { type: 'openOperatorUi'; route: 'issuetypes' | 'projects' } + | { type: 'getModelProviders' } + | { type: 'probeProvider'; slug: string } + | { type: 'connectProvider'; slug: string } + | { type: 'createDelegator'; request: CreateDelegatorRequest }; /** Messages from the extension host to the webview */ export type ExtensionToWebviewMessage = @@ -85,7 +102,11 @@ export type ExtensionToWebviewMessage = | { type: 'externalIssueTypesError'; provider: string; projectKey: string; error: string } | { type: 'issueTypeCreated'; issueType: IssueTypeResponse } | { type: 'issueTypeUpdated'; issueType: IssueTypeResponse } - | { type: 'issueTypeDeleted'; key: string }; + | { type: 'issueTypeDeleted'; key: string } + | { type: 'modelProvidersLoaded'; kinds: ModelServerKindEntry[]; delegators: DelegatorResponse[] } + | { type: 'providerProbed'; slug: string; result: ModelServerModelsResponse } + | { type: 'delegatorCreated'; name: string } + | { type: 'modelProvidersError'; error: string }; export interface JiraValidationInfo { valid: boolean;