diff --git a/.claude/commands/build-backstage-server.md b/.claude/commands/build-backstage-server.md deleted file mode 100644 index 98f5a0d7..00000000 --- a/.claude/commands/build-backstage-server.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Build, lint, test, and run the backstage-server locally -allowed-tools: Bash, Read -model: sonnet ---- - -# Build Backstage Server - -Build, validate, and run the `backstage-server` subproject locally for inspection. Stop immediately on any failure and report the error. - -## Workflow - -Run each step sequentially from the `backstage-server/` directory. If any step fails, stop and report the failure clearly. - -1. **Install dependencies**: `cd backstage-server && bun install` -2. **Lint**: `cd backstage-server && bun run lint` -3. **Typecheck**: `cd backstage-server && bun run typecheck` -4. **Test**: `cd backstage-server && bun test` -5. **Build**: `cd backstage-server && bun run build` (builds frontend, embeds assets, produces standalone binary at `dist/backstage-server`) -6. **Verify binary**: `ls -lh backstage-server/dist/backstage-server` (confirm binary exists and report its size) -7. **Run dev server**: `cd backstage-server && bun run dev` (run in background with hot-reload on port 7007) -8. **Report**: Confirm the server is running at http://localhost:7007. Note that it proxies to the Operator API at :7008. - -## Notes - -- If port 7007 is already in use, report the conflict and suggest killing the existing process. -- To stop the dev server later, kill the background Bun process. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..cb062bff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# Build context only needs the prebuilt binaries staged by CI. +* +!operator-linux-amd64 +!operator-linux-arm64 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9072ad3e..910075a8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,11 @@ updates: groups: rust-deps: patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: cargo directory: "/opr8r" @@ -15,6 +20,24 @@ updates: groups: opr8r-deps: patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 + + - package-ecosystem: cargo + directory: "/zed-extension" + schedule: + interval: weekly + groups: + zed-deps: + patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: npm directory: "/vscode-extension" @@ -23,11 +46,21 @@ updates: groups: vscode-deps: patterns: ["*"] + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: bundler directory: "/docs" schedule: interval: weekly + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 - package-ecosystem: github-actions directory: "/" @@ -36,3 +69,10 @@ updates: groups: actions: patterns: ["*"] + ignore: + - dependency-name: "actions/*" + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 14 + semver-patch-days: 3 diff --git a/.github/workflows/backstage.yaml b/.github/workflows/backstage.yaml deleted file mode 100644 index 11c2d5c7..00000000 --- a/.github/workflows/backstage.yaml +++ /dev/null @@ -1,155 +0,0 @@ -name: Backstage CI - -on: - pull_request: - paths: - - 'backstage-server/**' - - '.github/workflows/backstage.yaml' - workflow_dispatch: - -concurrency: - group: backstage-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' - -jobs: - app-ci: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Lint app - run: bun run lint:app - - name: Test app - run: bun run test:app - - backend-ci: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Lint backend - run: bun run lint:backend - - name: Test backend - run: bun run test:backend - - plugins-ci: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Lint plugins - run: bun run lint:plugins - - name: Test plugins - run: bun run test:plugins - - typecheck: - runs-on: ubuntu-latest - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Generate embedded assets - run: bun run build:embeds - - name: TypeScript type check - run: bun run typecheck - - name: Knip (unused exports/imports) - run: bun run knip - - coverage: - runs-on: ubuntu-latest - needs: [app-ci, backend-ci, plugins-ci] - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Run tests with coverage - run: bun test packages --coverage --coverage-reporter=lcov - - name: Upload to Codecov - uses: codecov/codecov-action@v5 - with: - files: backstage-server/coverage/lcov.info - flags: typescript - fail_ci_if_error: false - - build: - runs-on: ubuntu-latest - needs: [coverage, typecheck] - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - run: bun run build - - uses: actions/upload-artifact@v4 - with: - name: backstage-server - path: backstage-server/dist/backstage-server - retention-days: 7 - - e2e: - runs-on: ubuntu-latest - needs: [build] - defaults: - run: - working-directory: backstage-server - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - name: Download backstage-server binary - uses: actions/download-artifact@v4 - with: - name: backstage-server - path: backstage-server/dist - - name: Make binary executable - run: chmod +x dist/backstage-server - - name: Install Playwright browsers - run: bunx playwright install --with-deps chromium - - name: Run E2E tests - run: bun run test:e2e - env: - USE_BINARY: 'true' - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: backstage-server/playwright-report/ - retention-days: 7 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4b2013ce..ba225bef 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -14,7 +14,6 @@ on: - 'Cargo.lock' - 'docs/**' - '.github/workflows/docs.yml' - - 'backstage-server/package.json' - 'vscode-extension/package.json' - 'vscode-extension/src/webhook-server.ts' pull_request: @@ -36,10 +35,10 @@ jobs: lint-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 with: components: rustfmt, clippy @@ -53,14 +52,31 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Build UI dist + run: | + cd ui + bun install --frozen-lockfile + bun run build + DIST_SIZE=$(du -sb dist/ | cut -f1) + echo "UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB uncompressed)" + if [ "$DIST_SIZE" -gt 5242880 ]; then + echo "::error::UI dist exceeds 5MB uncompressed budget (${DIST_SIZE}B)" + exit 1 + fi + - name: Check formatting run: cargo fmt -- --check - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --locked --all-targets --all-features -- -D warnings - name: Tests - run: cargo test --all-features + run: cargo test --locked --all-features - name: cargo-deny uses: EmbarkStudios/cargo-deny-action@v2 @@ -69,15 +85,34 @@ jobs: arguments: --all-features command: check + # Compute the next version once, before anything is built, so the compiled + # binaries embed the same version that the release tag + Docker tag will use. + # The actual file edits + commit + tag still happen in the release job. + version: + needs: lint-test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + version: ${{ steps.calc.outputs.version }} + steps: + - uses: actions/checkout@v6 + - id: calc + run: | + V=$(cat VERSION) + IFS='.' read -r major minor patch <<< "$V" + NEW_VERSION="$major.$minor.$((patch + 1))" + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "Next version: $V -> $NEW_VERSION" + coverage: needs: lint-test if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 with: components: llvm-tools-preview @@ -92,7 +127,7 @@ jobs: restore-keys: ${{ runner.os }}-cargo-coverage- - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@v2 - name: Configure Git for tests run: | @@ -113,7 +148,7 @@ jobs: # Git integration test flags OPERATOR_GIT_TEST_ENABLED: 'true' OPERATOR_GIT_PUSH_ENABLED: 'true' - run: cargo llvm-cov --all-features --codecov --output-path codecov.json -- --test-threads=1 + run: cargo llvm-cov --locked --all-features --codecov --output-path codecov.json -- --test-threads=1 - name: Cleanup optest branches if: always() @@ -135,10 +170,10 @@ jobs: needs: lint-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux @@ -154,12 +189,12 @@ jobs: restore-keys: ${{ runner.os }}-cargo-launch- - name: Build operator - run: cargo build + run: cargo build --locked - name: Run launch integration tests env: OPERATOR_LAUNCH_TEST_ENABLED: 'true' - run: cargo test --test launch_integration -- --test-threads=1 --nocapture + run: cargo test --locked --test launch_integration -- --test-threads=1 --nocapture - name: Cleanup sessions if: always() @@ -171,10 +206,10 @@ jobs: needs: lint-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -187,15 +222,15 @@ jobs: restore-keys: ${{ runner.os }}-cargo-relay- - name: Build opr8r (provides relay subcommand) - run: cargo build --manifest-path opr8r/Cargo.toml + run: cargo build --locked --manifest-path opr8r/Cargo.toml - name: Run relay integration tests env: OPERATOR_RELAY_INTEGRATION_TEST_ENABLED: 'true' - run: cargo test --test relay_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test relay_integration -- --nocapture --test-threads=1 build: - needs: lint-test + needs: [lint-test, version] if: github.event_name == 'push' && github.ref == 'refs/heads/main' strategy: fail-fast: false @@ -203,26 +238,22 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - bun_target: bun-linux-x64 artifact_name: operator-linux-x86_64 - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - bun_target: bun-linux-arm64 artifact_name: operator-linux-arm64 - os: macos-14 target: aarch64-apple-darwin - bun_target: bun-darwin-arm64 artifact_name: operator-macos-arm64 - os: windows-latest target: x86_64-pc-windows-msvc - bun_target: bun-windows-x64 artifact_name: operator-windows-x86_64.exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 with: targets: ${{ matrix.target }} @@ -236,49 +267,33 @@ jobs: key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo- - # Check if backstage-server exists (Milestone 6) - - name: Check for backstage-server - id: backstage - shell: bash - run: | - if [ -d "backstage-server" ] && [ -f "backstage-server/package.json" ]; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - - # Install Bun (only if backstage-server exists) + # Install Bun (for embedded UI build) - name: Install Bun - if: steps.backstage.outputs.exists == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: latest + # Pinned to match .tool-versions + bun-version: 1.3.14 - # Install backstage-server dependencies - - name: Install backstage dependencies - if: steps.backstage.outputs.exists == 'true' + # Build embedded web UI for operator binary + - name: Build UI for embedding run: | - cd backstage-server - bun install - - # Build release first to create target/ directory - - name: Build release - run: cargo build --release --target ${{ matrix.target }} + cd ui + bun install --frozen-lockfile + bun run build - # Build backstage frontend and compile binary (after cargo build creates target/) - # This builds the React frontend, generates embedded assets, then compiles - - name: Build backstage binary - if: steps.backstage.outputs.exists == 'true' + # Embed the release version so the binary's --version matches its tag. + # Portable across the linux/macos/windows matrix (sed-to-tempfile, no -i). + - name: Set release version shell: bash run: | - cd backstage-server - # Build frontend, generate embeds, compile binary - bun run build - # Ensure target directory exists (belt-and-suspenders) - mkdir -p ../target - # Move binary to target directory with platform suffix - mv dist/backstage-server ../target/backstage-server-${{ matrix.bun_target }} - ls -la ../target/backstage-server-* + V="${{ needs.version.outputs.version }}" + echo "$V" > VERSION + sed 's/^version = ".*"/version = "'"$V"'"/' Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml + cargo update --workspace + + # Build release with embedded UI + - name: Build release + run: cargo build --locked --release --features embed-ui --target ${{ matrix.target }} - name: Rename binary shell: bash @@ -305,39 +320,14 @@ jobs: APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} run: scripts/notarize.sh "${{ matrix.artifact_name }}" - # PAUSED: codesign/notarize of macOS backstage-server binary is temporarily disabled. - # Re-enable by uncommenting the two steps below. - # - name: Codesign backstage-server binary - # if: matrix.os == 'macos-14' && steps.backstage.outputs.exists == 'true' - # env: - # APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # run: scripts/codesign.sh "target/backstage-server-${{ matrix.bun_target }}" - # - # - name: Notarize backstage-server binary - # if: matrix.os == 'macos-14' && steps.backstage.outputs.exists == 'true' - # env: - # APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} - # APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} - # APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} - # run: scripts/notarize.sh "target/backstage-server-${{ matrix.bun_target }}" - - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} - # Upload backstage binary artifact - - name: Upload backstage artifact - if: steps.backstage.outputs.exists == 'true' - uses: actions/upload-artifact@v4 - with: - name: backstage-server-${{ matrix.bun_target }} - path: target/backstage-server-${{ matrix.bun_target }} - build-opr8r: - needs: lint-test + needs: [lint-test, version] if: github.event_name == 'push' && github.ref == 'refs/heads/main' strategy: fail-fast: false @@ -360,10 +350,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry uses: actions/cache@v4 @@ -376,8 +366,17 @@ jobs: restore-keys: | opr8r-${{ matrix.artifact_name }}-cargo- + # Embed the release version (working-directory is opr8r/, so this edits + # opr8r/Cargo.toml). Portable across the build matrix. + - name: Set release version + shell: bash + run: | + V="${{ needs.version.outputs.version }}" + sed 's/^version = ".*"/version = "'"$V"'"/' Cargo.toml > Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml + cargo update --workspace + - name: Build release binary - run: cargo build --release + run: cargo build --locked --release - name: Strip binary (Linux/macOS) if: runner.os != 'Windows' @@ -435,10 +434,12 @@ jobs: AZ_PIPELINE_DEFINITION_ID: ${{ secrets.AZ_PIPELINE_DEFINITION_ID }} release: - needs: [build, build-opr8r, sign-windows] + needs: [version, build, build-opr8r, sign-windows] runs-on: ubuntu-latest + outputs: + version: ${{ needs.version.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -447,65 +448,56 @@ jobs: with: path: artifacts - - name: Bump version - id: version - run: | - VERSION=$(cat VERSION) - IFS='.' read -r major minor patch <<< "$VERSION" - NEW_VERSION="$major.$minor.$((patch + 1))" - echo "$NEW_VERSION" > VERSION - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Bumped version: $VERSION -> $NEW_VERSION" + # Version was computed in the `version` job and already baked into the + # built binaries; persist it into the tracked manifests for the commit/tag. + - name: Write VERSION file + run: echo "${{ needs.version.outputs.version }}" > VERSION - name: Update Cargo.toml version run: | - sed -i 's/^version = ".*"/version = "${{ steps.version.outputs.version }}"/' Cargo.toml + sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' Cargo.toml - name: Update Cargo.lock run: cargo update --workspace - name: Update docs/_config.yml version run: | - sed -i 's/^version: .*/version: ${{ steps.version.outputs.version }}/' docs/_config.yml + sed -i 's/^version: .*/version: ${{ needs.version.outputs.version }}/' docs/_config.yml - name: Update package.json versions run: | - jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' backstage-server/package.json > tmp.json && mv tmp.json backstage-server/package.json - jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' vscode-extension/package.json > tmp.json && mv tmp.json vscode-extension/package.json + jq --arg v "${{ needs.version.outputs.version }}" '.version = $v' vscode-extension/package.json > tmp.json && mv tmp.json vscode-extension/package.json - name: Update opr8r Cargo.toml version run: | - sed -i 's/^version = ".*"/version = "${{ steps.version.outputs.version }}"/' opr8r/Cargo.toml + sed -i 's/^version = ".*"/version = "${{ needs.version.outputs.version }}"/' opr8r/Cargo.toml cd opr8r && cargo update --workspace - name: Update TypeScript VERSION constant run: | - sed -i "s/const VERSION = '[^']*'/const VERSION = '${{ steps.version.outputs.version }}'/" vscode-extension/src/webhook-server.ts + sed -i "s/const VERSION = '[^']*'/const VERSION = '${{ needs.version.outputs.version }}'/" vscode-extension/src/webhook-server.ts - name: Commit version bump run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add VERSION Cargo.toml Cargo.lock docs/_config.yml \ - backstage-server/package.json \ vscode-extension/package.json \ vscode-extension/src/webhook-server.ts \ opr8r/Cargo.toml opr8r/Cargo.lock - git commit -m "chore: bump version to v${{ steps.version.outputs.version }} [skip ci]" + git commit -m "chore: bump version to v${{ needs.version.outputs.version }} [skip ci]" git push - name: Create tag run: | - git tag "v${{ steps.version.outputs.version }}" - git push origin "v${{ steps.version.outputs.version }}" + git tag "v${{ needs.version.outputs.version }}" + git push origin "v${{ needs.version.outputs.version }}" - name: Prepare release assets run: | mkdir -p release-assets # Copy operator binaries find artifacts -type f -name 'operator-*' -exec cp {} release-assets/ \; - # Copy backstage-server binaries (if built) - find artifacts -type f -name 'backstage-server-*' -exec cp {} release-assets/ \; 2>/dev/null || true # Copy opr8r binaries find artifacts -type f -name 'opr8r-*' -exec cp {} release-assets/ \; chmod +x release-assets/* @@ -515,8 +507,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release create "v${{ steps.version.outputs.version }}" \ - --title "v${{ steps.version.outputs.version }}" \ + gh release create "v${{ needs.version.outputs.version }}" \ + --title "v${{ needs.version.outputs.version }}" \ --generate-notes \ release-assets/* @@ -526,10 +518,6 @@ jobs: OP_LINUX_ARM=$(sha256sum release-assets/operator-linux-arm64 | cut -d' ' -f1) OP_LINUX_X86=$(sha256sum release-assets/operator-linux-x86_64 | cut -d' ' -f1) OP_WIN=$(sha256sum release-assets/operator-windows-x86_64.exe | cut -d' ' -f1) - BS_DARWIN=$(sha256sum release-assets/backstage-server-bun-darwin-arm64 | cut -d' ' -f1) - BS_LINUX_ARM=$(sha256sum release-assets/backstage-server-bun-linux-arm64 | cut -d' ' -f1) - BS_LINUX_X64=$(sha256sum release-assets/backstage-server-bun-linux-x64 | cut -d' ' -f1) - BS_WIN=$(sha256sum release-assets/backstage-server-bun-windows-x64 | cut -d' ' -f1) cat > docs/_data/checksums.yml << EOF # SHA256 checksums - AUTO-GENERATED BY CI @@ -540,20 +528,53 @@ jobs: linux_arm64: "${OP_LINUX_ARM}" linux_x86_64: "${OP_LINUX_X86}" windows_x86_64: "${OP_WIN}" - - backstage: - darwin_arm64: "${BS_DARWIN}" - linux_arm64: "${BS_LINUX_ARM}" - linux_x64: "${BS_LINUX_X64}" - windows_x64: "${BS_WIN}" EOF - name: Commit checksums run: | git add docs/_data/checksums.yml - git commit -m "chore: update checksums for v${{ steps.version.outputs.version }} [skip ci]" || true + git commit -m "chore: update checksums for v${{ needs.version.outputs.version }} [skip ci]" || true git push + docker: + needs: [build, release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Download linux binaries + uses: actions/download-artifact@v4 + with: + pattern: operator-linux-* + path: bins + merge-multiple: true + + # buildx exposes TARGETARCH as amd64/arm64; map the x86_64 artifact name. + - name: Stage binaries for build context + run: | + cp bins/operator-linux-x86_64 ./operator-linux-amd64 + cp bins/operator-linux-arm64 ./operator-linux-arm64 + chmod +x operator-linux-amd64 operator-linux-arm64 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + untra/operator:${{ needs.release.outputs.version }} + untra/operator:latest + deploy-docs: needs: release runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 282567c6..d69866b7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,7 +7,7 @@ on: paths: - 'docs/**' - 'src/docs_gen/**' - - 'src/backstage/taxonomy.toml' + - 'src/taxonomy/taxonomy.toml' - 'src/templates/*.json' - '.github/workflows/docs.yml' workflow_dispatch: @@ -23,17 +23,18 @@ concurrency: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + BUNDLE_FROZEN: 'true' jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Install Rust toolchain for docs generation - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 # Cache cargo for faster builds - name: Cache cargo @@ -48,7 +49,7 @@ jobs: # Generate reference documentation from source-of-truth files - name: Generate reference docs - run: cargo run -- docs + run: cargo run --locked -- docs - name: Setup Ruby uses: ruby/setup-ruby@v1 @@ -57,6 +58,10 @@ jobs: bundler-cache: true working-directory: docs + - name: Audit gems + run: gem install bundler-audit && bundle-audit check --update + working-directory: docs + - name: Build Jekyll site run: bundle exec jekyll build working-directory: docs diff --git a/.github/workflows/integration-tests-matrix.yml b/.github/workflows/integration-tests-matrix.yml index 1e8de8d8..f5caf091 100644 --- a/.github/workflows/integration-tests-matrix.yml +++ b/.github/workflows/integration-tests-matrix.yml @@ -74,10 +74,10 @@ jobs: extension: .exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -90,7 +90,7 @@ jobs: restore-keys: ${{ matrix.artifact }}-cargo- - name: Build release - run: cargo build --release + run: cargo build --locked --release - name: Upload artifact uses: actions/upload-artifact@v4 @@ -119,10 +119,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -135,7 +135,7 @@ jobs: restore-keys: opr8r-${{ matrix.artifact }}-cargo- - name: Build release - run: cargo build --release + run: cargo build --locked --release - name: Upload artifact uses: actions/upload-artifact@v4 @@ -167,7 +167,7 @@ jobs: opr8r_artifact: opr8r-macos-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install tmux (Linux) if: runner.os == 'Linux' @@ -178,7 +178,7 @@ jobs: run: brew install tmux - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -208,7 +208,7 @@ jobs: - name: Run launch integration tests env: OPERATOR_LAUNCH_TEST_ENABLED: 'true' - run: cargo test --test launch_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test launch_integration -- --nocapture --test-threads=1 - name: Cleanup tmux sessions if: always() @@ -234,10 +234,10 @@ jobs: opr8r_artifact: opr8r-linux-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -303,10 +303,10 @@ jobs: opr8r_artifact: opr8r-macos-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -336,7 +336,7 @@ jobs: - name: Run cmux integration tests (mock-based) env: OPERATOR_CMUX_TEST_ENABLED: 'true' - run: cargo test --test launch_integration_cmux -- --nocapture --test-threads=1 + run: cargo test --locked --test launch_integration_cmux -- --nocapture --test-threads=1 # ============================================================================ # VSCODE WRAPPER LAUNCH TESTS (Rust library-level, all platforms) @@ -356,10 +356,10 @@ jobs: artifact: operator-macos-arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -383,7 +383,7 @@ jobs: - name: Run VS Code wrapper launch tests env: OPERATOR_VSCODE_TEST_ENABLED: 'true' - run: cargo test --test launch_integration_vscode -- --nocapture --test-threads=1 + run: cargo test --locked --test launch_integration_vscode -- --nocapture --test-threads=1 # ============================================================================ # REST API INTEGRATION TESTS (All platforms) @@ -408,10 +408,10 @@ jobs: extension: .exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -436,7 +436,7 @@ jobs: - name: Run REST API integration tests env: OPERATOR_REST_API_TEST_ENABLED: 'true' - run: cargo test --test rest_api_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test rest_api_integration -- --nocapture --test-threads=1 # ============================================================================ # OPR8R CLI INTEGRATION TESTS (All platforms) @@ -465,7 +465,7 @@ jobs: extension: .exe runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download operator binary uses: actions/download-artifact@v4 @@ -530,7 +530,7 @@ jobs: run: working-directory: vscode-extension steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 46cb518d..34181863 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # Full history needed for git operations @@ -61,7 +61,7 @@ jobs: git remote set-head origin --auto || true - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo uses: actions/cache@v4 @@ -82,7 +82,7 @@ jobs: OPERATOR_JIRA_EMAIL: ${{ secrets.OPERATOR_JIRA_EMAIL }} OPERATOR_JIRA_API_KEY: ${{ secrets.OPERATOR_JIRA_API_KEY }} OPERATOR_JIRA_TEST_PROJECT: ${{ secrets.OPERATOR_JIRA_TEST_PROJECT }} - run: cargo test --test kanban_integration jira_tests -- --nocapture --test-threads=1 + run: cargo test --locked --test kanban_integration jira_tests -- --nocapture --test-threads=1 - name: Run Linear integration tests if: >- @@ -91,7 +91,7 @@ jobs: env: OPERATOR_LINEAR_API_KEY: ${{ secrets.OPERATOR_LINEAR_API_KEY }} OPERATOR_LINEAR_TEST_TEAM: ${{ secrets.OPERATOR_LINEAR_TEST_TEAM }} - run: cargo test --test kanban_integration linear_tests -- --nocapture --test-threads=1 + run: cargo test --locked --test kanban_integration linear_tests -- --nocapture --test-threads=1 - name: Run GitHub Projects integration tests if: >- @@ -100,7 +100,7 @@ jobs: env: OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} - run: cargo test --test kanban_integration github_tests -- --nocapture --test-threads=1 + run: cargo test --locked --test kanban_integration github_tests -- --nocapture --test-threads=1 - name: Run cross-provider tests env: @@ -112,14 +112,14 @@ jobs: OPERATOR_LINEAR_TEST_TEAM: ${{ secrets.OPERATOR_LINEAR_TEST_TEAM }} OPERATOR_GITHUB_TOKEN: ${{ secrets.OPERATOR_GITHUB_TOKEN }} OPERATOR_GITHUB_TEST_PROJECT: ${{ secrets.OPERATOR_GITHUB_TEST_PROJECT }} - run: cargo test --test kanban_integration test_provider_interface_consistency -- --nocapture + run: cargo test --locked --test kanban_integration test_provider_interface_consistency -- --nocapture # Git Integration Tests - name: Run Git integration tests (read-only) if: github.event_name != 'workflow_dispatch' || inputs.run_git env: OPERATOR_GIT_TEST_ENABLED: 'true' - run: cargo test --test git_integration -- --nocapture --test-threads=1 + run: cargo test --locked --test git_integration -- --nocapture --test-threads=1 # NOTE: Git push tests disabled in CI - require authenticated push access # Run locally with OPERATOR_GIT_PUSH_ENABLED=true to test push operations diff --git a/.github/workflows/opr8r.yaml b/.github/workflows/opr8r.yaml index 65bd712a..641b19f5 100644 --- a/.github/workflows/opr8r.yaml +++ b/.github/workflows/opr8r.yaml @@ -28,10 +28,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 with: components: rustfmt, clippy @@ -50,10 +50,10 @@ jobs: run: cargo fmt -- --check - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --locked --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test --all-features + run: cargo test --locked --all-features - name: cargo-deny uses: EmbarkStudios/cargo-deny-action@v2 @@ -71,10 +71,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 with: components: llvm-tools-preview @@ -90,10 +90,10 @@ jobs: opr8r-${{ runner.os }}-cargo-coverage- - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + uses: taiki-e/install-action@v2 - name: Generate coverage - run: cargo llvm-cov --all-features --codecov --output-path codecov.json + run: cargo llvm-cov --locked --all-features --codecov --output-path codecov.json - name: Upload to Codecov uses: codecov/codecov-action@v5 @@ -128,10 +128,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry uses: actions/cache@v4 @@ -145,7 +145,7 @@ jobs: opr8r-${{ matrix.artifact }}-cargo- - name: Build release binary - run: cargo build --release + run: cargo build --locked --release - name: Strip binary (Linux/macOS) if: runner.os != 'Windows' diff --git a/.github/workflows/vscode-extension.yaml b/.github/workflows/vscode-extension.yaml index 4acb3562..87fb026c 100644 --- a/.github/workflows/vscode-extension.yaml +++ b/.github/workflows/vscode-extension.yaml @@ -30,7 +30,7 @@ jobs: lint-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 @@ -96,10 +96,10 @@ jobs: run: working-directory: opr8r steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 - name: Cache cargo registry uses: actions/cache@v4 @@ -113,7 +113,7 @@ jobs: opr8r-${{ matrix.artifact }}-cargo- - name: Build release binary - run: cargo build --release + run: cargo build --locked --release - name: Strip binary (Linux/macOS) if: runner.os != 'Windows' @@ -162,7 +162,7 @@ jobs: opr8r_extension: .exe runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 @@ -209,7 +209,7 @@ jobs: run: working-directory: . # Override global default - publish job runs from repo root steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/zed-extension.yaml b/.github/workflows/zed-extension.yaml index 16fdc5ab..fbbb126e 100644 --- a/.github/workflows/zed-extension.yaml +++ b/.github/workflows/zed-extension.yaml @@ -24,10 +24,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.95 with: targets: wasm32-wasip1 components: clippy, rustfmt @@ -41,10 +41,17 @@ jobs: run: cargo fmt -- --check - name: Lint - run: cargo clippy --target wasm32-wasip1 -- -D warnings + run: cargo clippy --locked --target wasm32-wasip1 -- -D warnings - name: Build - run: cargo build --release --target wasm32-wasip1 + run: cargo build --locked --release --target wasm32-wasip1 + + - name: cargo-deny + uses: EmbarkStudios/cargo-deny-action@v2 + with: + manifest-path: zed-extension/Cargo.toml + arguments: --all-features + command: check - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 834975f4..8300859d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,12 @@ docs/_site/ # built vscode extension *.vsix +# built zed extension +*.wasm + # vscode-extension generated types (copied from bindings/) vscode-extension/src/generated/ vscode-extension/out/ vscode-extension/.vscode-test/ -test-output.txt \ No newline at end of file +test-output.txt +.tickets/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..e5f422f4 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=604800000 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..21bece3a --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +rust 1.95 +bun 1.3.14 diff --git a/AI_POLICY.md b/AI_POLICY.md new file mode 100644 index 00000000..a0d61f63 --- /dev/null +++ b/AI_POLICY.md @@ -0,0 +1,26 @@ +# AI Policy + +Using AI (i.e., LLMs) as tools for coding is welcome. This project itself encourages AI assisted software development. +Still, A high bar is held for software development process used by humans and AI to develop all contributions to this project. +Moreover, the project maintainers remain responsible for any code that is published as part of a release. +Code Contributors are expected to be responsible for what they publish. + +**AI should not be used to generate comments when communicating with maintainers**. +Comments are expected to be written by humans. Comments that are believed to be written by AI may be hidden without notice. + +If you are opening an issue, you should be able to describe the problem in your own words. + +If you are opening a pull request, you are expected to be able to explain the proposed changes in your own words. +This includes the pull request body and responses to questions. +**Do not copy responses from the AI when replying to questions from maintainers.** + +This project requires a human in the loop who understands the work produced by AI. +The endeavor of this project is to give humans tools to direct AI to accomplish higher quality consistent work. +**Contributions made by autonomous agents may be closed, perhaps without notice, and will be prioritized appropriately.** + +If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. +It must be accompanied by human commentary explaining the relevance and implications of the context. Do not share long snippets of AI output. + +AI is useful when communicating as a non-native English speaker. +If you are using AI to edit your comments for this purpose, please take the time to ensure it reflects your own voice and ideas. +If using AI for translation, we recommend writing in your native language and including the AI translation in a separate quote block. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c1adbf5a..c31122d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,6 @@ cargo run cd vscode-extension && npm run lint && npm run compile ``` -**backstage-server** (TypeScript/Bun): -```bash -cd backstage-server && bun run lint && bun run typecheck && bun test -``` - ### Test-Driven Development (TDD) This project follows TDD practices: @@ -206,7 +201,7 @@ Operator uses a schema-driven, code-derived documentation strategy to reduce mai | File | Generates | Purpose | |------|-----------|---------| -| `src/backstage/taxonomy.toml` | `docs/backstage/taxonomy.md` | 25 project Kinds across 5 tiers | +| `src/taxonomy/taxonomy.toml` | `docs/taxonomy/index.md` | 25 project Kinds across 5 tiers | | `src/schemas/issuetype_schema.json` | `docs/schemas/issuetype.md` | Issue type structure (key, mode, fields, steps) | | `src/schemas/ticket_metadata.schema.json` | `docs/schemas/metadata.md` | Ticket YAML frontmatter format | | `src/ui/keybindings.rs` | `docs/shortcuts/index.md` | Keyboard shortcuts by context | @@ -243,3 +238,25 @@ All generated files include a header warning: 2. Implement `name()`, `source()`, `output_path()`, and `generate()` 3. Register in `src/docs_gen/mod.rs` `generate_all()` function 4. Add to CLI match in `src/main.rs` `cmd_docs()` + +## Design & UI Consistency + +Operator presents one brand (terracotta + cornflower + cream over a green +scale) across **four rendering surfaces**. Keep them consistent by following the +rule that fits each surface — they are deliberately *not* all styled the same +way. Full details and swatches live in `docs/design-system/` (`/design-system/`). + +**Brand source of truth:** `docs/assets/css/tokens.css` — the only place the +brand hex values + dark-mode overrides are declared. Both web surfaces consume +it; never re-declare a brand color elsewhere. + +| Surface | Where | Rule | +|---------|-------|------| +| Docs site (Jekyll) | `docs/assets/css/main.css` | Links `tokens.css` (via `_includes/head.html`); style components with `var(--...)`, never raw hex. | +| Embedded SPA (Vite/React) | `ui/src/index.css` + `*.module.css` | Imports `tokens.css`; layers app-only semantic tokens (`--surface`, `--border`, `--danger`, …) on top. Components reference semantic tokens, not raw hex. | +| Ratatui TUI | `src/ui/*.rs` | Terminal can't render hex — match a **semantic role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). Reuse `color_for_key`/`glyph_for_key` from `src/templates/mod.rs`; don't re-hardcode issuetype/priority colors. | +| VS Code webview (MUI) | `vscode-extension/webview-ui/` | **Defer to the VS Code host theme** (`computeStyles.ts` → `createVSCodeTheme.ts`). Apply brand only as accents via `OPERATOR_BRAND`; never override the user's editor theme wholesale. | + +When adding or changing UI: change a brand color in `tokens.css` (web surfaces +follow automatically); reference semantic tokens in new web CSS; map a role to +ANSI in the TUI; and leave the webview deferring to the editor theme. diff --git a/Cargo.lock b/Cargo.lock index 774a55c9..0d734248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,57 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4361ba6627e51de955b10f3c77fb9eb959c85191a236c1c2c84e32f4ff240faf" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-process", + "blocking", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabdc9d845d08ec7ed2d0c9de1ae4a1b198301407d55855261572761be90ec9f" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b957d8391ac3933e2a940446171c508d2b8ffc386d8fa7d0b9c936a2575b463e" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" @@ -46,9 +97,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -61,15 +112,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -96,9 +147,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -141,9 +192,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -166,7 +217,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -197,7 +248,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -213,9 +264,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -223,7 +274,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -254,9 +305,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -356,9 +407,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -394,11 +445,20 @@ dependencies = [ "piper", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -423,9 +483,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.50" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -445,9 +505,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -459,9 +519,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -469,9 +529,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -481,9 +541,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -493,15 +553,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" @@ -533,7 +593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" dependencies = [ "async-trait", - "convert_case", + "convert_case 0.6.0", "json5", "nom", "pathdiff", @@ -560,7 +620,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -574,6 +634,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -584,6 +653,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -648,7 +727,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "crossterm_winapi", "mio", "parking_lot", @@ -689,8 +768,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -707,24 +796,49 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -753,7 +867,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -769,6 +883,29 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -802,11 +939,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "objc2", ] @@ -838,9 +975,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -923,27 +1060,31 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" @@ -1000,27 +1141,66 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1037,9 +1217,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1048,22 +1228,23 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1071,7 +1252,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1087,9 +1267,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1107,11 +1287,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" @@ -1132,9 +1325,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1142,7 +1335,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1151,9 +1344,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.3.2" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" dependencies = [ "derive_builder", "log", @@ -1162,9 +1355,15 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1188,9 +1387,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1221,9 +1420,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1266,9 +1465,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1281,7 +1480,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1289,15 +1487,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1322,14 +1519,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1348,9 +1544,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1372,12 +1568,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1385,9 +1582,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1398,9 +1595,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1412,15 +1609,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1432,15 +1629,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1451,6 +1648,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1470,9 +1673,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1480,12 +1683,23 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1521,11 +1735,11 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1543,19 +1757,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.9" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -1574,16 +1778,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1599,6 +1805,16 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonrpcmsg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1611,11 +1827,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "libc", ] @@ -1625,21 +1841,25 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.10.0", "libc", - "redox_syscall 0.6.0", ] [[package]] @@ -1650,15 +1870,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1671,9 +1891,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru" @@ -1692,9 +1912,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", "objc2", @@ -1719,9 +1939,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -1766,9 +1986,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1778,9 +1998,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -1793,19 +2013,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "7.1.3" @@ -1822,7 +2029,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "filetime", "fsevent-sys", "inotify", @@ -1837,9 +2044,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00" dependencies = [ "futures-lite", "log", @@ -1860,9 +2067,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -1878,9 +2085,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-modular" @@ -1908,9 +2115,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1921,7 +2128,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "dispatch2", "objc2", ] @@ -1938,7 +2145,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -1947,9 +2154,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1959,15 +2166,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1985,15 +2191,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2003,8 +2209,9 @@ dependencies = [ [[package]] name = "operator" -version = "0.1.31" +version = "0.2.0" dependencies = [ + "agent-client-protocol", "anyhow", "async-trait", "axum", @@ -2014,12 +2221,14 @@ dependencies = [ "config", "crossterm", "dirs", + "flate2", "futures-util", "glob", "handlebars", "http-body-util", "lazy_static", "mac-notification-sys", + "mime_guess", "notify", "notify-rust", "once_cell", @@ -2027,14 +2236,15 @@ dependencies = [ "ratatui", "regex", "reqwest", - "schemars", + "rust-embed", + "schemars 1.2.1", "serde", "serde_json", "serde_yaml", "sha2", "sysinfo", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "toml", @@ -2046,6 +2256,7 @@ dependencies = [ "ts-rs", "tui-textarea", "utoipa", + "utoipa-axum", "utoipa-swagger-ui", "uuid", "which", @@ -2116,7 +2327,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -2127,6 +2338,12 @@ 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" @@ -2141,9 +2358,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -2151,9 +2368,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -2161,9 +2378,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", @@ -2174,31 +2391,45 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2207,9 +2438,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polling" @@ -2221,15 +2452,15 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2249,20 +2480,30 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.10+spec-1.0.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2290,7 +2531,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -2298,9 +2539,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -2311,7 +2552,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2333,24 +2574,30 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -2368,9 +2615,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2381,7 +2628,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -2390,7 +2637,7 @@ dependencies = [ "itertools", "lru", "paste", - "strum", + "strum 0.26.3", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -2398,9 +2645,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2422,16 +2669,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "redox_syscall" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" -dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", ] [[package]] @@ -2440,7 +2678,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -2467,9 +2705,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -2479,9 +2717,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2490,15 +2728,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -2548,12 +2786,47 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "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" @@ -2561,7 +2834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags 2.10.0", + "bitflags 2.11.1", "serde", "serde_derive", ] @@ -2612,9 +2885,18 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -2622,7 +2904,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2631,22 +2913,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -2658,9 +2940,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2685,9 +2967,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2700,18 +2982,30 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "schemars" -version = "1.2.0" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", @@ -2724,9 +3018,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -2742,12 +3036,12 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2755,14 +3049,20 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -2806,16 +3106,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2861,13 +3161,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -2894,6 +3226,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2923,24 +3261,25 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2950,12 +3289,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2982,7 +3321,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2998,17 +3346,35 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3051,12 +3417,12 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3077,21 +3443,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows 0.61.3", "windows-version", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3115,11 +3481,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3135,9 +3501,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -3195,9 +3561,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3205,9 +3571,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3220,9 +3586,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3237,9 +3603,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3279,12 +3645,13 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -3313,9 +3680,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -3326,33 +3693,33 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", - "toml_datetime 0.7.5+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -3363,9 +3730,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -3379,21 +3746,21 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -3422,12 +3789,13 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", - "thiserror 2.0.17", + "symlink", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -3466,9 +3834,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -3497,7 +3865,7 @@ dependencies = [ "chrono", "lazy_static", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "ts-rs-macros", "uuid", ] @@ -3527,9 +3895,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -3539,13 +3907,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -3556,15 +3924,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -3589,6 +3957,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3603,9 +3977,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3627,21 +4001,34 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "utoipa-gen", ] +[[package]] +name = "utoipa-axum" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839e89ad0db7f9e8737dace8ff43c1ce0711d5e0d08cc1c9d31cc8454d4643ee" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + [[package]] name = "utoipa-gen" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", @@ -3670,11 +4057,11 @@ dependencies = [ [[package]] name = "uuid" -version = "1.19.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -3725,18 +4112,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -3747,22 +4143,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3770,9 +4163,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -3783,18 +4176,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -3812,9 +4239,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -3827,7 +4254,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.1.2", + "rustix 1.1.4", "winsafe", ] @@ -4316,9 +4743,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -4331,15 +4767,103 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yaml-rust2" @@ -4354,9 +4878,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4365,9 +4889,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -4377,9 +4901,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ "async-broadcast", "async-executor", @@ -4395,15 +4919,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix 1.1.4", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -4411,9 +4936,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4426,30 +4951,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "static_assertions", - "winnow", + "winnow 1.0.3", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4458,18 +4982,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -4485,9 +5009,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4496,9 +5020,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4507,9 +5031,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -4527,12 +5051,18 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "zopfli", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.3" @@ -4547,23 +5077,23 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4574,13 +5104,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" dependencies = [ "proc-macro2", "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index 34b8698c..dd837c1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "operator" -version = "0.1.31" +version = "0.2.0" edition = "2021" +rust-version = "1.95" description = "Multi-agent orchestration dashboard for gbqr.us" authors = ["gbqr.us"] license = "MIT" @@ -91,9 +92,22 @@ http-body-util = "0.1" utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-swagger-ui = { version = "8", features = ["axum"] } +# Agent Client Protocol (ACP) — JSON-RPC 2.0 over stdio for editor integration +agent-client-protocol = "0.12" + +# Embedded web UI (behind embed-ui feature flag) +rust-embed = { version = "8", optional = true } +mime_guess = { version = "2", optional = true } +utoipa-axum = "0.1" + +[features] +default = ["embed-ui"] +embed-ui = ["dep:rust-embed", "dep:mime_guess"] + [dev-dependencies] operator-relay = { path = "crates/relay" } tempfile = "3" +flate2 = "1" [lints.rust] unsafe_code = "deny" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d01b07dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# glibc 2.41 >= the ubuntu-24.04 build runners' 2.39, so the GNU binary runs. +FROM debian:trixie-slim + +# Populated automatically by buildx per target platform (amd64 / arm64). +ARG TARGETARCH + +# Substrate Operator needs to launch agents: git (VCS ops), tmux (session +# wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / +# codex / gemini) and its auth are supplied by the user via a derived image or +# a mount + env vars -- not baked in here. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git tmux \ + && rm -rf /var/lib/apt/lists/* + +# CI stages the prebuilt release binary as operator-linux-${TARGETARCH} +# (operator-linux-x86_64 is renamed to operator-linux-amd64; arm64 matches). +COPY operator-linux-${TARGETARCH} /usr/local/bin/operator +RUN chmod +x /usr/local/bin/operator + +# Fail the multi-arch build (incl. arm64 under QEMU binfmt from +# setup-qemu-action) before push if the binary can't execute on this base. +# --version short-circuits in clap before any config or tmux load. +RUN ["/usr/local/bin/operator", "--version"] + +# Run as an unprivileged user with a writable HOME by default. A compromised +# agent tool then can't act as root against the mounted workspace. +# Debian ships a legacy `operator` system group, so assign the existing `users` +# group rather than letting useradd create a colliding same-name group. +RUN useradd --create-home --uid 1000 --gid users operator +USER operator + +# Mount your projects root here: `docker run -v $(pwd):/op:rw ...`. +# Operator auto-loads .tickets/operator/config.toml relative to the cwd. +# Created owned by uid 1000, so the default (no-mount) workdir is writable. +WORKDIR /op +ENTRYPOINT ["operator"] diff --git a/README.md b/README.md index b02c24d7..0b8edc7f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ ![Operator! logo](docs/assets/img/operator_logo.svg) # Operator! -[![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) [![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/untra.operator-terminals?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=untra.operator-terminals) +[![GitHub Tag](https://img.shields.io/github/v/tag/untra/operator)](https://github.com/untra/operator/releases) [![codecov](https://codecov.io/gh/untra/operator/branch/main/graph/badge.svg)](https://codecov.io/gh/untra/operator) + +**_This Project is currently in alpha, is free to use, and officially promises nothing yet!_** * **Session** [![tmux](https://img.shields.io/badge/tmux-1BB91F?logo=tmux&logoColor=white)](https://operator.untra.io/getting-started/sessions/tmux/) [![cmux](https://img.shields.io/badge/cmux-333333)](https://operator.untra.io/getting-started/sessions/cmux/) [![Zellij](https://img.shields.io/badge/Zellij-E8590C)](https://operator.untra.io/getting-started/sessions/zellij/) +* **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/) + * **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/) * **Git Version Control** [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://operator.untra.io/getting-started/git/github/) [![GitLab](https://img.shields.io/badge/GitLab-FC6D26?logo=gitlab&logoColor=white)](https://operator.untra.io/getting-started/git/gitlab/) +* **Platform** [![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)](https://operator.untra.io/getting-started/platforms/docker/) [![Coder](https://img.shields.io/badge/Coder-7C71FF?logo=coder&logoColor=white)](https://operator.untra.io/getting-started/platforms/coder/) + An orchestration tool for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development. Install Operator! Terminals extension from Visual Studio Code Marketplace @@ -307,7 +313,7 @@ Reference documentation is auto-generated from source-of-truth files to minimize | Generator | Source | Output | |-----------|--------|--------| -| taxonomy | `src/backstage/taxonomy.toml` | `docs/backstage/taxonomy.md` | +| taxonomy | `src/taxonomy/taxonomy.toml` | `docs/taxonomy/index.md` | | issuetype-schema | `src/schemas/issuetype_schema.json` | `docs/schemas/issuetype.md` | | metadata-schema | `src/schemas/ticket_metadata.schema.json` | `docs/schemas/metadata.md` | | shortcuts | `src/ui/keybindings.rs` | `docs/shortcuts/index.md` | diff --git a/VERSION b/VERSION index db7a4804..0ea3a944 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.31 +0.2.0 diff --git a/backstage-server/bunfig.toml b/backstage-server/bunfig.toml index ef9c844f..d9661d42 100644 --- a/backstage-server/bunfig.toml +++ b/backstage-server/bunfig.toml @@ -1,2 +1,5 @@ +[install] +minimumReleaseAge = 604800 + [test] preload = ["./packages/app/src/test/setup.ts"] diff --git a/backstage-server/package.json b/backstage-server/package.json index 6edd3ef2..ad71d626 100644 --- a/backstage-server/package.json +++ b/backstage-server/package.json @@ -1,6 +1,6 @@ { "name": "operator-backstage", - "version": "0.1.31", + "version": "0.2.0", "author": { "name": "Samuel Volin", "email": "untra.sam@gmail.com", diff --git a/bindings/AcpConfig.ts b/bindings/AcpConfig.ts new file mode 100644 index 00000000..b8a2a547 --- /dev/null +++ b/bindings/AcpConfig.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Agent Client Protocol (ACP) agent configuration. + * + * Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`, + * Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session + * maps to an in-progress ACP ticket and a delegator subprocess. + */ +export type AcpConfig = { +/** + * Whether the dashboard advertises the `operator acp` stdio entrypoint + * (and editor-config snippet actions). Set to false on machines that + * shouldn't be used as ACP agents. + */ +stdio_advertised: boolean, +/** + * Name of the delegator (from `[[delegators]]`) to use for ACP prompts. + * If unset or not found, falls back to the operator's default delegator + * resolution. + */ +default_delegator: string | null, +/** + * Maximum number of concurrent ACP sessions. New `session/new` requests + * beyond this limit are rejected with a JSON-RPC error. + */ +max_concurrent_sessions: number, }; diff --git a/bindings/AgentDetailResponse.ts b/bindings/AgentDetailResponse.ts new file mode 100644 index 00000000..ce288b6a --- /dev/null +++ b/bindings/AgentDetailResponse.ts @@ -0,0 +1,78 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Full details for a single agent + */ +export type AgentDetailResponse = { +/** + * Agent ID (UUID) + */ +id: string, +/** + * Associated ticket ID (e.g., "FEAT-042") + */ +ticket_id: string, +/** + * Ticket type: FEAT, FIX, INV, SPIKE + */ +ticket_type: string, +/** + * Project being worked on + */ +project: string, +/** + * Agent status: running, `awaiting_input`, completing, orphaned + */ +status: string, +/** + * When the agent started (ISO 8601) + */ +started_at: string, +/** + * Last activity timestamp (ISO 8601) + */ +last_activity: string, +/** + * Current workflow step + */ +current_step: string | null, +/** + * LLM tool used (e.g., "claude", "gemini", "codex") + */ +llm_tool: string | null, +/** + * LLM model alias (e.g., "opus", "sonnet", "gpt-4o") + */ +llm_model: string | null, +/** + * Launch mode: "default", "yolo", "docker", "docker-yolo" + */ +launch_mode: string | null, +/** + * PR URL if created during "pr" step + */ +pr_url: string | null, +/** + * Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + */ +pr_status: string | null, +/** + * Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + */ +session_wrapper: string | null, +/** + * Review state for `awaiting_input` agents + */ +review_state: string | null, +/** + * Completed steps for this ticket + */ +completed_steps: Array, +/** + * Path to the git worktree for this ticket + */ +worktree_path: string | null, +/** + * Whether this is a paired (interactive) agent + */ +paired: boolean, }; diff --git a/bindings/AgentsConfig.ts b/bindings/AgentsConfig.ts index da77acf9..475049d3 100644 --- a/bindings/AgentsConfig.ts +++ b/bindings/AgentsConfig.ts @@ -1,6 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AgentsConfig = { max_parallel: number, cores_reserved: number, health_check_interval: bigint, +export type AgentsConfig = { max_parallel: number, cores_reserved: number, +/** + * Maximum concurrent agents per project/repo (default: 1). + * Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + */ +max_agents_per_repo: number, health_check_interval: bigint, /** * Timeout in seconds for each agent generation (default: 300 = 5 min) */ diff --git a/bindings/Config.ts b/bindings/Config.ts index c5a38378..eed10427 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -1,13 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AcpConfig } from "./AcpConfig"; import type { AgentsConfig } from "./AgentsConfig"; import type { ApiConfig } from "./ApiConfig"; -import type { BackstageConfig } from "./BackstageConfig"; import type { Delegator } from "./Delegator"; import type { GitConfig } from "./GitConfig"; import type { KanbanConfig } from "./KanbanConfig"; import type { LaunchConfig } from "./LaunchConfig"; import type { LlmToolsConfig } from "./LlmToolsConfig"; import type { LoggingConfig } from "./LoggingConfig"; +import type { McpConfig } from "./McpConfig"; import type { ModelServer } from "./ModelServer"; import type { NotificationsConfig } from "./NotificationsConfig"; import type { PathsConfig } from "./PathsConfig"; @@ -28,7 +29,7 @@ projects: Array, agents: AgentsConfig, notifications: NotificationsConfi /** * Session wrapper configuration (tmux, vscode, or cmux) */ -sessions: SessionsConfig, llm_tools: LlmToolsConfig, backstage: BackstageConfig, rest_api: RestApiConfig, git: GitConfig, +sessions: SessionsConfig, llm_tools: LlmToolsConfig, rest_api: RestApiConfig, git: GitConfig, /** * Kanban provider configuration for syncing issues from Jira, Linear, etc. */ @@ -49,4 +50,12 @@ model_servers: Array, /** * Relay MCP injection configuration */ -relay: RelayConfig, }; +relay: RelayConfig, +/** + * Model Context Protocol (MCP) server configuration + */ +mcp: McpConfig, +/** + * Agent Client Protocol (ACP) agent configuration + */ +acp: AcpConfig, }; diff --git a/bindings/ExternalMcpServer.ts b/bindings/ExternalMcpServer.ts new file mode 100644 index 00000000..9fe22b47 --- /dev/null +++ b/bindings/ExternalMcpServer.ts @@ -0,0 +1,44 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An external MCP server to inject into spawned agent sessions. + * + * Values in `command`, `args`, and `env` support `${VAR}` interpolation, + * expanded at spawn time from the operator process environment. + * + * When `discover_from` is set, operator reads an MCP server spec from that + * JSON sidecar file at spawn time. The sidecar must contain a top-level + * `mcpServer` object with `command`, `args`, and `env` fields. If the file + * is absent and `command` is empty, the server is silently skipped. + */ +export type ExternalMcpServer = { +/** + * Server name used as the key in the `mcpServers` JSON object + * (e.g., "kanbots"). Must be unique across all external servers. + */ +name: string, +/** + * Command to execute. Supports `${VAR}` interpolation. + */ +command: string, +/** + * Command arguments. Each element supports `${VAR}` interpolation. + */ +args: Array, +/** + * Environment variables passed to the MCP server process. + * Values support `${VAR}` interpolation. + */ +env: { [key in string]?: string }, +/** + * Whether this server is enabled. Allows disabling without removing config. + */ +enabled: boolean, +/** + * Path to a JSON sidecar discovery file. Relative paths resolve from + * the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. + * When the file exists, its `mcpServer` spec is used verbatim (overriding + * `command`/`args`/`env`). When absent and `command` is empty, the server + * is silently skipped. + */ +discover_from: string | null, }; diff --git a/bindings/KanbanProviderCatalogEntry.ts b/bindings/KanbanProviderCatalogEntry.ts new file mode 100644 index 00000000..ee27924e --- /dev/null +++ b/bindings/KanbanProviderCatalogEntry.ts @@ -0,0 +1,36 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One supported kanban provider, as advertised by + * `GET /api/v1/kanban/providers`. + * + * This is the single source of truth (derived from + * `KanbanProviderType::ALL`) that the web `/#/kanban` list view and the VS + * Code onboarding picker both render against, so the available options can't + * drift between surfaces. + */ +export type KanbanProviderCatalogEntry = { +/** + * Stable lowercase slug ("jira" | "linear" | "github"). + */ +slug: string, +/** + * Human-readable name (e.g. "Jira Cloud", "GitHub Projects"). + */ +display_name: string, +/** + * One-line connect description shown next to the provider. + */ +description: string, +/** + * Credential/token page opened when the user chooses to configure it. + */ +setup_url: string, +/** + * VS Code codicon hint (rendered as `$(icon)` in the picker). + */ +icon: string, +/** + * Whether at least one instance of this provider is already configured. + */ +configured: boolean, }; diff --git a/bindings/LaunchTicketRequest.ts b/bindings/LaunchTicketRequest.ts index 44de7492..8e6ead2c 100644 --- a/bindings/LaunchTicketRequest.ts +++ b/bindings/LaunchTicketRequest.ts @@ -16,6 +16,11 @@ provider: string | null, * Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator */ model: string | null, +/** + * Ad-hoc model server to target (e.g. "ollama-local") — legacy fallback when + * no delegator. Injects the server's base URL / API key env at spawn. + */ +model_server: string | null, /** * Run in YOLO mode (auto-accept all prompts) */ diff --git a/bindings/McpConfig.ts b/bindings/McpConfig.ts new file mode 100644 index 00000000..4fb7e6f1 --- /dev/null +++ b/bindings/McpConfig.ts @@ -0,0 +1,29 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ExternalMcpServer } from "./ExternalMcpServer"; + +/** + * Model Context Protocol (MCP) server configuration + */ +export type McpConfig = { +/** + * Whether to mount MCP HTTP/SSE endpoints on the REST API server. + * Toggling requires an API restart (no hot-swap of the axum router). + */ +http_enabled: boolean, +/** + * Whether the descriptor endpoint advertises the `operator mcp` stdio + * command. Set to false on multi-tenant/remote deployments where clients + * shouldn't spawn local subprocesses. + */ +stdio_advertised: boolean, +/** + * Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + * create) over MCP. Defaults to `false` because any MCP client can call them. + */ +expose_ticket_write_tools: boolean, +/** + * External MCP servers to inject into spawned agent sessions. + * Each entry produces a separate `--mcp-config` file alongside the + * relay config when launching Claude Code agents. + */ +external_servers: Array, }; diff --git a/bindings/McpDescriptorResponse.ts b/bindings/McpDescriptorResponse.ts index 0e94b175..650cea80 100644 --- a/bindings/McpDescriptorResponse.ts +++ b/bindings/McpDescriptorResponse.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { StdioCommand } from "./StdioCommand"; /** * MCP server descriptor for client discovery @@ -27,4 +28,9 @@ label: string, /** * URL of the OpenAPI spec for reference */ -openapi_url: string | null, }; +openapi_url: string | null, +/** + * Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`. + * Clients may spawn this as a subprocess instead of using `transport_url`. + */ +stdio: StdioCommand | null, }; diff --git a/bindings/ModelEntry.ts b/bindings/ModelEntry.ts new file mode 100644 index 00000000..f908890d --- /dev/null +++ b/bindings/ModelEntry.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. + +/** + * A single model offered by a server (from a live probe). + */ +export type ModelEntry = { +/** + * Model id passed to `--model` + */ +id: string, +/** + * Display name when the server provides one + */ +display_name: string | null, }; diff --git a/bindings/ModelServerKindEntry.ts b/bindings/ModelServerKindEntry.ts new file mode 100644 index 00000000..04dfd8bf --- /dev/null +++ b/bindings/ModelServerKindEntry.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A model-server kind from the shared catalog (single source of truth). + */ +export type ModelServerKindEntry = { +/** + * Stable slug stored as `ModelServer.kind` (e.g. "ollama") + */ +slug: string, +/** + * Human-friendly display name + */ +display_name: string, +/** + * One-line connect blurb + */ +description: string, +/** + * Help/credential setup page + */ +setup_url: string, +/** + * Codicon hint + */ +icon: string, +/** + * Whether this is an implicit vendor builtin (always present, not deletable) + */ +is_builtin: boolean, }; diff --git a/bindings/ModelServerModelsResponse.ts b/bindings/ModelServerModelsResponse.ts new file mode 100644 index 00000000..cde204b2 --- /dev/null +++ b/bindings/ModelServerModelsResponse.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. +import type { ModelEntry } from "./ModelEntry"; + +/** + * Response listing the models a server offers, plus reachability. + */ +export type ModelServerModelsResponse = { +/** + * Server name probed + */ +server: string, +/** + * Whether the endpoint was reachable + */ +reachable: boolean, +/** + * Models offered (empty when unreachable) + */ +models: Array, +/** + * Error message when unreachable + */ +error: string | null, }; diff --git a/bindings/Project.ts b/bindings/Project.ts index a256a462..53853a71 100644 --- a/bindings/Project.ts +++ b/bindings/Project.ts @@ -34,7 +34,7 @@ default_branch: string | null, */ ai_context_path: string | null, /** - * Backstage taxonomy kind (tier 1-5) + * Project taxonomy kind (tier 1-5) */ kind: string | null, /** diff --git a/bindings/RowActionDto.ts b/bindings/RowActionDto.ts new file mode 100644 index 00000000..0094098e --- /dev/null +++ b/bindings/RowActionDto.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A browser-openable action on a status section row (e.g. "Open Web UI", + * "Swagger"). Only URL-style actions surface to the web UI; TUI-only actions + * (toggles, env edits) are omitted. + */ +export type RowActionDto = { +/** + * Display label for the action button/link. + */ +label: string, +/** + * Browser URL the action opens. + */ +url: string, }; diff --git a/bindings/SectionDto.ts b/bindings/SectionDto.ts new file mode 100644 index 00000000..78af2f51 --- /dev/null +++ b/bindings/SectionDto.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SectionRowDto } from "./SectionRowDto"; + +/** + * A status section with its health and child rows. + */ +export type SectionDto = { +/** + * Stable section id (e.g. "config", "connections", "kanban"). + */ +id: string, label: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, description: string, +/** + * Section ids that must be Green before this section is usable. + */ +prerequisites: Array, +/** + * Whether all prerequisites are met. Sections are always returned (the web + * UI styles unmet ones as locked) rather than hidden by progressive disclosure. + */ +met: boolean, children: Array, }; diff --git a/bindings/SectionRowDto.ts b/bindings/SectionRowDto.ts new file mode 100644 index 00000000..ad76b44d --- /dev/null +++ b/bindings/SectionRowDto.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RowActionDto } from "./RowActionDto"; + +/** + * A child row within a status section. + */ +export type SectionRowDto = { +/** + * Stable, section-scoped row id. Clients use it as a tree key and to route + * row-specific commands without matching on the (mutable) display label. + * Dynamic rows carry their entity key (issue-type key, project name); + * static rows carry a fixed slug (e.g. "git-token"). + */ +id: string, +/** + * Nesting depth within the section (1 = direct child, 2 = grandchild). + * Lets clients rebuild the tree (e.g. LLM tools → model aliases). + */ +depth: number, label: string, description: string, +/** + * Icon hint (e.g. "check", "warning", "tool", "folder"). + */ +icon: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, +/** + * Browser-openable actions for this row (links shown in the web UI). + */ +actions: Array, }; diff --git a/bindings/StdioCommand.ts b/bindings/StdioCommand.ts new file mode 100644 index 00000000..0e4e894d --- /dev/null +++ b/bindings/StdioCommand.ts @@ -0,0 +1,21 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Stdio entrypoint advertised in the descriptor when + * `[mcp].stdio_advertised = true`. Clients use this to spawn operator + * as an MCP subprocess instead of (or alongside) the SSE transport. + */ +export type StdioCommand = { +/** + * Absolute path to the operator binary (the same binary serving this descriptor) + */ +command: string, +/** + * Args to pass: typically `["mcp"]` + */ +args: Array, +/** + * Working directory the client should set when spawning. Defaults to the + * operator process's current working directory. + */ +cwd: string, }; diff --git a/bindings/TicketDetailResponse.ts b/bindings/TicketDetailResponse.ts new file mode 100644 index 00000000..5153b1b5 --- /dev/null +++ b/bindings/TicketDetailResponse.ts @@ -0,0 +1,82 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Full ticket details including content and metadata + */ +export type TicketDetailResponse = { +/** + * Ticket ID (e.g., "FEAT-7598") + */ +id: string, +/** + * Ticket summary/title + */ +summary: string, +/** + * Ticket type: FEAT, FIX, INV, SPIKE + */ +ticket_type: string, +/** + * Project name + */ +project: string, +/** + * Current status: queued, running, awaiting, completed + */ +status: string, +/** + * Current step name + */ +step: string, +/** + * Human-readable step name + */ +step_display_name: string | null, +/** + * Priority: P0-critical, P1-high, P2-medium, P3-low + */ +priority: string, +/** + * Timestamp (YYYYMMDD-HHMM format) + */ +timestamp: string, +/** + * Full markdown content of the ticket + */ +content: string, +/** + * Ticket filename + */ +filename: string, +/** + * Full filesystem path + */ +filepath: string, +/** + * Session IDs per step (`step_name` -> `session_uuid`) + */ +sessions: { [key in string]?: string }, +/** + * Delegator used per step (`step_name` -> `delegator_name`) + */ +step_delegators: { [key in string]?: string }, +/** + * Path to git worktree (if created) + */ +worktree_path: string | null, +/** + * Git branch name + */ +branch: string | null, +/** + * External issue ID from kanban provider + */ +external_id: string | null, +/** + * URL to the issue in the external provider + */ +external_url: string | null, +/** + * Provider name (e.g., "jira", "linear") + */ +external_provider: string | null, }; diff --git a/bindings/UpdateModelServerRequest.ts b/bindings/UpdateModelServerRequest.ts new file mode 100644 index 00000000..875b57ab --- /dev/null +++ b/bindings/UpdateModelServerRequest.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to update an existing user-declared model server. + * + * All fields except `kind` are replaced; `name` is taken from the path. + */ +export type UpdateModelServerRequest = { +/** + * Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + */ +kind: string, +/** + * Base URL of the inference endpoint + */ +base_url: string | null, +/** + * Name of an env var providing the API key + */ +api_key_env: string | null, +/** + * Additional environment variables + */ +extra_env: { [key in string]?: string }, +/** + * Optional display name for UI + */ +display_name: string | null, }; diff --git a/bindings/UpdateTicketStatusRequest.ts b/bindings/UpdateTicketStatusRequest.ts new file mode 100644 index 00000000..5dcf0181 --- /dev/null +++ b/bindings/UpdateTicketStatusRequest.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request to update a ticket's status + */ +export type UpdateTicketStatusRequest = { +/** + * Target status: queued, running, awaiting, done + */ +status: string, }; diff --git a/bindings/UpdateTicketStatusResponse.ts b/bindings/UpdateTicketStatusResponse.ts new file mode 100644 index 00000000..368d1e48 --- /dev/null +++ b/bindings/UpdateTicketStatusResponse.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. + +/** + * Response from updating a ticket's status + */ +export type UpdateTicketStatusResponse = { +/** + * Ticket ID + */ +id: string, +/** + * Previous status before the update + */ +previous_status: string, +/** + * New status after the update + */ +status: string, +/** + * Human-readable message + */ +message: string, }; diff --git a/bindings/WorkflowExportResponse.ts b/bindings/WorkflowExportResponse.ts new file mode 100644 index 00000000..5f537777 --- /dev/null +++ b/bindings/WorkflowExportResponse.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. + +/** + * Response for exporting a ticket to a Claude dynamic workflow (`.js`). + */ +export type WorkflowExportResponse = { +/** + * The ticket the workflow was generated from. + */ +ticket_id: string, +/** + * The issue type key that supplied the step structure. + */ +issuetype_key: string, +/** + * Suggested filename for saving the workflow (`.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source. + */ +contents: string, }; diff --git a/bindings/WorkflowPreviewResponse.ts b/bindings/WorkflowPreviewResponse.ts new file mode 100644 index 00000000..eabf09c6 --- /dev/null +++ b/bindings/WorkflowPreviewResponse.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response for a *preview* workflow generated from an issue type alone (no + * concrete ticket). Used by the UI to visualize an issue type's workflow shape. + */ +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, }; diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..df593449 --- /dev/null +++ b/build.rs @@ -0,0 +1,50 @@ +use std::path::Path; + +fn main() { + if std::env::var("CARGO_FEATURE_EMBED_UI").is_err() { + return; + } + + let ui_dist = Path::new("ui/dist"); + let index = ui_dist.join("index.html"); + + // Create a minimal placeholder so rust-embed's derive macro doesn't fail + // during CI `--all-features` when the UI hasn't been built yet. + if !index.exists() { + std::fs::create_dir_all(ui_dist).expect("create ui/dist"); + std::fs::write( + &index, + "", + ) + .expect("write placeholder index.html"); + println!( + "cargo:warning=ui/dist/index.html is a placeholder — run `cd ui && bun run build` for real UI" + ); + } + + // Size gate: walk ui/dist, sum file sizes, fail if over 15MB uncompressed + let total = walk_dir_size(ui_dist); + assert!( + total <= 15_728_640, + "UI dist is {}B ({:.1}MB) — exceeds 15MB uncompressed budget", + total, + total as f64 / 1_048_576.0 + ); + + println!("cargo:rerun-if-changed=ui/dist"); +} + +fn walk_dir_size(dir: &Path) -> u64 { + let mut total = 0u64; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += walk_dir_size(&path); + } else if let Ok(meta) = path.metadata() { + total += meta.len(); + } + } + } + total +} diff --git a/coder-module/README.md b/coder-module/README.md new file mode 100644 index 00000000..4c927464 --- /dev/null +++ b/coder-module/README.md @@ -0,0 +1,77 @@ +--- +display_name: Operator +description: Run Operator agent orchestrator as a background service in your Coder workspace +icon: ../../../../.icons/terminal.svg +verified: false +tags: [ai, agents, orchestration, automation] +--- + +# Operator + +Run [Operator](https://github.com/untra/operator) as a background REST API server inside your Coder workspace. Operator manages ticket queues, launches LLM-powered coding agents, and tracks their progress. + +The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. + +## Usage + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +### Custom configuration + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + port = 7008 + max_parallel_agents = 4 + session_wrapper = "tmux" +} +``` + +### Full TOML override + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + config_toml = <<-EOT + [rest_api] + enabled = true + port = 7008 + + [agents] + max_parallel = 4 + health_check_interval = 30 + + [sessions] + wrapper = "tmux" + + [[delegators]] + name = "default" + tool = "claude-code" + model = "sonnet" + EOT +} +``` + +## Prerequisites + +The workspace image must include `tmux` (or your chosen `session_wrapper`) for operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +## Coder Workspace Context + +Coder automatically injects environment variables into every workspace that operator can reference in ticket templates and agent prompts: + +- `CODER_WORKSPACE_NAME` — workspace identifier +- `CODER_WORKSPACE_OWNER` — workspace owner username +- `CODER_AGENT_TOKEN` — agent authentication token + +No operator configuration is needed to access these — they are ambient in the workspace environment. diff --git a/coder-module/main.test.ts b/coder-module/main.test.ts new file mode 100644 index 00000000..3e7a1e93 --- /dev/null +++ b/coder-module/main.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("operator", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("offline and use_cached cannot both be true", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + offline: "true", + use_cached: "true", + }); + }; + expect(t).toThrow("offline and use_cached cannot both be true"); + }); + + it("rejects invalid session_wrapper values", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + session_wrapper: "invalid", + }); + }; + expect(t).toThrow("session_wrapper must be one of"); + }); + + it("rejects invalid share values", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + share: "invalid", + }); + }; + expect(t).toThrow("share must be one of"); + }); + + it("applies with default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const script = findResourceInstance(state, "coder_script"); + expect(script.run_on_start).toBe(true); + expect(script.display_name).toBe("Operator"); + + const app = findResourceInstance(state, "coder_app"); + expect(app.url).toBe("http://localhost:7008"); + expect(app.slug).toBe("operator"); + expect(app.display_name).toBe("Operator"); + expect(app.share).toBe("owner"); + expect(app.healthcheck[0].url).toBe( + "http://localhost:7008/api/v1/health", + ); + }); + + it("applies with custom port", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + port: "9000", + }); + + const app = findResourceInstance(state, "coder_app"); + expect(app.url).toBe("http://localhost:9000"); + expect(app.healthcheck[0].url).toBe( + "http://localhost:9000/api/v1/health", + ); + }); + + it("generates config with custom values", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + port: "9000", + max_parallel_agents: "4", + session_wrapper: "zellij", + }); + + const script = findResourceInstance(state, "coder_script").script; + expect(script).toContain("port = 9000"); + expect(script).toContain("max_parallel = 4"); + expect(script).toContain('wrapper = "zellij"'); + }); + + it("uses config_toml verbatim when provided", async () => { + const customConfig = '[rest_api]\nenabled = true\nport = 8080'; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + config_toml: customConfig, + }); + + const script = findResourceInstance(state, "coder_script").script; + expect(script).toContain(customConfig); + }); +}); diff --git a/coder-module/main.tf b/coder-module/main.tf new file mode 100644 index 00000000..3e788a01 --- /dev/null +++ b/coder-module/main.tf @@ -0,0 +1,149 @@ +terraform { + required_version = ">= 1.2" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "port" { + type = number + description = "The port for the operator REST API server." + default = 7008 +} + +variable "display_name" { + type = string + description = "The display name for the operator application in the Coder dashboard." + default = "Operator" +} + +variable "slug" { + type = string + description = "The slug for the operator application." + default = "operator" +} + +variable "install_version" { + type = string + description = "The version of operator to install (must match a GitHub release tag)." + default = "0.2.0" +} + +variable "install_prefix" { + type = string + description = "The directory to install the operator binary into." + default = "/tmp/operator" +} + +variable "log_path" { + type = string + description = "The path to write operator log output." + default = "/tmp/operator.log" +} + +variable "config_toml" { + type = string + description = "Raw TOML configuration content. When set, this is written verbatim to .tickets/operator/config.toml instead of the auto-generated config." + default = "" +} + +variable "max_parallel_agents" { + type = number + description = "Maximum number of parallel agents operator can run." + default = 2 +} + +variable "session_wrapper" { + type = string + description = "Session wrapper type for agent terminal sessions." + default = "tmux" + validation { + condition = contains(["tmux", "cmux", "zellij"], var.session_wrapper) + error_message = "session_wrapper must be one of: tmux, cmux, zellij." + } +} + +variable "share" { + type = string + default = "owner" + validation { + condition = contains(["owner", "authenticated", "public"], var.share) + error_message = "share must be one of: owner, authenticated, public." + } +} + +variable "order" { + type = number + description = "The order determines the position of the app in the Coder dashboard. Lower values appear first." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "offline" { + type = bool + description = "Skip downloading operator from GitHub. Requires a pre-installed binary at install_prefix." + default = false +} + +variable "use_cached" { + type = bool + description = "Use a cached operator binary if present, otherwise download from GitHub." + default = false +} + +resource "coder_script" "operator" { + agent_id = var.agent_id + display_name = "Operator" + icon = "/icon/terminal.svg" + script = templatefile("${path.module}/run.sh", { + VERSION = var.install_version, + PORT = var.port, + INSTALL_PREFIX = var.install_prefix, + LOG_PATH = var.log_path, + CONFIG_TOML = var.config_toml, + MAX_PARALLEL = var.max_parallel_agents, + SESSION_WRAPPER = var.session_wrapper, + OFFLINE = var.offline, + USE_CACHED = var.use_cached, + }) + run_on_start = true + + lifecycle { + precondition { + condition = !var.offline || !var.use_cached + error_message = "offline and use_cached cannot both be true." + } + } +} + +resource "coder_app" "operator" { + agent_id = var.agent_id + slug = var.slug + display_name = var.display_name + url = "http://localhost:${var.port}" + icon = "/icon/terminal.svg" + subdomain = false + share = var.share + order = var.order + group = var.group + + healthcheck { + url = "http://localhost:${var.port}/api/v1/health" + interval = 5 + threshold = 6 + } +} diff --git a/coder-module/run.sh b/coder-module/run.sh new file mode 100755 index 00000000..4c793bfd --- /dev/null +++ b/coder-module/run.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +BOLD='\033[0;1m' +RESET='\033[0m' + +ARCH=$$(uname -m) +case "$$ARCH" in + x86_64) PLATFORM="linux-x86_64" ;; + aarch64) PLATFORM="linux-arm64" ;; + *) + echo "Unsupported architecture: $$ARCH" + exit 1 + ;; +esac + +OPERATOR_BIN="${INSTALL_PREFIX}/operator" + +if [ "${USE_CACHED}" = "true" ] && [ -f "$$OPERATOR_BIN" ]; then + echo "Using cached operator binary" +elif [ "${OFFLINE}" = "true" ]; then + if [ -f "$$OPERATOR_BIN" ]; then + echo "Using offline operator binary" + else + echo "No operator binary found in offline mode" + exit 1 + fi +else + printf "$${BOLD}Installing operator v${VERSION}...$${RESET}\n" + + if [ -n "$$CODER_SCRIPT_BIN_DIR" ] && [ -e "$$CODER_SCRIPT_BIN_DIR/operator" ]; then + rm "$$CODER_SCRIPT_BIN_DIR/operator" + fi + + mkdir -p "${INSTALL_PREFIX}" + RELEASE_URL="https://github.com/untra/operator/releases/download/v${VERSION}/operator-$$PLATFORM" + + output=$$(curl -fsSL "$$RELEASE_URL" -o "$$OPERATOR_BIN" 2>&1) + if [ $$? -ne 0 ]; then + echo "Failed to download operator: $$output" + exit 1 + fi + chmod +x "$$OPERATOR_BIN" + printf "Operator v${VERSION} installed to ${INSTALL_PREFIX}\n" +fi + +if [ -n "$$CODER_SCRIPT_BIN_DIR" ] && [ ! -e "$$CODER_SCRIPT_BIN_DIR/operator" ]; then + ln -s "$$OPERATOR_BIN" "$$CODER_SCRIPT_BIN_DIR/operator" +fi + +mkdir -p .tickets/operator .tickets/queue + +if [ -n "${CONFIG_TOML}" ]; then + echo "${CONFIG_TOML}" > .tickets/operator/config.toml +else + cat > .tickets/operator/config.toml < "${LOG_PATH}" 2>&1 & + +for i in $$(seq 1 30); do + if curl -s "http://localhost:${PORT}/api/v1/health" > /dev/null 2>&1; then + echo "Operator is running on port ${PORT}" + exit 0 + fi + sleep 1 +done + +echo "Operator failed to start within 30 seconds. Check logs at ${LOG_PATH}" +exit 1 diff --git a/config/default.toml b/config/default.toml index 61ee22cb..78516d9e 100644 --- a/config/default.toml +++ b/config/default.toml @@ -10,6 +10,9 @@ max_parallel = 5 # Number of CPU cores to keep free (actual max = cores - reserved) cores_reserved = 1 +# Maximum concurrent agents per project/repo (requires use_worktrees when > 1) +max_agents_per_repo = 1 + # Timeout for agent health checks (seconds) health_check_interval = 30 diff --git a/crates/relay/Cargo.lock b/crates/relay/Cargo.lock index 767e158c..91350b93 100644 --- a/crates/relay/Cargo.lock +++ b/crates/relay/Cargo.lock @@ -33,9 +33,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -94,13 +94,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -177,9 +176,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -200,7 +199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -242,9 +241,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -264,9 +263,9 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ "bitflags 2.11.1", "libc", @@ -290,10 +289,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall", ] [[package]] @@ -304,9 +300,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "memchr" @@ -388,12 +384,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "prettyplease" version = "0.2.37" @@ -428,15 +418,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -514,9 +495,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -587,9 +568,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -701,9 +682,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -714,9 +695,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -724,9 +705,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -737,9 +718,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 64f01ec7..e2a3c2d2 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) base64 (0.3.0) bigdecimal (4.0.1) @@ -45,13 +45,13 @@ GEM webrick (~> 1.7) jekyll-sass-converter (3.1.0) sass-embedded (~> 1.75) - jekyll-seo-tag (2.8.0) + jekyll-seo-tag (2.9.0) jekyll (>= 3.8, < 5.0) jekyll-sitemap (1.4.0) jekyll (>= 3.7, < 5.0) jekyll-watch (2.2.1) listen (~> 3.0) - json (2.18.0) + json (2.19.5) kramdown (2.5.1) rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) diff --git a/docs/_config.yml b/docs/_config.yml index 1715da00..6ea56231 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -42,7 +42,7 @@ collections_dir: . # Permalink structure permalink: pretty -version: 0.1.31 +version: 0.2.0 # Google Analytics ga_tag: G-5JZPJWWT7S # Replace with actual GA4 measurement ID from analytics.google.com diff --git a/docs/_data/checksums.yml b/docs/_data/checksums.yml index e3c5dc24..a227eeb1 100644 --- a/docs/_data/checksums.yml +++ b/docs/_data/checksums.yml @@ -6,9 +6,3 @@ operator: linux_arm64: "8f88a7faaf536809707c525e71190f3bee70a5c6be5f83636abe90118f83620f" linux_x86_64: "f26ae3badf31a914c15e979696fa4a2850cc6a942475dbe7b4fdb56dbc483d5e" windows_x86_64: "ac832b8daa1c7aa790b3a2921d9a94c193a9764c4f969daa0520dbb52f5001bc" - -backstage: - darwin_arm64: "b253c7af7f78dd077cece5ef0e616574a65eb2e976b639bbf4e67156b5bbd850" - linux_arm64: "92f345b2c239964e71f5822c796ac04b4c3e9e0a291f4c66c269507dfda17549" - linux_x64: "bebbc806b4c0b19deaa8d3707c04e078cff9103e96aa86404aeac565f6c36b5b" - windows_x64: "" diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 44e422da..c11a7313 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -24,6 +24,12 @@ docs: - title: VS Code Extension url: /getting-started/sessions/vscode/ icon: vscode + - title: Cursor + url: /getting-started/sessions/cursor/ + icon: cursor + - title: Zed + url: /getting-started/sessions/zed/ + icon: zed - title: Supported Kanban Providers url: /getting-started/kanban/ children: @@ -63,16 +69,29 @@ docs: - title: Operating System url: /getting-started/notifications/os/ icon: notification + - title: Supported Workspace Platforms + url: /getting-started/platforms/ + children: + - title: Coder + url: /getting-started/platforms/coder/ + icon: coder + - title: Docker + url: /getting-started/platforms/docker/ + icon: docker - title: Core children: - title: Kanban url: /kanban/ + codicon: layout - title: Issue Types url: /issue-types/ + codicon: issues - title: Tickets url: /tickets/ + codicon: note - title: Agents url: /agents/ + codicon: robot children: - title: Artifact Detection url: /agents/artifact-detection/ @@ -80,17 +99,26 @@ docs: children: - title: CLI url: /cli/ + codicon: terminal - title: LLM Tools url: /llm-tools/ + codicon: sparkle - title: Configuration url: /configuration/ + codicon: settings-gear - title: Shortcuts url: /shortcuts/ - - title: Backstage + codicon: keyboard + - title: Design System + url: /design-system/ + codicon: symbol-color + - title: Taxonomy + codicon: type-hierarchy children: - title: Project Taxonomy - url: /backstage/taxonomy/ + url: /taxonomy/ - title: Schemas + codicon: bracket children: - title: Overview url: /schemas/ diff --git a/docs/_includes/head.html b/docs/_includes/head.html index da2637c7..80c70368 100644 --- a/docs/_includes/head.html +++ b/docs/_includes/head.html @@ -18,6 +18,8 @@ })(); + + diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html index 80087e02..90d7b0dc 100644 --- a/docs/_includes/sidebar.html +++ b/docs/_includes/sidebar.html @@ -42,7 +42,7 @@ {% endfor %}
- {% if item.url %}{{ item.title }}{% else %}{{ item.title }}{% endif %} + {% if item.url %}{% if item.codicon %}{% endif %}{{ item.title }}{% else %}{% if item.codicon %}{% endif %}{{ item.title }}{% endif %}
{% else %} - {{ + {% if item.codicon %}{% endif %}{{ item.title }} {% endif %} diff --git a/docs/assets/css/codicon.css b/docs/assets/css/codicon.css new file mode 100644 index 00000000..78a38a6a --- /dev/null +++ b/docs/assets/css/codicon.css @@ -0,0 +1,710 @@ +/*--------------------------------------------------------------------------------------------- + * Vendored from @vscode/codicons (https://github.com/microsoft/vscode-codicons). + * Code (this CSS) is licensed under the MIT License, (c) Microsoft Corporation. + * The icons themselves are licensed under CC-BY-4.0. + * The font url() below was repointed to ../fonts/codicon.ttf for the Jekyll layout. + *--------------------------------------------------------------------------------------------*/ + +@font-face { + font-family: "codicon"; + font-display: block; + src: url("../fonts/codicon.ttf?721d4c0a96379d0c13d3d5596893c348") format("truetype"); +} + +.codicon[class*='codicon-'] { + font: normal normal normal 16px/1 codicon; + display: inline-block; + text-decoration: none; + text-rendering: auto; + text-align: center; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; + -webkit-user-select: none; + -ms-user-select: none; +} + +/*--------------------- + * Modifiers + *-------------------*/ + +@keyframes codicon-spin { + 100% { + transform:rotate(360deg); + } +} + +.codicon-sync.codicon-modifier-spin, +.codicon-loading.codicon-modifier-spin, +.codicon-gear.codicon-modifier-spin { + /* Use steps to throttle FPS to reduce CPU usage */ + animation: codicon-spin 1.5s steps(30) infinite; +} + +.codicon-modifier-disabled { + opacity: 0.5; +} + +.codicon-modifier-hidden { + opacity: 0; +} + +/* custom speed & easing for loading icon */ +.codicon-loading { + animation-duration: 1s !important; + animation-timing-function: cubic-bezier(0.53, 0.21, 0.29, 0.67) !important; +} + +/*--------------------- + * Icons + *-------------------*/ + +.codicon-add:before { content: "\ea60" } +.codicon-plus:before { content: "\ea60" } +.codicon-gist-new:before { content: "\ea60" } +.codicon-repo-create:before { content: "\ea60" } +.codicon-lightbulb:before { content: "\ea61" } +.codicon-light-bulb:before { content: "\ea61" } +.codicon-repo:before { content: "\ea62" } +.codicon-repo-delete:before { content: "\ea62" } +.codicon-gist-fork:before { content: "\ea63" } +.codicon-repo-forked:before { content: "\ea63" } +.codicon-git-pull-request:before { content: "\ea64" } +.codicon-git-pull-request-abandoned:before { content: "\ea64" } +.codicon-record-keys:before { content: "\ea65" } +.codicon-keyboard:before { content: "\ea65" } +.codicon-tag:before { content: "\ea66" } +.codicon-git-pull-request-label:before { content: "\ea66" } +.codicon-tag-add:before { content: "\ea66" } +.codicon-tag-remove:before { content: "\ea66" } +.codicon-person:before { content: "\ea67" } +.codicon-person-follow:before { content: "\ea67" } +.codicon-person-outline:before { content: "\ea67" } +.codicon-person-filled:before { content: "\ea67" } +.codicon-source-control:before { content: "\ea68" } +.codicon-mirror:before { content: "\ea69" } +.codicon-mirror-public:before { content: "\ea69" } +.codicon-star:before { content: "\ea6a" } +.codicon-star-add:before { content: "\ea6a" } +.codicon-star-delete:before { content: "\ea6a" } +.codicon-star-empty:before { content: "\ea6a" } +.codicon-comment:before { content: "\ea6b" } +.codicon-comment-add:before { content: "\ea6b" } +.codicon-alert:before { content: "\ea6c" } +.codicon-warning:before { content: "\ea6c" } +.codicon-search:before { content: "\ea6d" } +.codicon-search-save:before { content: "\ea6d" } +.codicon-log-out:before { content: "\ea6e" } +.codicon-sign-out:before { content: "\ea6e" } +.codicon-log-in:before { content: "\ea6f" } +.codicon-sign-in:before { content: "\ea6f" } +.codicon-eye:before { content: "\ea70" } +.codicon-eye-unwatch:before { content: "\ea70" } +.codicon-eye-watch:before { content: "\ea70" } +.codicon-circle-filled:before { content: "\ea71" } +.codicon-primitive-dot:before { content: "\ea71" } +.codicon-close-dirty:before { content: "\ea71" } +.codicon-debug-breakpoint:before { content: "\ea71" } +.codicon-debug-breakpoint-disabled:before { content: "\ea71" } +.codicon-debug-hint:before { content: "\ea71" } +.codicon-terminal-decoration-success:before { content: "\ea71" } +.codicon-primitive-square:before { content: "\ea72" } +.codicon-edit:before { content: "\ea73" } +.codicon-pencil:before { content: "\ea73" } +.codicon-info:before { content: "\ea74" } +.codicon-issue-opened:before { content: "\ea74" } +.codicon-gist-private:before { content: "\ea75" } +.codicon-git-fork-private:before { content: "\ea75" } +.codicon-lock:before { content: "\ea75" } +.codicon-mirror-private:before { content: "\ea75" } +.codicon-close:before { content: "\ea76" } +.codicon-remove-close:before { content: "\ea76" } +.codicon-x:before { content: "\ea76" } +.codicon-repo-sync:before { content: "\ea77" } +.codicon-sync:before { content: "\ea77" } +.codicon-clone:before { content: "\ea78" } +.codicon-desktop-download:before { content: "\ea78" } +.codicon-beaker:before { content: "\ea79" } +.codicon-microscope:before { content: "\ea79" } +.codicon-vm:before { content: "\ea7a" } +.codicon-device-desktop:before { content: "\ea7a" } +.codicon-file:before { content: "\ea7b" } +.codicon-more:before { content: "\ea7c" } +.codicon-ellipsis:before { content: "\ea7c" } +.codicon-kebab-horizontal:before { content: "\ea7c" } +.codicon-mail-reply:before { content: "\ea7d" } +.codicon-reply:before { content: "\ea7d" } +.codicon-organization:before { content: "\ea7e" } +.codicon-organization-filled:before { content: "\ea7e" } +.codicon-organization-outline:before { content: "\ea7e" } +.codicon-new-file:before { content: "\ea7f" } +.codicon-file-add:before { content: "\ea7f" } +.codicon-new-folder:before { content: "\ea80" } +.codicon-file-directory-create:before { content: "\ea80" } +.codicon-trash:before { content: "\ea81" } +.codicon-trashcan:before { content: "\ea81" } +.codicon-history:before { content: "\ea82" } +.codicon-clock:before { content: "\ea82" } +.codicon-folder:before { content: "\ea83" } +.codicon-file-directory:before { content: "\ea83" } +.codicon-symbol-folder:before { content: "\ea83" } +.codicon-logo-github:before { content: "\ea84" } +.codicon-mark-github:before { content: "\ea84" } +.codicon-github:before { content: "\ea84" } +.codicon-terminal:before { content: "\ea85" } +.codicon-console:before { content: "\ea85" } +.codicon-repl:before { content: "\ea85" } +.codicon-zap:before { content: "\ea86" } +.codicon-symbol-event:before { content: "\ea86" } +.codicon-error:before { content: "\ea87" } +.codicon-stop:before { content: "\ea87" } +.codicon-variable:before { content: "\ea88" } +.codicon-symbol-variable:before { content: "\ea88" } +.codicon-array:before { content: "\ea8a" } +.codicon-symbol-array:before { content: "\ea8a" } +.codicon-symbol-module:before { content: "\ea8b" } +.codicon-symbol-package:before { content: "\ea8b" } +.codicon-symbol-namespace:before { content: "\ea8b" } +.codicon-symbol-object:before { content: "\ea8b" } +.codicon-symbol-method:before { content: "\ea8c" } +.codicon-symbol-function:before { content: "\ea8c" } +.codicon-symbol-constructor:before { content: "\ea8c" } +.codicon-symbol-boolean:before { content: "\ea8f" } +.codicon-symbol-null:before { content: "\ea8f" } +.codicon-symbol-numeric:before { content: "\ea90" } +.codicon-symbol-number:before { content: "\ea90" } +.codicon-symbol-structure:before { content: "\ea91" } +.codicon-symbol-struct:before { content: "\ea91" } +.codicon-symbol-parameter:before { content: "\ea92" } +.codicon-symbol-type-parameter:before { content: "\ea92" } +.codicon-symbol-key:before { content: "\ea93" } +.codicon-symbol-text:before { content: "\ea93" } +.codicon-symbol-reference:before { content: "\ea94" } +.codicon-go-to-file:before { content: "\ea94" } +.codicon-symbol-enum:before { content: "\ea95" } +.codicon-symbol-value:before { content: "\ea95" } +.codicon-symbol-ruler:before { content: "\ea96" } +.codicon-symbol-unit:before { content: "\ea96" } +.codicon-activate-breakpoints:before { content: "\ea97" } +.codicon-archive:before { content: "\ea98" } +.codicon-arrow-both:before { content: "\ea99" } +.codicon-arrow-down:before { content: "\ea9a" } +.codicon-arrow-left:before { content: "\ea9b" } +.codicon-arrow-right:before { content: "\ea9c" } +.codicon-arrow-small-down:before { content: "\ea9d" } +.codicon-arrow-small-left:before { content: "\ea9e" } +.codicon-arrow-small-right:before { content: "\ea9f" } +.codicon-arrow-small-up:before { content: "\eaa0" } +.codicon-arrow-up:before { content: "\eaa1" } +.codicon-bell:before { content: "\eaa2" } +.codicon-bold:before { content: "\eaa3" } +.codicon-book:before { content: "\eaa4" } +.codicon-bookmark:before { content: "\eaa5" } +.codicon-debug-breakpoint-conditional-unverified:before { content: "\eaa6" } +.codicon-debug-breakpoint-conditional:before { content: "\eaa7" } +.codicon-debug-breakpoint-conditional-disabled:before { content: "\eaa7" } +.codicon-debug-breakpoint-data-unverified:before { content: "\eaa8" } +.codicon-debug-breakpoint-data:before { content: "\eaa9" } +.codicon-debug-breakpoint-data-disabled:before { content: "\eaa9" } +.codicon-debug-breakpoint-log-unverified:before { content: "\eaaa" } +.codicon-debug-breakpoint-log:before { content: "\eaab" } +.codicon-debug-breakpoint-log-disabled:before { content: "\eaab" } +.codicon-briefcase:before { content: "\eaac" } +.codicon-broadcast:before { content: "\eaad" } +.codicon-browser:before { content: "\eaae" } +.codicon-bug:before { content: "\eaaf" } +.codicon-calendar:before { content: "\eab0" } +.codicon-case-sensitive:before { content: "\eab1" } +.codicon-check:before { content: "\eab2" } +.codicon-checklist:before { content: "\eab3" } +.codicon-chevron-down:before { content: "\eab4" } +.codicon-chevron-left:before { content: "\eab5" } +.codicon-chevron-right:before { content: "\eab6" } +.codicon-chevron-up:before { content: "\eab7" } +.codicon-chrome-close:before { content: "\eab8" } +.codicon-chrome-maximize:before { content: "\eab9" } +.codicon-chrome-minimize:before { content: "\eaba" } +.codicon-chrome-restore:before { content: "\eabb" } +.codicon-circle-outline:before { content: "\eabc" } +.codicon-circle:before { content: "\eabc" } +.codicon-debug-breakpoint-unverified:before { content: "\eabc" } +.codicon-terminal-decoration-incomplete:before { content: "\eabc" } +.codicon-circle-slash:before { content: "\eabd" } +.codicon-circuit-board:before { content: "\eabe" } +.codicon-clear-all:before { content: "\eabf" } +.codicon-clippy:before { content: "\eac0" } +.codicon-close-all:before { content: "\eac1" } +.codicon-cloud-download:before { content: "\eac2" } +.codicon-cloud-upload:before { content: "\eac3" } +.codicon-code:before { content: "\eac4" } +.codicon-collapse-all:before { content: "\eac5" } +.codicon-color-mode:before { content: "\eac6" } +.codicon-comment-discussion:before { content: "\eac7" } +.codicon-credit-card:before { content: "\eac9" } +.codicon-dash:before { content: "\eacc" } +.codicon-dashboard:before { content: "\eacd" } +.codicon-database:before { content: "\eace" } +.codicon-debug-continue:before { content: "\eacf" } +.codicon-debug-disconnect:before { content: "\ead0" } +.codicon-debug-pause:before { content: "\ead1" } +.codicon-debug-restart:before { content: "\ead2" } +.codicon-debug-start:before { content: "\ead3" } +.codicon-debug-step-into:before { content: "\ead4" } +.codicon-debug-step-out:before { content: "\ead5" } +.codicon-debug-step-over:before { content: "\ead6" } +.codicon-debug-stop:before { content: "\ead7" } +.codicon-debug:before { content: "\ead8" } +.codicon-device-camera-video:before { content: "\ead9" } +.codicon-device-camera:before { content: "\eada" } +.codicon-device-mobile:before { content: "\eadb" } +.codicon-diff-added:before { content: "\eadc" } +.codicon-diff-ignored:before { content: "\eadd" } +.codicon-diff-modified:before { content: "\eade" } +.codicon-diff-removed:before { content: "\eadf" } +.codicon-diff-renamed:before { content: "\eae0" } +.codicon-diff:before { content: "\eae1" } +.codicon-diff-sidebyside:before { content: "\eae1" } +.codicon-discard:before { content: "\eae2" } +.codicon-editor-layout:before { content: "\eae3" } +.codicon-empty-window:before { content: "\eae4" } +.codicon-exclude:before { content: "\eae5" } +.codicon-extensions:before { content: "\eae6" } +.codicon-eye-closed:before { content: "\eae7" } +.codicon-file-binary:before { content: "\eae8" } +.codicon-file-code:before { content: "\eae9" } +.codicon-file-media:before { content: "\eaea" } +.codicon-file-pdf:before { content: "\eaeb" } +.codicon-file-submodule:before { content: "\eaec" } +.codicon-file-symlink-directory:before { content: "\eaed" } +.codicon-file-symlink-file:before { content: "\eaee" } +.codicon-file-zip:before { content: "\eaef" } +.codicon-files:before { content: "\eaf0" } +.codicon-filter:before { content: "\eaf1" } +.codicon-flame:before { content: "\eaf2" } +.codicon-fold-down:before { content: "\eaf3" } +.codicon-fold-up:before { content: "\eaf4" } +.codicon-fold:before { content: "\eaf5" } +.codicon-folder-active:before { content: "\eaf6" } +.codicon-folder-opened:before { content: "\eaf7" } +.codicon-gear:before { content: "\eaf8" } +.codicon-gift:before { content: "\eaf9" } +.codicon-gist-secret:before { content: "\eafa" } +.codicon-gist:before { content: "\eafb" } +.codicon-git-commit:before { content: "\eafc" } +.codicon-git-compare:before { content: "\eafd" } +.codicon-compare-changes:before { content: "\eafd" } +.codicon-git-merge:before { content: "\eafe" } +.codicon-github-action:before { content: "\eaff" } +.codicon-github-alt:before { content: "\eb00" } +.codicon-globe:before { content: "\eb01" } +.codicon-grabber:before { content: "\eb02" } +.codicon-graph:before { content: "\eb03" } +.codicon-gripper:before { content: "\eb04" } +.codicon-heart:before { content: "\eb05" } +.codicon-home:before { content: "\eb06" } +.codicon-horizontal-rule:before { content: "\eb07" } +.codicon-hubot:before { content: "\eb08" } +.codicon-inbox:before { content: "\eb09" } +.codicon-issue-reopened:before { content: "\eb0b" } +.codicon-issues:before { content: "\eb0c" } +.codicon-italic:before { content: "\eb0d" } +.codicon-jersey:before { content: "\eb0e" } +.codicon-json:before { content: "\eb0f" } +.codicon-bracket:before { content: "\eb0f" } +.codicon-kebab-vertical:before { content: "\eb10" } +.codicon-key:before { content: "\eb11" } +.codicon-law:before { content: "\eb12" } +.codicon-lightbulb-autofix:before { content: "\eb13" } +.codicon-link-external:before { content: "\eb14" } +.codicon-link:before { content: "\eb15" } +.codicon-list-ordered:before { content: "\eb16" } +.codicon-list-unordered:before { content: "\eb17" } +.codicon-live-share:before { content: "\eb18" } +.codicon-loading:before { content: "\eb19" } +.codicon-location:before { content: "\eb1a" } +.codicon-mail-read:before { content: "\eb1b" } +.codicon-mail:before { content: "\eb1c" } +.codicon-markdown:before { content: "\eb1d" } +.codicon-megaphone:before { content: "\eb1e" } +.codicon-mention:before { content: "\eb1f" } +.codicon-milestone:before { content: "\eb20" } +.codicon-git-pull-request-milestone:before { content: "\eb20" } +.codicon-mortar-board:before { content: "\eb21" } +.codicon-move:before { content: "\eb22" } +.codicon-multiple-windows:before { content: "\eb23" } +.codicon-mute:before { content: "\eb24" } +.codicon-no-newline:before { content: "\eb25" } +.codicon-note:before { content: "\eb26" } +.codicon-octoface:before { content: "\eb27" } +.codicon-open-preview:before { content: "\eb28" } +.codicon-package:before { content: "\eb29" } +.codicon-paintcan:before { content: "\eb2a" } +.codicon-pin:before { content: "\eb2b" } +.codicon-play:before { content: "\eb2c" } +.codicon-run:before { content: "\eb2c" } +.codicon-plug:before { content: "\eb2d" } +.codicon-preserve-case:before { content: "\eb2e" } +.codicon-preview:before { content: "\eb2f" } +.codicon-project:before { content: "\eb30" } +.codicon-pulse:before { content: "\eb31" } +.codicon-question:before { content: "\eb32" } +.codicon-quote:before { content: "\eb33" } +.codicon-radio-tower:before { content: "\eb34" } +.codicon-reactions:before { content: "\eb35" } +.codicon-references:before { content: "\eb36" } +.codicon-refresh:before { content: "\eb37" } +.codicon-regex:before { content: "\eb38" } +.codicon-remote-explorer:before { content: "\eb39" } +.codicon-remote:before { content: "\eb3a" } +.codicon-remove:before { content: "\eb3b" } +.codicon-replace-all:before { content: "\eb3c" } +.codicon-replace:before { content: "\eb3d" } +.codicon-repo-clone:before { content: "\eb3e" } +.codicon-repo-force-push:before { content: "\eb3f" } +.codicon-repo-pull:before { content: "\eb40" } +.codicon-repo-push:before { content: "\eb41" } +.codicon-report:before { content: "\eb42" } +.codicon-request-changes:before { content: "\eb43" } +.codicon-rocket:before { content: "\eb44" } +.codicon-root-folder-opened:before { content: "\eb45" } +.codicon-root-folder:before { content: "\eb46" } +.codicon-rss:before { content: "\eb47" } +.codicon-ruby:before { content: "\eb48" } +.codicon-save-all:before { content: "\eb49" } +.codicon-save-as:before { content: "\eb4a" } +.codicon-save:before { content: "\eb4b" } +.codicon-screen-full:before { content: "\eb4c" } +.codicon-screen-normal:before { content: "\eb4d" } +.codicon-search-stop:before { content: "\eb4e" } +.codicon-server:before { content: "\eb50" } +.codicon-settings-gear:before { content: "\eb51" } +.codicon-settings:before { content: "\eb52" } +.codicon-shield:before { content: "\eb53" } +.codicon-smiley:before { content: "\eb54" } +.codicon-sort-precedence:before { content: "\eb55" } +.codicon-split-horizontal:before { content: "\eb56" } +.codicon-split-vertical:before { content: "\eb57" } +.codicon-squirrel:before { content: "\eb58" } +.codicon-star-full:before { content: "\eb59" } +.codicon-star-half:before { content: "\eb5a" } +.codicon-symbol-class:before { content: "\eb5b" } +.codicon-symbol-color:before { content: "\eb5c" } +.codicon-symbol-constant:before { content: "\eb5d" } +.codicon-symbol-enum-member:before { content: "\eb5e" } +.codicon-symbol-field:before { content: "\eb5f" } +.codicon-symbol-file:before { content: "\eb60" } +.codicon-symbol-interface:before { content: "\eb61" } +.codicon-symbol-keyword:before { content: "\eb62" } +.codicon-symbol-misc:before { content: "\eb63" } +.codicon-symbol-operator:before { content: "\eb64" } +.codicon-symbol-property:before { content: "\eb65" } +.codicon-wrench:before { content: "\eb65" } +.codicon-wrench-subaction:before { content: "\eb65" } +.codicon-symbol-snippet:before { content: "\eb66" } +.codicon-tasklist:before { content: "\eb67" } +.codicon-telescope:before { content: "\eb68" } +.codicon-text-size:before { content: "\eb69" } +.codicon-three-bars:before { content: "\eb6a" } +.codicon-thumbsdown:before { content: "\eb6b" } +.codicon-thumbsup:before { content: "\eb6c" } +.codicon-tools:before { content: "\eb6d" } +.codicon-triangle-down:before { content: "\eb6e" } +.codicon-triangle-left:before { content: "\eb6f" } +.codicon-triangle-right:before { content: "\eb70" } +.codicon-triangle-up:before { content: "\eb71" } +.codicon-twitter:before { content: "\eb72" } +.codicon-unfold:before { content: "\eb73" } +.codicon-unlock:before { content: "\eb74" } +.codicon-unmute:before { content: "\eb75" } +.codicon-unverified:before { content: "\eb76" } +.codicon-verified:before { content: "\eb77" } +.codicon-versions:before { content: "\eb78" } +.codicon-vm-active:before { content: "\eb79" } +.codicon-vm-outline:before { content: "\eb7a" } +.codicon-vm-running:before { content: "\eb7b" } +.codicon-watch:before { content: "\eb7c" } +.codicon-whitespace:before { content: "\eb7d" } +.codicon-whole-word:before { content: "\eb7e" } +.codicon-window:before { content: "\eb7f" } +.codicon-word-wrap:before { content: "\eb80" } +.codicon-zoom-in:before { content: "\eb81" } +.codicon-zoom-out:before { content: "\eb82" } +.codicon-list-filter:before { content: "\eb83" } +.codicon-list-flat:before { content: "\eb84" } +.codicon-list-selection:before { content: "\eb85" } +.codicon-selection:before { content: "\eb85" } +.codicon-list-tree:before { content: "\eb86" } +.codicon-debug-breakpoint-function-unverified:before { content: "\eb87" } +.codicon-debug-breakpoint-function:before { content: "\eb88" } +.codicon-debug-breakpoint-function-disabled:before { content: "\eb88" } +.codicon-debug-stackframe-active:before { content: "\eb89" } +.codicon-circle-small-filled:before { content: "\eb8a" } +.codicon-debug-stackframe-dot:before { content: "\eb8a" } +.codicon-terminal-decoration-mark:before { content: "\eb8a" } +.codicon-debug-stackframe:before { content: "\eb8b" } +.codicon-debug-stackframe-focused:before { content: "\eb8b" } +.codicon-debug-breakpoint-unsupported:before { content: "\eb8c" } +.codicon-symbol-string:before { content: "\eb8d" } +.codicon-debug-reverse-continue:before { content: "\eb8e" } +.codicon-debug-step-back:before { content: "\eb8f" } +.codicon-debug-restart-frame:before { content: "\eb90" } +.codicon-debug-alt:before { content: "\eb91" } +.codicon-call-incoming:before { content: "\eb92" } +.codicon-call-outgoing:before { content: "\eb93" } +.codicon-menu:before { content: "\eb94" } +.codicon-expand-all:before { content: "\eb95" } +.codicon-feedback:before { content: "\eb96" } +.codicon-git-pull-request-reviewer:before { content: "\eb96" } +.codicon-group-by-ref-type:before { content: "\eb97" } +.codicon-ungroup-by-ref-type:before { content: "\eb98" } +.codicon-account:before { content: "\eb99" } +.codicon-git-pull-request-assignee:before { content: "\eb99" } +.codicon-bell-dot:before { content: "\eb9a" } +.codicon-debug-console:before { content: "\eb9b" } +.codicon-library:before { content: "\eb9c" } +.codicon-output:before { content: "\eb9d" } +.codicon-run-all:before { content: "\eb9e" } +.codicon-sync-ignored:before { content: "\eb9f" } +.codicon-pinned:before { content: "\eba0" } +.codicon-github-inverted:before { content: "\eba1" } +.codicon-server-process:before { content: "\eba2" } +.codicon-server-environment:before { content: "\eba3" } +.codicon-pass:before { content: "\eba4" } +.codicon-issue-closed:before { content: "\eba4" } +.codicon-stop-circle:before { content: "\eba5" } +.codicon-play-circle:before { content: "\eba6" } +.codicon-record:before { content: "\eba7" } +.codicon-debug-alt-small:before { content: "\eba8" } +.codicon-vm-connect:before { content: "\eba9" } +.codicon-cloud:before { content: "\ebaa" } +.codicon-merge:before { content: "\ebab" } +.codicon-export:before { content: "\ebac" } +.codicon-graph-left:before { content: "\ebad" } +.codicon-magnet:before { content: "\ebae" } +.codicon-notebook:before { content: "\ebaf" } +.codicon-redo:before { content: "\ebb0" } +.codicon-check-all:before { content: "\ebb1" } +.codicon-pinned-dirty:before { content: "\ebb2" } +.codicon-pass-filled:before { content: "\ebb3" } +.codicon-circle-large-filled:before { content: "\ebb4" } +.codicon-circle-large:before { content: "\ebb5" } +.codicon-circle-large-outline:before { content: "\ebb5" } +.codicon-combine:before { content: "\ebb6" } +.codicon-gather:before { content: "\ebb6" } +.codicon-table:before { content: "\ebb7" } +.codicon-variable-group:before { content: "\ebb8" } +.codicon-type-hierarchy:before { content: "\ebb9" } +.codicon-type-hierarchy-sub:before { content: "\ebba" } +.codicon-type-hierarchy-super:before { content: "\ebbb" } +.codicon-git-pull-request-create:before { content: "\ebbc" } +.codicon-run-above:before { content: "\ebbd" } +.codicon-run-below:before { content: "\ebbe" } +.codicon-notebook-template:before { content: "\ebbf" } +.codicon-debug-rerun:before { content: "\ebc0" } +.codicon-workspace-trusted:before { content: "\ebc1" } +.codicon-workspace-untrusted:before { content: "\ebc2" } +.codicon-workspace-unknown:before { content: "\ebc3" } +.codicon-terminal-cmd:before { content: "\ebc4" } +.codicon-terminal-debian:before { content: "\ebc5" } +.codicon-terminal-linux:before { content: "\ebc6" } +.codicon-terminal-powershell:before { content: "\ebc7" } +.codicon-terminal-tmux:before { content: "\ebc8" } +.codicon-terminal-ubuntu:before { content: "\ebc9" } +.codicon-terminal-bash:before { content: "\ebca" } +.codicon-arrow-swap:before { content: "\ebcb" } +.codicon-copy:before { content: "\ebcc" } +.codicon-person-add:before { content: "\ebcd" } +.codicon-filter-filled:before { content: "\ebce" } +.codicon-wand:before { content: "\ebcf" } +.codicon-debug-line-by-line:before { content: "\ebd0" } +.codicon-inspect:before { content: "\ebd1" } +.codicon-layers:before { content: "\ebd2" } +.codicon-layers-dot:before { content: "\ebd3" } +.codicon-layers-active:before { content: "\ebd4" } +.codicon-compass:before { content: "\ebd5" } +.codicon-compass-dot:before { content: "\ebd6" } +.codicon-compass-active:before { content: "\ebd7" } +.codicon-azure:before { content: "\ebd8" } +.codicon-issue-draft:before { content: "\ebd9" } +.codicon-git-pull-request-closed:before { content: "\ebda" } +.codicon-git-pull-request-draft:before { content: "\ebdb" } +.codicon-debug-all:before { content: "\ebdc" } +.codicon-debug-coverage:before { content: "\ebdd" } +.codicon-run-errors:before { content: "\ebde" } +.codicon-folder-library:before { content: "\ebdf" } +.codicon-debug-continue-small:before { content: "\ebe0" } +.codicon-beaker-stop:before { content: "\ebe1" } +.codicon-graph-line:before { content: "\ebe2" } +.codicon-graph-scatter:before { content: "\ebe3" } +.codicon-pie-chart:before { content: "\ebe4" } +.codicon-bracket-dot:before { content: "\ebe5" } +.codicon-bracket-error:before { content: "\ebe6" } +.codicon-lock-small:before { content: "\ebe7" } +.codicon-azure-devops:before { content: "\ebe8" } +.codicon-verified-filled:before { content: "\ebe9" } +.codicon-newline:before { content: "\ebea" } +.codicon-layout:before { content: "\ebeb" } +.codicon-layout-activitybar-left:before { content: "\ebec" } +.codicon-layout-activitybar-right:before { content: "\ebed" } +.codicon-layout-panel-left:before { content: "\ebee" } +.codicon-layout-panel-center:before { content: "\ebef" } +.codicon-layout-panel-justify:before { content: "\ebf0" } +.codicon-layout-panel-right:before { content: "\ebf1" } +.codicon-layout-panel:before { content: "\ebf2" } +.codicon-layout-sidebar-left:before { content: "\ebf3" } +.codicon-layout-sidebar-right:before { content: "\ebf4" } +.codicon-layout-statusbar:before { content: "\ebf5" } +.codicon-layout-menubar:before { content: "\ebf6" } +.codicon-layout-centered:before { content: "\ebf7" } +.codicon-target:before { content: "\ebf8" } +.codicon-indent:before { content: "\ebf9" } +.codicon-record-small:before { content: "\ebfa" } +.codicon-error-small:before { content: "\ebfb" } +.codicon-terminal-decoration-error:before { content: "\ebfb" } +.codicon-arrow-circle-down:before { content: "\ebfc" } +.codicon-arrow-circle-left:before { content: "\ebfd" } +.codicon-arrow-circle-right:before { content: "\ebfe" } +.codicon-arrow-circle-up:before { content: "\ebff" } +.codicon-layout-sidebar-right-off:before { content: "\ec00" } +.codicon-layout-panel-off:before { content: "\ec01" } +.codicon-layout-sidebar-left-off:before { content: "\ec02" } +.codicon-blank:before { content: "\ec03" } +.codicon-heart-filled:before { content: "\ec04" } +.codicon-map:before { content: "\ec05" } +.codicon-map-horizontal:before { content: "\ec05" } +.codicon-fold-horizontal:before { content: "\ec05" } +.codicon-map-filled:before { content: "\ec06" } +.codicon-map-horizontal-filled:before { content: "\ec06" } +.codicon-fold-horizontal-filled:before { content: "\ec06" } +.codicon-circle-small:before { content: "\ec07" } +.codicon-bell-slash:before { content: "\ec08" } +.codicon-bell-slash-dot:before { content: "\ec09" } +.codicon-comment-unresolved:before { content: "\ec0a" } +.codicon-git-pull-request-go-to-changes:before { content: "\ec0b" } +.codicon-git-pull-request-new-changes:before { content: "\ec0c" } +.codicon-search-fuzzy:before { content: "\ec0d" } +.codicon-comment-draft:before { content: "\ec0e" } +.codicon-send:before { content: "\ec0f" } +.codicon-sparkle:before { content: "\ec10" } +.codicon-insert:before { content: "\ec11" } +.codicon-mic:before { content: "\ec12" } +.codicon-thumbsdown-filled:before { content: "\ec13" } +.codicon-thumbsup-filled:before { content: "\ec14" } +.codicon-coffee:before { content: "\ec15" } +.codicon-snake:before { content: "\ec16" } +.codicon-game:before { content: "\ec17" } +.codicon-vr:before { content: "\ec18" } +.codicon-chip:before { content: "\ec19" } +.codicon-piano:before { content: "\ec1a" } +.codicon-music:before { content: "\ec1b" } +.codicon-mic-filled:before { content: "\ec1c" } +.codicon-repo-fetch:before { content: "\ec1d" } +.codicon-copilot:before { content: "\ec1e" } +.codicon-lightbulb-sparkle:before { content: "\ec1f" } +.codicon-robot:before { content: "\ec20" } +.codicon-sparkle-filled:before { content: "\ec21" } +.codicon-diff-single:before { content: "\ec22" } +.codicon-diff-multiple:before { content: "\ec23" } +.codicon-surround-with:before { content: "\ec24" } +.codicon-share:before { content: "\ec25" } +.codicon-git-stash:before { content: "\ec26" } +.codicon-git-stash-apply:before { content: "\ec27" } +.codicon-git-stash-pop:before { content: "\ec28" } +.codicon-vscode:before { content: "\ec29" } +.codicon-vscode-insiders:before { content: "\ec2a" } +.codicon-code-oss:before { content: "\ec2b" } +.codicon-run-coverage:before { content: "\ec2c" } +.codicon-run-all-coverage:before { content: "\ec2d" } +.codicon-coverage:before { content: "\ec2e" } +.codicon-github-project:before { content: "\ec2f" } +.codicon-map-vertical:before { content: "\ec30" } +.codicon-fold-vertical:before { content: "\ec30" } +.codicon-map-vertical-filled:before { content: "\ec31" } +.codicon-fold-vertical-filled:before { content: "\ec31" } +.codicon-go-to-search:before { content: "\ec32" } +.codicon-percentage:before { content: "\ec33" } +.codicon-sort-percentage:before { content: "\ec33" } +.codicon-attach:before { content: "\ec34" } +.codicon-go-to-editing-session:before { content: "\ec35" } +.codicon-edit-session:before { content: "\ec36" } +.codicon-code-review:before { content: "\ec37" } +.codicon-copilot-warning:before { content: "\ec38" } +.codicon-python:before { content: "\ec39" } +.codicon-copilot-large:before { content: "\ec3a" } +.codicon-copilot-warning-large:before { content: "\ec3b" } +.codicon-keyboard-tab:before { content: "\ec3c" } +.codicon-copilot-blocked:before { content: "\ec3d" } +.codicon-copilot-not-connected:before { content: "\ec3e" } +.codicon-flag:before { content: "\ec3f" } +.codicon-lightbulb-empty:before { content: "\ec40" } +.codicon-symbol-method-arrow:before { content: "\ec41" } +.codicon-copilot-unavailable:before { content: "\ec42" } +.codicon-repo-pinned:before { content: "\ec43" } +.codicon-keyboard-tab-above:before { content: "\ec44" } +.codicon-keyboard-tab-below:before { content: "\ec45" } +.codicon-git-pull-request-done:before { content: "\ec46" } +.codicon-mcp:before { content: "\ec47" } +.codicon-extensions-large:before { content: "\ec48" } +.codicon-layout-panel-dock:before { content: "\ec49" } +.codicon-layout-sidebar-left-dock:before { content: "\ec4a" } +.codicon-layout-sidebar-right-dock:before { content: "\ec4b" } +.codicon-copilot-in-progress:before { content: "\ec4c" } +.codicon-copilot-error:before { content: "\ec4d" } +.codicon-copilot-success:before { content: "\ec4e" } +.codicon-chat-sparkle:before { content: "\ec4f" } +.codicon-search-sparkle:before { content: "\ec50" } +.codicon-edit-sparkle:before { content: "\ec51" } +.codicon-copilot-snooze:before { content: "\ec52" } +.codicon-send-to-remote-agent:before { content: "\ec53" } +.codicon-comment-discussion-sparkle:before { content: "\ec54" } +.codicon-chat-sparkle-warning:before { content: "\ec55" } +.codicon-chat-sparkle-error:before { content: "\ec56" } +.codicon-collection:before { content: "\ec57" } +.codicon-new-collection:before { content: "\ec58" } +.codicon-thinking:before { content: "\ec59" } +.codicon-build:before { content: "\ec5a" } +.codicon-comment-discussion-quote:before { content: "\ec5b" } +.codicon-cursor:before { content: "\ec5c" } +.codicon-eraser:before { content: "\ec5d" } +.codicon-file-text:before { content: "\ec5e" } +.codicon-quotes:before { content: "\ec60" } +.codicon-rename:before { content: "\ec61" } +.codicon-run-with-deps:before { content: "\ec62" } +.codicon-debug-connected:before { content: "\ec63" } +.codicon-strikethrough:before { content: "\ec64" } +.codicon-open-in-product:before { content: "\ec65" } +.codicon-index-zero:before { content: "\ec66" } +.codicon-agent:before { content: "\ec67" } +.codicon-edit-code:before { content: "\ec68" } +.codicon-repo-selected:before { content: "\ec69" } +.codicon-skip:before { content: "\ec6a" } +.codicon-merge-into:before { content: "\ec6b" } +.codicon-git-branch-changes:before { content: "\ec6c" } +.codicon-git-branch-staged-changes:before { content: "\ec6d" } +.codicon-git-branch-conflicts:before { content: "\ec6e" } +.codicon-git-branch:before { content: "\ec6f" } +.codicon-git-branch-create:before { content: "\ec6f" } +.codicon-git-branch-delete:before { content: "\ec6f" } +.codicon-search-large:before { content: "\ec70" } +.codicon-terminal-git-bash:before { content: "\ec71" } +.codicon-window-active:before { content: "\ec72" } +.codicon-forward:before { content: "\ec73" } +.codicon-download:before { content: "\ec74" } +.codicon-clockface:before { content: "\ec75" } +.codicon-unarchive:before { content: "\ec76" } +.codicon-session-in-progress:before { content: "\ec77" } +.codicon-collection-small:before { content: "\ec78" } +.codicon-vm-small:before { content: "\ec79" } +.codicon-cloud-small:before { content: "\ec7a" } +.codicon-add-small:before { content: "\ec7b" } +.codicon-remove-small:before { content: "\ec7c" } +.codicon-worktree-small:before { content: "\ec7d" } +.codicon-worktree:before { content: "\ec7e" } +.codicon-screen-cut:before { content: "\ec7f" } +.codicon-ask:before { content: "\ec80" } +.codicon-openai:before { content: "\ec81" } +.codicon-claude:before { content: "\ec82" } +.codicon-open-in-window:before { content: "\ec83" } +.codicon-new-session:before { content: "\ec84" } +.codicon-git-fetch:before { content: "\f101" } +.codicon-vm-pending:before { content: "\f102" } diff --git a/docs/assets/css/main.css b/docs/assets/css/main.css index 83b0d417..0c2b8cae 100644 --- a/docs/assets/css/main.css +++ b/docs/assets/css/main.css @@ -1,13 +1,7 @@ /* Operator Documentation Theme - * Color Palette (Refined): - * - Terracotta: #E05D44 (primary, backgrounds) - * - Cornflower: #6688AA (muted text, separators) - * - Cream: #F2EAC9 (accent, highlights) - * Green Scale: - * - Sage: #66AA99 (L1 - nav buttons) - * - Teal: #448880 (L2 - hover) - * - Deep Pine: #115566 (L3 - selected, text) - * - Midnight: #082226 (L4 - darkest) + * Brand palette + green scale are defined in tokens.css (single source of + * truth, shared with the embedded SPA). This file holds docs-site layout and + * component styling only, referencing those tokens via var(--...). */ /* Brand styling for inline Operator! mentions */ @@ -16,42 +10,13 @@ font-weight: 700; } +/* Brand palette + green scale + dark overrides live in tokens.css + * (the single source of truth, linked before this file in _includes/head.html). + * Only docs-site-specific tokens belong here. */ :root { - --color-salmon: #E05D44; - --color-salmon-dark: #6688AA; - --color-cream: #F2EAC9; - --color-coral: #E05D44; - --color-bg: #faf8f5; - --color-white: #ffffff; - - /* Green scale */ - --color-green-l1: #66AA99; - --color-green-l2: #448880; - --color-green-l3: #115566; - --color-green-l4: #082226; - --color-teal: #115566; - - --sidebar-width: 260px; --content-max-width: 800px; } -/* Dark theme */ -[data-theme="dark"] { - --color-salmon: #E05D44; - --color-salmon-dark: #88AABB; - --color-cream: #1a1d1e; - --color-coral: #FF7A66; - --color-bg: #0d1011; - --color-white: #1a1d1e; - - /* Green scale - adjusted for dark mode */ - --color-green-l1: #3d8a7a; - --color-green-l2: #5aa896; - --color-green-l3: #88ccbb; - --color-green-l4: #aaddcc; - --color-teal: #b8d4cc; -} - /* Reset */ *, *::before, *::after { box-sizing: border-box; @@ -114,7 +79,7 @@ body { margin: 8px 0 0; font-size: 0.75rem; font-weight: 700; - color: var(--color-salmon-dark); + color: var(--color-cornflower); } .sidebar-nav { @@ -328,6 +293,15 @@ summary.nav-item-row::-webkit-details-marker { filter: invert(1) brightness(1.1) sepia(0.3); } +/* Concept codicons on second-level nav items. Unlike the brand SVG s above, + * the codicon webfont inherits currentColor, so it needs no dark-mode filter. */ +.nav-codicon { + font-size: 14px; + margin-right: 6px; + vertical-align: -2px; + color: inherit; +} + /* Downloads nav link - highlighted */ .sidebar-nav .downloads-link { margin-top: 16px; @@ -340,7 +314,7 @@ summary.nav-item-row::-webkit-details-marker { } .sidebar-nav .downloads-link a:hover { - background: var(--color-salmon-dark); + background: var(--color-cornflower); } .sidebar-nav .downloads-link a.active { @@ -350,11 +324,11 @@ summary.nav-item-row::-webkit-details-marker { .sidebar-footer { margin-top: 32px; padding-top: 16px; - border-top: 1px solid var(--color-salmon-dark); + border-top: 1px solid var(--color-cornflower); } .sidebar-footer a { - color: var(--color-salmon-dark); + color: var(--color-cornflower); text-decoration: none; font-size: 0.875rem; } @@ -448,13 +422,47 @@ blockquote { padding: 12px 20px; border-left: 4px solid var(--color-salmon); background-color: var(--color-cream); - color: var(--color-salmon-dark); + color: var(--color-cornflower); } blockquote p:last-child { margin-bottom: 0; } +/* Badges */ +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 600; +} + +.badge.supported { + background-color: var(--color-green-l1); + color: var(--color-green-l4); +} + +.badge.recommended { + background-color: var(--color-green-l2); + color: var(--color-cream); +} + +.badge.alpha { + background-color: var(--color-salmon); + color: var(--color-cream); +} + +/* Alpha integration banner */ +.alpha-banner { + margin: 0.5em 0 1.5em; + padding: 12px 20px; + border-left: 4px solid var(--color-salmon); + background-color: var(--color-cream); + color: var(--color-cornflower); + font-style: italic; +} + /* Tables */ table { width: 100%; @@ -501,7 +509,7 @@ tr:hover { } .download-button:hover { - background: var(--color-salmon-dark); + background: var(--color-cornflower); color: var(--color-cream); text-decoration: none; } diff --git a/docs/assets/css/tokens.css b/docs/assets/css/tokens.css new file mode 100644 index 00000000..c0f6c69f --- /dev/null +++ b/docs/assets/css/tokens.css @@ -0,0 +1,50 @@ +/* Operator! brand tokens — SINGLE SOURCE OF TRUTH for web surfaces. + * + * Consumed by both the docs site (docs/assets/css/main.css) and the embedded + * React SPA (ui/src/index.css). Do not re-declare these brand vars in either + * consumer — change them here and both surfaces follow. + * + * The TUI (src/ui/*.rs, ANSI) and the VS Code webview (defers to the editor + * theme) deliberately do NOT consume this file — see operator/CLAUDE.md + * "Design & UI Consistency" and docs/design-system/. + * + * Palette: warm terracotta + cornflower + cream, over a green scale + * (sage -> teal -> deep pine -> midnight). Light default, dark via + * [data-theme="dark"]. + */ + +:root { + /* Brand palette */ + --color-salmon: #e05d44; /* Terracotta — primary brand */ + --color-cornflower: #6688aa; /* Muted blue — secondary text / separators */ + --color-cream: #f2eac9; /* Warm accent / highlights */ + --color-coral: #e05d44; /* Link / accent (alias of salmon in light) */ + --color-bg: #faf8f5; /* Page background */ + --color-white: #ffffff; + + /* Green scale */ + --color-green-l1: #66aa99; /* Sage — nav buttons */ + --color-green-l2: #448880; /* Teal — hover / success */ + --color-green-l3: #115566; /* Deep pine — selected / primary text */ + --color-green-l4: #082226; /* Midnight — darkest */ + --color-teal: #115566; /* Body text (== green-l3) */ + + /* Shared layout */ + --sidebar-width: 260px; +} + +[data-theme="dark"] { + --color-salmon: #e05d44; + --color-cornflower: #88aabb; + --color-cream: #1a1d1e; + --color-coral: #ff7a66; + --color-bg: #0d1011; + --color-white: #1a1d1e; + + /* Green scale — adjusted for dark mode */ + --color-green-l1: #3d8a7a; + --color-green-l2: #5aa896; + --color-green-l3: #88ccbb; + --color-green-l4: #aaddcc; + --color-teal: #b8d4cc; +} diff --git a/docs/assets/fonts/codicon.ttf b/docs/assets/fonts/codicon.ttf new file mode 100644 index 00000000..938db267 Binary files /dev/null and b/docs/assets/fonts/codicon.ttf differ diff --git a/docs/assets/icons/coder.svg b/docs/assets/icons/coder.svg new file mode 100644 index 00000000..a0acff3a --- /dev/null +++ b/docs/assets/icons/coder.svg @@ -0,0 +1 @@ +Coder \ No newline at end of file diff --git a/docs/assets/icons/cursor.svg b/docs/assets/icons/cursor.svg new file mode 100644 index 00000000..61303fbc --- /dev/null +++ b/docs/assets/icons/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/docs/assets/icons/docker.svg b/docs/assets/icons/docker.svg new file mode 100644 index 00000000..9f18dd92 --- /dev/null +++ b/docs/assets/icons/docker.svg @@ -0,0 +1 @@ +Docker diff --git a/docs/assets/icons/zed.svg b/docs/assets/icons/zed.svg new file mode 100644 index 00000000..02327fd1 --- /dev/null +++ b/docs/assets/icons/zed.svg @@ -0,0 +1 @@ +Zed Industries \ No newline at end of file diff --git a/docs/cli/index.md b/docs/cli/index.md index db57386c..e5213666 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -17,6 +17,7 @@ Operator provides both a TUI dashboard and CLI commands for queue management. | `-c, --config` | Config file path | | `-d, --debug` | Enable debug logging | | `-w, --web` | Start with web view enabled | +| `--ui` | Open the embedded web UI in a browser on launch | ## Commands @@ -105,6 +106,19 @@ Start the REST API server for issue type management | Argument/Option | Description | | --- | --- | | `-p, --port` | Port to listen on (default: 7008) | +| `--open` | Open the web UI in browser after server starts | + +### `mcp` + +Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, `JetBrains`, etc.) + +No additional arguments. + +### `acp` + +Run as an ACP agent over stdio (for use by Zed, `JetBrains`, Emacs `agent-shell`, etc.) + +No additional arguments. ### `setup` @@ -114,13 +128,18 @@ Initialize operator workspace (non-interactive by default) | --- | --- | | `-i, --interactive` | Launch TUI setup wizard instead of non-interactive setup | | `-C, --collection` | Collection preset: simple, dev-kanban, devops-kanban (default: simple) | -| `--backstage` | Enable backstage configuration | | `-f, --force` | Overwrite existing files | | `-w, --working-dir` | Working directory (parent of .tickets/) | | `-k, --kanban-provider` | Kanban provider to configure: jira, linear | | `-l, --llm-tool` | Preferred LLM tool: claude, codex, gemini | | `--skip-llm-detection` | Skip LLM tool detection | +### `workflow` + +Convert between operator issuetypes and other orchestration formats + +No additional arguments. + ## Environment Variables All configuration can be overridden via environment variables using the `OPERATOR_` prefix with `__` as the separator for nested config paths. @@ -155,8 +174,6 @@ All configuration can be overridden via environment variables using the `OPERATO | `OPERATOR_LAUNCH__MODE` | Agent launch mode (tmux or direct) | tmux | | `OPERATOR_LAUNCH__CONFIRM` | Require confirmation before launching agents | true | | `OPERATOR_TMUX__SESSION_PREFIX` | Prefix for tmux session names | operator | -| `OPERATOR_BACKSTAGE__PORT` | Port for the Backstage web server | 3000 | -| `OPERATOR_BACKSTAGE__AUTO_START` | Automatically start Backstage server with TUI | false | | `OPERATOR_LLM_TOOLS__ENABLED` | Enable LLM tool allowlist/denylist functionality | true | | `OPERATOR_LLM_TOOLS__ALLOWED` | Comma-separated list of allowed LLM tools (empty = all allowed) | | | `OPERATOR_LLM_TOOLS__DENIED` | Comma-separated list of denied LLM tools | | @@ -229,13 +246,6 @@ All configuration can be overridden via environment variables using the `OPERATO | --- | --- | --- | | `OPERATOR_TMUX__SESSION_PREFIX` | Prefix for tmux session names | operator | -### Backstage - -| Variable | Description | Default | -| --- | --- | --- | -| `OPERATOR_BACKSTAGE__PORT` | Port for the Backstage web server | 3000 | -| `OPERATOR_BACKSTAGE__AUTO_START` | Automatically start Backstage server with TUI | false | - ### LLM Tools | Variable | Description | Default | diff --git a/docs/configuration/index.md b/docs/configuration/index.md index ce02fc3e..122f02d1 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -24,7 +24,6 @@ Operator configuration is stored in `.tickets/operator/config.toml`. | `[api]` | External API integration settings | | `[logging]` | Log level and output configuration | | `[tmux]` | Tmux integration settings | -| `[backstage]` | Backstage server integration | | `[llm_tools]` | LLM CLI tool detection and providers | ## `[agents]` @@ -35,6 +34,7 @@ Agent lifecycle, parallelism, and health monitoring | --- | --- | --- | --- | | `max_parallel` * | `integer` | 5 | | | `cores_reserved` * | `integer` | 1 | | +| `max_agents_per_repo` | `integer` | - | Maximum concurrent agents per project/repo (default: 1). Requires `git.use_worktrees` = true when > 1 to avoid conflicts. | | `health_check_interval` * | `integer` | 30 | | | `generation_timeout_secs` | `integer` | 300 | Timeout in seconds for each agent generation (default: 300 = 5 min) | | `sync_interval` | `integer` | 60 | Interval in seconds between ticket-session syncs (default: 60) | @@ -133,22 +133,6 @@ Tmux integration settings | --- | --- | --- | --- | | `config_generated` | `boolean` | - | Whether custom tmux config has been generated | -## `[backstage]` - -Backstage server integration - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `enabled` | `boolean` | true | Whether Backstage integration is enabled | -| `display` | `boolean` | - | Whether to show Backstage in the Connections status section | -| `port` | `integer` | 7007 | Port for the Backstage server | -| `auto_start` | `boolean` | false | Auto-start Backstage server when TUI launches | -| `subpath` | `string` | backstage | Subdirectory within `state_path` for Backstage installation | -| `branding_subpath` | `string` | branding | Subdirectory within backstage path for branding customization | -| `release_url` | `string` | - | Base URL for downloading backstage-server binary | -| `local_binary_path` | `string` \| `null` | - | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` | -| `branding` | → `BrandingConfig` | - | Branding and theming configuration | - ## `[llm_tools]` LLM CLI tool detection and providers @@ -172,6 +156,7 @@ model_servers = [] [agents] max_parallel = 5 cores_reserved = 1 +max_agents_per_repo = 1 health_check_interval = 30 generation_timeout_secs = 300 sync_interval = 60 @@ -278,27 +263,6 @@ detection_complete = false [llm_tools.skill_directory_overrides] -[backstage] -enabled = true -display = false -port = 7007 -auto_start = false -subpath = "backstage" -branding_subpath = "branding" -release_url = "https://github.com/untra/operator/releases/latest/download" - -[backstage.branding] -app_title = "Operator Portal" -org_name = "Operator" -logo_path = "logo.svg" - -[backstage.branding.colors] -primary = "#cc6c55" -secondary = "#114145" -accent = "#f4dbb7" -warning = "#d46048" -muted = "#8a4a3a" - [rest_api] enabled = true port = 7008 @@ -330,6 +294,16 @@ timeout_secs = 3 [relay] auto_inject_mcp = false +[mcp] +http_enabled = true +stdio_advertised = true +expose_ticket_write_tools = false +external_servers = [] + +[acp] +stdio_advertised = true +max_concurrent_sessions = 8 + ``` ## Configuration Files @@ -351,5 +325,5 @@ Any configuration option can be overridden via environment variables. **Examples**: - `OPERATOR_AGENTS__MAX_PARALLEL=2` - `OPERATOR_LOGGING__LEVEL=debug` -- `OPERATOR_BACKSTAGE__PORT=8080` +- `OPERATOR_TMUX__ENABLED=true` diff --git a/docs/design-system/index.md b/docs/design-system/index.md new file mode 100644 index 00000000..375812de --- /dev/null +++ b/docs/design-system/index.md @@ -0,0 +1,144 @@ +--- +title: "Design System" +description: "Operator's brand palette, design tokens, and the consistency rules each rendering surface follows." +layout: doc +--- + +Operator! presents one brand across four +rendering surfaces. This page is the human-readable companion to the brand +tokens — it explains *intent* the raw `:root` block can't, and records the rules +each surface follows so the look stays consistent as the codebase grows. + +## Source of truth + +All brand colors live in one file: **`docs/assets/css/tokens.css`**. It declares +the palette and the dark-mode overrides as CSS custom properties, and nothing +else re-declares them. Change a color there and both web surfaces follow. + +``` +docs/assets/css/tokens.css ← single source of truth (:root + [data-theme="dark"]) + ├─ docs site: linked in _includes/head.html, before main.css + └─ embedded SPA: @import "../../docs/assets/css/tokens.css" in ui/src/index.css +``` + +## Palette + +| Token | Light | Role | +|-------|-------|------| +| `--color-salmon` | `#e05d44` | Terracotta — primary brand, headings accents, CTAs | +| `--color-cornflower` | `#6688aa` | Muted blue — secondary/muted text, separators | +| `--color-cream` | `#f2eac9` | Warm accent / highlight surfaces | +| `--color-coral` | `#e05d44` | Links / accents (alias of salmon in light mode) | +| `--color-bg` | `#faf8f5` | Page background | +| `--color-white` | `#ffffff` | Base surface | +| `--color-green-l1` | `#66aa99` | Sage — nav buttons | +| `--color-green-l2` | `#448880` | Teal — hover / success | +| `--color-green-l3` | `#115566` | Deep pine — selected / primary text | +| `--color-green-l4` | `#082226` | Midnight — darkest | +| `--color-teal` | `#115566` | Body text (equals green-l3) | + +Dark mode (`[data-theme="dark"]`) keeps salmon constant, brightens coral, and +inverts the green scale. See `tokens.css` for the exact dark values. + +> **Naming note:** `--color-cornflower` was previously named +> `--color-salmon-dark`, which lied about its value (`#6688aa` is blue, not a +> dark salmon). It was renamed in lockstep across both web surfaces. + +## Semantic tokens (SPA only) + +The embedded SPA layers app-specific semantic tokens on top of the brand +palette, in `ui/src/index.css`. The docs site doesn't need these. Components +reference the semantic token, never the raw brand color: + +`--surface`, `--surface-alt`, `--border`, `--text`, `--text-muted`, +`--danger` / `--danger-bg`, `--warning` / `--warning-bg`, +`--success` / `--success-bg`, plus layout tokens `--radius-sm|--radius|--radius-lg` +and `--font-sans|--font-mono`. + +## The four surfaces + +Each surface gets the rule that fits it — they are deliberately not styled +identically. + +| Surface | Where | Rule | +|---------|-------|------| +| **Docs site** (Jekyll) | `docs/assets/css/main.css` | Links `tokens.css`; style with `var(--...)`, never raw hex. | +| **Embedded SPA** (Vite/React) | `ui/src/index.css` + `*.module.css` | Imports `tokens.css`; uses semantic tokens, never raw hex. | +| **Ratatui TUI** | `src/ui/*.rs` | Terminal can't render hex — map a semantic **role to ANSI** (danger→Red, success→Green, warning→Yellow, focus→Cyan). | +| **VS Code webview** (MUI) | `vscode-extension/webview-ui/` | Defers to the VS Code host theme; brand only as accents via `OPERATOR_BRAND`. Never overrides the editor theme. | + +## Concept icons (codicons) + +Each high-level Operator concept gets **one icon** so the same idea reads the same +across surfaces. The vocabulary is [codicons](https://github.com/microsoft/vscode-codicons) +— the icon set VS Code uses — chosen because the VS Code extension already renders +its tree with codicon `ThemeIcon`s. This table is the **single source of truth**: +consult it (and update it) whenever you give a concept an icon. + +Each surface follows it by convention — there is no shared runtime registry: + +- **Embedded SPA** reads it via `ui/src/concepts.ts` (`CONCEPTS[key].icon`), rendered + by `ui/src/components/ConceptIcon.tsx`. The font is imported once in `main.tsx`. +- **Docs site** reads it via the `codicon:` field on items in + `_data/navigation.yml`, emitted by `_includes/sidebar.html`. The vendored webfont + is linked from `_includes/head.html` (`assets/css/codicon.css` + `assets/fonts/codicon.ttf`). +- **VS Code extension** already uses codicon `ThemeIcon`s directly. + +This is **additive** — distinct from the issue-type `glyph`→icon map in +`vscode-extension/src/issuetype-service.ts` and the `glyph_for_key`/`color_for_key` +helpers in `src/templates/mod.rs` (documented below). It follows the same +"central key → presentation" pattern, keyed by section concept. + +| Concept (key) | Icon | codicon | SPA | Docs | +|---------------|:----:|---------|:---:|:----:| +| dashboard | | `dashboard` | ✓ | | +| queue | | `list-ordered` | ✓ | | +| config (Configuration) | | `settings-gear` | ✓ | ✓ | +| connections | | `plug` | ✓ | | +| kanban | | `layout` | ✓ | ✓ | +| llm (LLM Tools) | | `sparkle` | ✓ | ✓ | +| model-servers | | `server` | ✓ | | +| git | | `git-branch` | ✓ | | +| issuetypes (Issue Types) | | `issues` | ✓ | ✓ | +| delegators | | `rocket` | ✓ | | +| projects (Managed Projects) | | `project` | ✓ | | +| agents | | `robot` | | ✓ | +| tickets | | `note` | | ✓ | +| taxonomy | | `type-hierarchy` | | ✓ | +| schemas | | `bracket` | | ✓ | +| shortcuts | | `keyboard` | | ✓ | +| cli | | `terminal` | | ✓ | +| design-system | | `symbol-color` | | ✓ | + +Keys match the `SectionId` serde renames in `src/ui/status_panel.rs` (and the SPA's +section ids). Every codicon name is unique — the Kanban-vs-Managed-Projects collision +was resolved as kanban→`layout`, projects→`project`. + +> **Attribution:** codicon **icons** are licensed [CC-BY-4.0](https://github.com/microsoft/vscode-codicons/blob/main/LICENSE); +> the font/CSS **code** is MIT, © Microsoft. The webfont is vendored under +> `docs/assets/` and bundled into the SPA via `@vscode/codicons`. + +## Issue type glyphs & colors + +Issue type color + glyph are defined once in the collection JSON schemas and +read through `color_for_key` / `glyph_for_key` in `src/templates/mod.rs`. Reuse +those helpers — do not re-hardcode the mapping in new UI. + +| Type | Glyph | Color | +|------|-------|-------| +| FEAT | `*` | green | +| FIX | `#` | magenta | +| TASK | `>` | cyan | +| SPIKE | `?` | blue | +| INV | `!` | yellow | +| ASSESS | `~` | magenta | +| SYNC | `@` | blue | +| INIT | `%` | green | + +## Priority colors + +Priority maps to a single role across surfaces: P0 → danger (red), P1 → warning +(yellow/gold), P2 → sage green, P3 → muted border. In the SPA these resolve to +`--danger` / `--warning` / `--color-green-l1` / `--border` +(`ui/src/components/KanbanBoard.module.css`); in the TUI to the nearest ANSI +(`src/ui/panels.rs`). diff --git a/docs/downloads/index.md b/docs/downloads/index.md index 87baa527..c28d8c7f 100644 --- a/docs/downloads/index.md +++ b/docs/downloads/index.md @@ -1,6 +1,6 @@ --- title: "Downloads" -description: "Download Operator! binaries for macOS, Linux, and Windows, including the optional Backstage server." +description: "Download Operator! binaries for macOS, Linux, and Windows." layout: doc --- @@ -35,19 +35,6 @@ For headless servers, CI/CD pipelines, or advanced workflows, download the CLI b | Linux | x86_64 | [operator-linux-x86_64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-linux-x86_64)
sha256:{{ site.data.checksums.operator.linux_x86_64 }} | | Windows | x86_64 | [operator-windows-x86_64.exe]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-windows-x86_64.exe)
sha256:{{ site.data.checksums.operator.windows_x86_64 }} | -## Backstage Server - -Optional companion server for web-based project monitoring dashboard. - -> **Note:** macOS code signing and notarization for the Backstage server binary is temporarily paused. The `backstage-server-bun-darwin-arm64` build is **unsigned**, so macOS Gatekeeper may block it on first launch ("cannot verify developer"). To run it, either remove the quarantine attribute (`xattr -d com.apple.quarantine ./backstage-server-bun-darwin-arm64`) or allow it via **System Settings → Privacy & Security**. The main `operator` binary is still signed and notarized as normal. - -| Platform | Architecture | Download | -|----------|--------------|----------| -| macOS | ARM64 | [backstage-server-bun-darwin-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-darwin-arm64) *(unsigned)*
sha256:{{ site.data.checksums.backstage.darwin_arm64 }} | -| Linux | ARM64 | [backstage-server-bun-linux-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-arm64)
sha256:{{ site.data.checksums.backstage.linux_arm64 }} | -| Linux | x64 | [backstage-server-bun-linux-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-x64)
sha256:{{ site.data.checksums.backstage.linux_x64 }} | -| Windows | x64 | [backstage-server-bun-windows-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-windows-x64)
sha256:{{ site.data.checksums.backstage.windows_x64 }} | - ## All Releases [View all releases on GitHub]({{ site.github.repo }}/releases) diff --git a/docs/getting-started/model-servers/index.md b/docs/getting-started/model-servers/index.md index 7ff36783..a73b7581 100644 --- a/docs/getting-started/model-servers/index.md +++ b/docs/getting-started/model-servers/index.md @@ -98,29 +98,49 @@ operator launch \ | `claude` | Only via bridge | Claude CLI speaks Anthropic protocol. Run `claude-code-router` (or similar) at a port and point `base_url` at that bridge with `kind = "anthropic-api"`. | | `gemini` | Only via bridge | Same story as claude; use `litellm-proxy` or similar. | +## Listing models + +Each kind knows how to enumerate the models its endpoint serves (ollama `/api/tags`, +OpenAI-protocol `/v1/models`, Anthropic `/v1/models`, Gemini `/v1beta/models`). The +same probe doubles as a reachability check — there is no separate "test connection". + +- **REST**: `GET /api/v1/model-servers/{name}/models` returns `{ reachable, models[], error? }`. +- **VS Code**: expand a server in the status tree to see its live model list (or an + "unreachable" line with the error). + ## REST API ``` -GET /api/v1/model-servers # list (declared + implicit builtins) -GET /api/v1/model-servers/{name} # fetch by name -POST /api/v1/model-servers # create -DELETE /api/v1/model-servers/{name} # delete (implicit builtins are protected) +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/{name} # fetch by name +GET /api/v1/model-servers/{name}/models # live model list + reachability +POST /api/v1/model-servers # create +PUT /api/v1/model-servers/{name} # update (implicit builtins are protected) +DELETE /api/v1/model-servers/{name} # delete (implicit builtins are protected) ``` -## What ships in this release +## How env injection works -This release lays down the infrastructure: +When a delegator (or the ad-hoc `--model-server` flag) resolves to a model server, +operator exports the server's connection env into the spawned agent's command script, +keyed by the server's protocol: -- Data model and config schema -- REST CRUD endpoints -- TUI and VS Code status tree sections -- `operator launch --model-server ` flag (validated, resolved through the normal delegator path) +| 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` | +| `google-api` | `GOOGLE_GEMINI_BASE_URL` | `GEMINI_API_KEY` | -**What's explicitly deferred:** +The API key is injected **by reference**, not by value: if `api_key_env = "MY_KEY"`, +the script exports `OPENAI_API_KEY="${MY_KEY}"`, which the shell resolves from the +inherited environment at run time. The secret is never written into the on-disk +command script. Any `extra_env` entries are exported verbatim and take precedence. +Implicit builtins with no `base_url` inject nothing — the vendor-default path is unchanged. -- Automatic ollama detection during `operator setup` -- Environment-variable injection on spawn (`OPENAI_BASE_URL=…` etc.) -- Full walkthroughs for wiring up claude/gemini via a bridge -- Bundled bridge binaries +**Still deferred:** -Those ship in the next release. In the meantime: declare your model server, attach it to a delegator, and set the appropriate `*_BASE_URL` env var in your shell before invoking operator — the spawned agent inherits it. +- Automatic ollama detection during `operator setup` +- Full walkthroughs / bundled binaries for wiring up claude/gemini via a bridge +- A primary "select server" UI action (selecting a server is currently inert; use a + delegator's `model_server` to bind one) diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md index 0132224c..d52b60b9 100644 --- a/docs/getting-started/platform-support.md +++ b/docs/getting-started/platform-support.md @@ -13,12 +13,12 @@ This page is the authoritative reference for what Operator supports on each oper | Feature | macOS | Linux | Windows | |---------|-------|-------|---------| | Session manager: VS Code Extension | ✅ | ✅ | ✅ Required | +| Session manager: Cursor | ✅ | ✅ | ✅ | | Session manager: tmux | ✅ | ✅ | ❌ | | Session manager: cmux | ✅ | ❌ | ❌ | | Session manager: Zellij | ✅ | ✅ | ❌ | | Relay hub (multi-agent) | ✅ | ✅ | ❌ | | `opr8r relay` subcommand | ✅ | ✅ | ❌ | -| Backstage Server | ✅ | ✅ | ❌ | | Native OS notifications | ✅ | ✅ | ⚠️ | | Kanban: Jira Cloud | ✅ | ✅ | ✅ | | Kanban: Linear | ✅ | ✅ | ✅ | @@ -40,7 +40,6 @@ Windows is a supported download target. The step-wrapper (`opr8r`), REST API, ka | Feature | Status | Reason | Workaround | |---------|--------|--------|------------| | Relay hub / `opr8r relay` | ❌ Blocked | Requires Unix domain sockets (`tokio::net::unix`), which are not available on Windows | None yet. Planned: named-pipe or TCP-loopback transport in a future release | -| Backstage Server | ❌ Blocked | Not yet ported to Windows | None. No timeline committed | | tmux session manager | ❌ N/A | tmux does not run on Windows | Use the VS Code Extension | | cmux session manager | ❌ N/A | cmux is macOS-specific | Use the VS Code Extension | | Zellij session manager | ❌ N/A | Zellij does not run on Windows | Use the VS Code Extension | diff --git a/docs/getting-started/platforms/coder.md b/docs/getting-started/platforms/coder.md new file mode 100644 index 00000000..e367d7fc --- /dev/null +++ b/docs/getting-started/platforms/coder.md @@ -0,0 +1,123 @@ +--- +title: "Coder" +description: "Run Operator as a background service in Coder workspaces via Terraform module." +layout: doc +--- + +# Coder + +Supported + +Run [Operator](https://operator.untra.io) as a background REST API server inside your [Coder](https://coder.com) workspace. The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. + +**Registry:** [`registry.coder.com/untra/operator/coder`](https://registry.coder.com/modules/operator) + +## Usage + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +### Custom configuration + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + port = 7008 + max_parallel_agents = 4 + session_wrapper = "tmux" +} +``` + +### Full TOML override + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + config_toml = <<-EOT + [rest_api] + enabled = true + port = 7008 + + [agents] + max_parallel = 4 + health_check_interval = 30 + + [sessions] + wrapper = "tmux" + + [[delegators]] + name = "default" + tool = "claude-code" + model = "sonnet" + EOT +} +``` + +## Variables + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_id` | `string` | (required) | The ID of a Coder agent | +| `port` | `number` | `7008` | The port for the operator REST API server | +| `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | +| `slug` | `string` | `"operator"` | Application slug | +| `install_version` | `string` | `"{{ site.version }}"` | GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binary into | +| `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | +| `config_toml` | `string` | `""` | Raw TOML config (written verbatim instead of auto-generated config) | +| `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | +| `session_wrapper` | `string` | `"tmux"` | Session wrapper type (`tmux`, `cmux`, or `zellij`) | +| `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | +| `order` | `number` | `null` | Position of the app in the Coder dashboard (lower = first) | +| `group` | `string` | `null` | Group that this app belongs to | +| `offline` | `bool` | `false` | Skip downloading; requires pre-installed binary at `install_prefix` | +| `use_cached` | `bool` | `false` | Use cached binary if present, otherwise download | + +## Prerequisites + +The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +## Coder Workspace Context + +Coder automatically injects environment variables into every workspace that Operator can reference in ticket templates and agent prompts: + +- `CODER_WORKSPACE_NAME` — workspace identifier +- `CODER_WORKSPACE_OWNER` — workspace owner username +- `CODER_AGENT_TOKEN` — agent authentication token + +No Operator configuration is needed to access these — they are ambient in the workspace environment. + +## How It Works + +1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) +2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary) +3. Generates a TOML configuration file (or uses the provided `config_toml`) +4. Starts `operator api` as a background process +5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds + +## Troubleshooting + +### Binary download fails + +1. Check that the `install_version` matches a valid [GitHub release tag](https://github.com/untra/operator/releases) +2. Verify the workspace has internet access (or use `offline = true` with a pre-installed binary) +3. Check logs at the configured `log_path` (default: `/tmp/operator.log`) + +### Healthcheck timeout + +1. Verify the port is not already in use: `ss -tlnp | grep 7008` +2. Check operator logs: `cat /tmp/operator.log` +3. Ensure the session wrapper (tmux by default) is installed in the workspace image + +### Port conflicts + +Change the `port` variable to an unused port. Remember to update any other services or extensions that connect to the Operator API. diff --git a/docs/getting-started/platforms/docker.md b/docs/getting-started/platforms/docker.md new file mode 100644 index 00000000..46bcd95a --- /dev/null +++ b/docs/getting-started/platforms/docker.md @@ -0,0 +1,132 @@ +--- +title: "Docker" +description: "Run Operator from an official multi-arch Docker image, mounting your projects root as the workspace." +layout: doc +--- + +# Docker + +Supported + +Run [Operator](https://operator.untra.io) from an official multi-arch container image. The image bundles the Operator binary (with the embedded web dashboard and REST API) on a slim Debian base, plus the `git` and `tmux` substrate Operator needs to launch agents. Mount your projects root into the container and Operator treats it as the workspace. + +**Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) — `linux/amd64` and `linux/arm64`. + +> **Not to be confused with** the `[docker]` config section, which makes Operator launch each *agent* inside a container. This page is about distributing *Operator itself* as a container image. The two are orthogonal. + +## Usage + +Run the TUI dashboard from your projects root: + +```bash +docker run --rm -v $(pwd):/op:rw -it untra/operator +``` + +The image sets `WORKDIR /op`, so the mounted directory becomes Operator's working +directory. If it contains `.tickets/operator/config.toml`, Operator loads it as implied +startup; otherwise it falls back to built-in defaults. + +Subcommands are appended after the image name. Run as a background REST API service: + +```bash +docker run --rm -v $(pwd):/op:rw -p 127.0.0.1:7008:7008 untra/operator api +``` + +> **Security:** the REST API is **unauthenticated**, sends permissive CORS headers, and +> exposes mutating endpoints (launching agents, editing config). The publish above binds it +> to loopback (`127.0.0.1`) only — a bare `-p 7008:7008` would expose it on every host +> interface. Do not publish it to untrusted networks; if you need remote access, put it +> behind an authenticating reverse proxy. + +Any Operator subcommand works the same way: + +```bash +docker run --rm -v $(pwd):/op:rw untra/operator queue # show queue +docker run --rm -v $(pwd):/op:rw untra/operator setup # initialize workspace +``` + +Pin a specific version instead of `latest`: + +```bash +docker run --rm -v $(pwd):/op:rw -it untra/operator:{{ site.version }} +``` + +## What's in the image + +| Included | Purpose | +|----------|---------| +| `operator` binary | The CLI/TUI/REST API, with the web dashboard embedded | +| `git` | Branch and commit operations for ticket work | +| `tmux` | Default session wrapper Operator uses to spawn agent sessions | +| `ca-certificates` | TLS for LLM, kanban, and git provider APIs | + +**Not included: the LLM CLI and its auth.** Operator launches agents via an LLM tool +(`claude`, `codex`, or `gemini`) that you supply. Two ways to provide it: + +1. **Derived image** — extend the official image with your tool of choice: + + ```dockerfile + FROM untra/operator + # The base image defaults to the non-root `operator` user; switch to root to + # install, then drop back. + USER root + RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ + && npm install -g @anthropic-ai/claude-code \ + && rm -rf /var/lib/apt/lists/* + USER operator + ``` + +2. **Mount + env vars** — mount an already-installed, authenticated CLI from the host + and pass credentials. The container runs as uid 1000 with `$HOME=/home/operator`: + + ```bash + docker run --rm -v $(pwd):/op:rw \ + -v $HOME/.claude:/home/operator/.claude \ + -e ANTHROPIC_API_KEY \ + -it untra/operator + ``` + +## Implied startup + +Operator reads `.tickets/operator/config.toml` relative to its working directory. Because +the image uses `WORKDIR /op` and you mount your projects root at `/op`, an existing config +is picked up automatically — no flags required. Run from a directory without one and +Operator uses its built-in defaults. + +A global override at `~/.config/operator/config.toml` (i.e. +`/home/operator/.config/operator/config.toml` in the container) also works if you mount it. + +## Prerequisites + +- Docker (with `buildx` for multi-arch hosts, which is the default on modern Docker). +- The host directory you mount at `/op` should be your **projects root** — the directory + containing your code repositories and `.tickets/` — so Operator can start work in the + right place. +- Use `-it` for the interactive TUI; omit it for one-shot subcommands and `api`. + +## Troubleshooting + +### Permission denied writing to the mounted directory + +The container runs as the unprivileged `operator` user (uid 1000), so it can only write into +`/op` (state, prompts, logs) if the mounted host directory is writable by uid 1000. On a +typical single-user Linux host your uid is already 1000 and it just works; otherwise either +make the directory writable (e.g. `chmod -R g+w` with a matching group, or `chown`) or run the +container as your own uid: + +```bash +docker run --rm -v $(pwd):/op:rw --user $(id -u):$(id -g) -it untra/operator +``` + +When overriding `--user`, the chosen uid has no entry in the image, so point `$HOME` at a +writable location for any tool config (e.g. add `-e HOME=/op`). + +### The dashboard renders as garbled output + +The TUI needs an interactive TTY. Include `-it` in the `docker run` invocation. + +### Agents fail to launch + +The base image intentionally omits the LLM CLI. Confirm your derived image or mount +provides an authenticated `claude` / `codex` / `gemini` on `PATH` inside the container +(`docker run --rm --entrypoint sh untra/operator -c 'command -v claude'`). diff --git a/docs/getting-started/platforms/index.md b/docs/getting-started/platforms/index.md new file mode 100644 index 00000000..d9a2a13f --- /dev/null +++ b/docs/getting-started/platforms/index.md @@ -0,0 +1,16 @@ +--- +title: "Supported Workspace Platforms" +description: "Workspace platform integrations for running Operator in remote development environments." +layout: doc +--- + +# Supported Workspace Platforms + +Operator can run as a background service in remote workspace platforms, providing API access and dashboard visibility without requiring a local terminal. + +## Available Options + +| Option | Status | Notes | +|--------|--------|-------| +| [Coder](/getting-started/platforms/coder/) | Supported | Terraform module, runs Operator as background API server with dashboard | +| [Docker](/getting-started/platforms/docker/) | Supported | Official multi-arch image (`untra/operator`); container is the workspace, mount your projects root at `/op` | diff --git a/docs/getting-started/prerequisites.md b/docs/getting-started/prerequisites.md index 6e6adcfe..396e0c0a 100644 --- a/docs/getting-started/prerequisites.md +++ b/docs/getting-started/prerequisites.md @@ -80,7 +80,6 @@ dnf install tmux Windows support has some limitations: - **Session Manager**: VS Code Extension is required (tmux not available) -- **Backstage Server**: Not supported on Windows - **Notifications**: Native notifications are planned; currently logs only See [Platform Support & Limitations](/getting-started/platform-support/) for a complete list of OS-specific gaps, their reasons, and planned resolutions. @@ -95,10 +94,6 @@ At least one AI coding agent should be installed: - [Codex](/getting-started/agents/codex/) - [Gemini](/getting-started/agents/gemini/) -### Backstage Server (macOS/Linux only) - -For centralized project management across multiple repositories, see [Backstage Server Setup](/getting-started/backstage-server/). Note: Not supported on Windows. - ### Kanban Integration For issue tracking integration, configure: diff --git a/docs/getting-started/sessions/cursor.md b/docs/getting-started/sessions/cursor.md new file mode 100644 index 00000000..dd501afc --- /dev/null +++ b/docs/getting-started/sessions/cursor.md @@ -0,0 +1,113 @@ +--- +title: "Cursor" +description: "Cursor IDE integration via the operator-terminals VS Code extension and Cursor's native MCP support." +layout: doc +--- + +# Cursor + +Supported + +Install from OpenVSX + +[Cursor](https://www.cursor.com) is a fork of VS Code that natively runs most VS Code extensions and adds its own MCP configuration surface. The same `operator-terminals` extension that powers the VS Code session manager runs in Cursor unmodified — the only difference is **where** the extension writes the MCP server entry. + +## Two Integration Paths + +### 1. Extension Path (recommended) + +Install `operator-terminals` from OpenVSX (Cursor's default extension registry) or via a downloaded `.vsix`, then run `Operator: Connect MCP Server` from the command palette. Inside Cursor, the extension writes the operator MCP entry to `~/.cursor/mcp.json` instead of VS Code's workspace `mcp.servers` — Cursor's MCP UI only surfaces user-scope entries, so writing workspace config would have no effect. + +This path also gives you the sidebar (Queue / In Progress / Completed), styled terminals, and the rest of the extension's features. + +### 2. Native MCP Path (no extension) + +If you don't want the extension, you can register operator directly with Cursor: + +1. From operator's TUI, navigate to the **Connections** panel and trigger the `WriteAndOpenMcpClientConfig` action with `client: "cursor"`. This writes a ready-to-paste snippet to `/operator/mcp/cursor.json` and opens it in your editor. +2. Copy the snippet's `mcpServers.operator` block into your `~/.cursor/mcp.json`, merging with any existing entries. +3. Restart Cursor or toggle the server in **Cursor Settings → MCP**. + +## Installation + +### From OpenVSX (Cursor's default) + +1. Open Cursor. +2. Open the Extensions sidebar (`Cmd+Shift+X` / `Ctrl+Shift+X`). +3. Search for **Operator Terminals**. +4. Click **Install**. + +### Manual Installation + +1. Download the `.vsix` from [GitHub releases](https://github.com/untra/operator/releases){:target="_blank"}. +2. In Cursor, open Extensions, click the `…` menu, and pick **Install from VSIX…**. + +## Configuration + +The extension shares the same configuration as the VS Code session manager. Settings are scoped under `operator.*` in Cursor's `settings.json`. + +| Setting | Default | Description | +|---------|---------|-------------| +| `operator.webhookPort` | `7009` | Port for webhook server | +| `operator.autoStart` | `true` | Start server on Cursor launch | +| `operator.terminalPrefix` | `op-` | Prefix for managed terminal names | +| `operator.ticketsDir` | `.tickets` | Path to tickets directory | +| `operator.apiUrl` | `http://localhost:7008` | Operator REST API URL | + +## MCP Integration + +Cursor's `~/.cursor/mcp.json` uses the `mcpServers` shape with `command`, `args`, and `cwd` — stdio only. SSE-style URL entries are not honored by Cursor's MCP UI. + +### Requirements + +- Operator must be running with `[mcp].stdio_advertised = true` in its config (this is the default). Restart the operator API after toggling. +- The operator binary path written into `~/.cursor/mcp.json` is taken from the running operator process — if you reinstall or move the binary, re-run `Operator: Connect MCP Server` to refresh the path. + +### Merge Semantics + +The extension's Cursor-write path is additive: + +- Any existing top-level keys in `~/.cursor/mcp.json` are preserved. +- Any existing `mcpServers.*` entries (other servers you've registered) are preserved. +- Only `mcpServers.operator` is set or overwritten on each run. + +If `~/.cursor/mcp.json` exists but contains malformed JSON, the extension shows an error and refuses to overwrite the file — fix or remove it manually and re-run the command. + +## Commands + +Same set as the VS Code session manager — access via the command palette (`Cmd+Shift+P`): + +| Command | Description | +|---------|-------------| +| `Operator: Start Webhook Server` | Start the webhook server | +| `Operator: Stop Webhook Server` | Stop the webhook server | +| `Operator: Connect MCP Server` | Register operator with Cursor's MCP (writes `~/.cursor/mcp.json`) | +| `Operator: Launch Ticket` | Launch a ticket in a new terminal | +| `Operator: Show Server Status` | Display server status | + +## Requirements + +- Cursor with stable MCP support (Cursor 0.42 or later; verify `Settings → MCP` is present). +- Operator built from `main` or a release that includes the MCP stdio plan (`operator mcp` subcommand and `[mcp].stdio_advertised` config flag). + +## Troubleshooting + +### `Operator MCP stdio entrypoint is not advertised` + +The descriptor your operator API returned did not include a `stdio` field, so Cursor cannot register the server. Set `[mcp].stdio_advertised = true` in your operator config and restart the API, then re-run `Operator: Connect MCP Server`. + +### Editing `~/.cursor/mcp.json` manually + +Open the file in any text editor. To remove the operator entry, delete the `mcpServers.operator` key (preserve the rest of the file's structure). Save and restart Cursor or toggle the server in **Cursor Settings → MCP**. + +### Switching between VS Code and Cursor on the same workspace + +If you previously ran `Operator: Connect MCP Server` in stock VS Code on the same workspace, the workspace `mcp.servers.operator` entry still exists in that workspace's `.vscode/settings.json`. Running the command again in Cursor adds a user-scope entry in `~/.cursor/mcp.json`. **Both work in their respective hosts**; the extension intentionally does not delete the workspace entry from inside Cursor (doing so would surprise you when you reopen the workspace in VS Code). + +If you want a clean slate in only one of the two editors, delete the entry from the file Cursor or VS Code doesn't see (`.vscode/settings.json` for VS Code, `~/.cursor/mcp.json` for Cursor). + +### Cursor doesn't see the operator server after running the command + +1. Check that `~/.cursor/mcp.json` exists and contains `mcpServers.operator` with `command`, `args`, and `cwd`. +2. Restart Cursor or open **Cursor Settings → MCP** and toggle the operator server off and on. +3. Confirm the `command` path in the JSON is executable (`ls -l ` and run it manually with ` mcp` — it should hang waiting for JSON-RPC on stdin, which is correct). diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md index 204134dd..98d544f6 100644 --- a/docs/getting-started/sessions/index.md +++ b/docs/getting-started/sessions/index.md @@ -13,9 +13,11 @@ Operator supports multiple session management backends for running AI coding age | Option | Status | Notes | |--------|--------|-------| | [VS Code Extension](/getting-started/sessions/vscode/) | Recommended (Preferred) | Integrated terminals in VS Code, works on all platforms | +| [Cursor](/getting-started/sessions/cursor/) | Supported | Cursor IDE (VS Code fork); same extension, native MCP via `~/.cursor/mcp.json` | | [tmux](/getting-started/sessions/tmux/) | Supported | Terminal multiplexer, ideal for headless/server environments | | [cmux](/getting-started/sessions/cmux/) | Supported | macOS terminal multiplexer, manages workspaces within cmux | | [Zellij](/getting-started/sessions/zellij/) | Supported | Terminal workspace manager, tab-per-agent model (macOS/Linux) | +| [Zed](/getting-started/sessions/zed/) | Supported | Zed editor extension; MCP context server, ACP agent, slash commands | ## How It Works @@ -30,6 +32,8 @@ Session managers provide: **VS Code Extension** is the recommended choice for most users. It provides an integrated experience with ticket management, color-coded terminals, and works seamlessly on macOS, Linux, and Windows without additional setup. +**Cursor** is the right choice if you already use Cursor as your daily editor. The same `operator-terminals` extension installs from OpenVSX, and `Operator: Connect MCP Server` writes to Cursor's native `~/.cursor/mcp.json` (stdio) so the operator tool surface shows up in Cursor's MCP UI and chat. + **tmux** remains an excellent choice for headless/server environments, SSH sessions, and users who prefer terminal-based workflows. It's particularly useful for remote servers where VS Code may not be available. **cmux** is a macOS-native option for users already working within cmux. It launches agents as cmux windows or workspaces. Requires macOS and that Operator is running inside a cmux session. diff --git a/docs/getting-started/sessions/zed.md b/docs/getting-started/sessions/zed.md new file mode 100644 index 00000000..d41556b5 --- /dev/null +++ b/docs/getting-started/sessions/zed.md @@ -0,0 +1,96 @@ +--- +title: "Zed" +description: "Zed editor integration for Operator via MCP context server, ACP agent, and slash commands." +layout: doc +--- + +# Zed + +Alpha + +
+This integration is in alpha and may have limited functionality or incomplete support. +
+ +The [Zed](https://zed.dev) extension for Operator provides three integration layers: an MCP context server for tools and resources, an ACP agent server for delegated prompts, and slash commands for quick operations. + +## Prerequisites + +- [Operator](https://operator.untra.io) installed and on PATH +- Zed editor + +## Installation + +1. Open Zed +2. Open the Extensions panel (**Zed > Extensions** or `Cmd+Shift+X`) +3. Search for **Operator** +4. Click **Install** + +## Setup + +### MCP Context Server (automatic) + +After installing the extension, Zed automatically registers `operator mcp` as a context server. All Operator tools appear in the Agent Panel: + +- `operator_health` / `operator_status` — system health +- `operator_list_tickets` — query queue, in-progress, completed tickets +- `operator_claim_ticket` / `operator_complete_ticket` / `operator_return_to_queue` — ticket lifecycle +- `operator_create_ticket` — create tickets from templates +- `operator_list_issue_types` / `operator_list_collections` / `operator_list_skills` — registry queries +- `operator_launch_ticket` / `operator_pause_queue` / `operator_resume_queue` — queue operations +- `operator_approve_agent` / `operator_reject_agent` — review actions + +If the `operator` binary is not found, the extension shows installation instructions. + +### ACP Agent Server (one-time setup) + +Run `/op-setup-agent` in the AI assistant to generate the config snippet, then paste it into `~/.config/zed/settings.json`. After restarting Zed, Operator appears as an agent in the Agent Panel — you can send prompts that flow through ACP to a Claude Code delegator. + +## Slash Commands + +| Command | Description | +|---------|-------------| +| `/op-status` | Show Operator health and status | +| `/op-queue` | List tickets in queue | +| `/op-launch TICKET-ID` | Launch a ticket | +| `/op-active` | List active agents | +| `/op-completed` | List recently completed tickets | +| `/op-ticket TICKET-ID` | Show ticket details | +| `/op-pause` | Pause queue processing | +| `/op-resume` | Resume queue processing | +| `/op-sync` | Sync kanban collections | +| `/op-approve AGENT-ID` | Approve agent review | +| `/op-reject AGENT-ID REASON` | Reject agent review | +| `/op-setup-agent` | Generate ACP agent server config | + +Commands with arguments support tab-completion from live API data. + +## How It Works + +Operator integrates with Zed through three communication channels: + +- **MCP Context Server** — Runs `operator mcp` via stdio. Tools and ticket resources appear natively in the Agent Panel without additional configuration. +- **ACP Agent Server** — Runs `operator acp` via stdio. Prompts sent to the Operator agent flow through a delegator to Claude Code, with streaming output back to Zed. +- **Slash Commands** — Communicate with the Operator REST API for quick status checks and operations directly in the AI assistant. + +## Configuration + +The Operator binary must be on your PATH. The extension also checks common install locations (`/usr/local/bin`, `/opt/homebrew/bin`). The REST API URL for slash commands defaults to `http://localhost:7008`. + +## Troubleshooting + +### MCP tools not appearing + +1. Verify Operator is on PATH: `which operator` +2. Test MCP server: `operator mcp` (should wait for JSON-RPC input) +3. Check Zed's extension logs: **View > Output > Extensions** + +### Slash commands failing + +1. Check that Operator API is running: `operator api` +2. Verify connectivity: `curl http://localhost:7008/api/v1/health` + +### Extension not appearing + +1. Open the Extensions panel and verify Operator is listed as installed +2. Try **Zed > Extensions > Reload** or restart Zed diff --git a/docs/index.md b/docs/index.md index c510c627..4ce46118 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,11 +41,13 @@ These are tools that comparable and aspirational for Operator - [agtx](https://github.com/fynnfluegge/agtx) - [claude-relay](https://github.com/Innestic/claude-relay) -- [gastown](https://github.com/gastownhall/gastown) +- [kanbots](https://github.com/Innestic/claude-relay) +- [agentic website](https://agenticmotherfucking.website/) ## Similar, but Worse: These are tools that are almost as good, and are inspirational, but just don't quite cut it: - [Ralph Code](https://github.com/frankbria/ralph-claude-code) -- [Vibe Kanban](https://www.vibekanban.com/) \ No newline at end of file +- [Vibe Kanban](https://www.vibekanban.com/) +- [gastown](https://github.com/gastownhall/gastown) \ No newline at end of file diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 2da1698b..6a7e353b 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -91,31 +91,6 @@ "skill_directory_overrides": {} } }, - "backstage": { - "$ref": "#/$defs/BackstageConfig", - "default": { - "enabled": true, - "display": false, - "port": 7007, - "auto_start": false, - "subpath": "backstage", - "branding_subpath": "branding", - "release_url": "https://github.com/untra/operator/releases/latest/download", - "local_binary_path": null, - "branding": { - "app_title": "Operator Portal", - "org_name": "Operator", - "logo_path": "logo.svg", - "colors": { - "primary": "#cc6c55", - "secondary": "#114145", - "accent": "#f4dbb7", - "warning": "#d46048", - "muted": "#8a4a3a" - } - } - } - }, "rest_api": { "$ref": "#/$defs/RestApiConfig", "default": { @@ -174,6 +149,32 @@ "$ref": "#/$defs/ModelServer" }, "default": [] + }, + "relay": { + "description": "Relay MCP injection configuration", + "$ref": "#/$defs/RelayConfig", + "default": { + "auto_inject_mcp": false + } + }, + "mcp": { + "description": "Model Context Protocol (MCP) server configuration", + "$ref": "#/$defs/McpConfig", + "default": { + "http_enabled": true, + "stdio_advertised": true, + "expose_ticket_write_tools": false, + "external_servers": [] + } + }, + "acp": { + "description": "Agent Client Protocol (ACP) agent configuration", + "$ref": "#/$defs/AcpConfig", + "default": { + "stdio_advertised": true, + "default_delegator": null, + "max_concurrent_sessions": 8 + } } }, "required": [ @@ -199,6 +200,13 @@ "format": "uint", "minimum": 0 }, + "max_agents_per_repo": { + "description": "Maximum concurrent agents per project/repo (default: 1).\nRequires `git.use_worktrees` = true when > 1 to avoid conflicts.", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 1 + }, "health_check_interval": { "type": "integer", "format": "uint64", @@ -1044,140 +1052,6 @@ } } }, - "BackstageConfig": { - "description": "Backstage integration configuration", - "type": "object", - "properties": { - "enabled": { - "description": "Whether Backstage integration is enabled", - "type": "boolean", - "default": true - }, - "display": { - "description": "Whether to show Backstage in the Connections status section", - "type": "boolean", - "default": false - }, - "port": { - "description": "Port for the Backstage server", - "type": "integer", - "format": "uint16", - "minimum": 0, - "maximum": 65535, - "default": 7007 - }, - "auto_start": { - "description": "Auto-start Backstage server when TUI launches", - "type": "boolean", - "default": false - }, - "subpath": { - "description": "Subdirectory within `state_path` for Backstage installation", - "type": "string", - "default": "backstage" - }, - "branding_subpath": { - "description": "Subdirectory within backstage path for branding customization", - "type": "string", - "default": "branding" - }, - "release_url": { - "description": "Base URL for downloading backstage-server binary", - "type": "string", - "default": "https://github.com/untra/operator/releases/latest/download" - }, - "local_binary_path": { - "description": "Optional local path to backstage-server binary\nIf set, this is used instead of downloading from `release_url`", - "type": [ - "string", - "null" - ], - "default": null - }, - "branding": { - "description": "Branding and theming configuration", - "$ref": "#/$defs/BrandingConfig", - "default": { - "app_title": "Operator Portal", - "org_name": "Operator", - "logo_path": "logo.svg", - "colors": { - "primary": "#cc6c55", - "secondary": "#114145", - "accent": "#f4dbb7", - "warning": "#d46048", - "muted": "#8a4a3a" - } - } - } - } - }, - "BrandingConfig": { - "description": "Branding configuration for Backstage portal", - "type": "object", - "properties": { - "app_title": { - "description": "App title shown in header", - "type": "string", - "default": "Operator Portal" - }, - "org_name": { - "description": "Organization name", - "type": "string", - "default": "Operator" - }, - "logo_path": { - "description": "Path to logo SVG (relative to branding path)", - "type": [ - "string", - "null" - ], - "default": null - }, - "colors": { - "description": "Theme colors (uses Operator defaults if not set)", - "$ref": "#/$defs/ThemeColors", - "default": { - "primary": "#cc6c55", - "secondary": "#114145", - "accent": "#f4dbb7", - "warning": "#d46048", - "muted": "#8a4a3a" - } - } - } - }, - "ThemeColors": { - "description": "Theme color configuration for Backstage\nDefault colors match Operator's tmux theme", - "type": "object", - "properties": { - "primary": { - "description": "Primary/accent color (default: salmon #cc6c55)", - "type": "string", - "default": "#cc6c55" - }, - "secondary": { - "description": "Secondary color (default: dark teal #114145)", - "type": "string", - "default": "#114145" - }, - "accent": { - "description": "Accent/highlight color (default: cream #f4dbb7)", - "type": "string", - "default": "#f4dbb7" - }, - "warning": { - "description": "Warning/error color (default: coral #d46048)", - "type": "string", - "default": "#d46048" - }, - "muted": { - "description": "Muted text color (default: darker salmon #8a4a3a)", - "type": "string", - "default": "#8a4a3a" - } - } - }, "RestApiConfig": { "description": "REST API server configuration", "type": "object", @@ -1405,6 +1279,11 @@ "type": "string" }, "default": {} + }, + "bidirectional": { + "description": "When true, operator pushes status changes and activity logs back to this kanban project.\nTicket state changes (todo→doing, doing→done) and step completions with delegator info\nare reflected upstream. Default: false.", + "type": "boolean", + "default": false } } }, @@ -1605,6 +1484,14 @@ "null" ], "default": null + }, + "operator_relay": { + "description": "Override global relay auto-inject MCP setting per-delegator (None = use global setting)", + "type": [ + "boolean", + "null" + ], + "default": null } } }, @@ -1657,6 +1544,121 @@ "name", "kind" ] + }, + "RelayConfig": { + "description": "Relay MCP injection configuration", + "type": "object", + "properties": { + "auto_inject_mcp": { + "description": "When true, automatically inject the relay MCP server for all delegators.\nWhen false (default), relay injection is opt-in per delegator.", + "type": "boolean", + "default": false + } + } + }, + "McpConfig": { + "description": "Model Context Protocol (MCP) server configuration", + "type": "object", + "properties": { + "http_enabled": { + "description": "Whether to mount MCP HTTP/SSE endpoints on the REST API server.\nToggling requires an API restart (no hot-swap of the axum router).", + "type": "boolean", + "default": true + }, + "stdio_advertised": { + "description": "Whether the descriptor endpoint advertises the `operator mcp` stdio\ncommand. Set to false on multi-tenant/remote deployments where clients\nshouldn't spawn local subprocesses.", + "type": "boolean", + "default": true + }, + "expose_ticket_write_tools": { + "description": "Whether to expose ticket-mutating tools (claim, complete, return-to-queue,\ncreate) over MCP. Defaults to `false` because any MCP client can call them.", + "type": "boolean", + "default": false + }, + "external_servers": { + "description": "External MCP servers to inject into spawned agent sessions.\nEach entry produces a separate `--mcp-config` file alongside the\nrelay config when launching Claude Code agents.", + "type": "array", + "items": { + "$ref": "#/$defs/ExternalMcpServer" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "ExternalMcpServer": { + "description": "An external MCP server to inject into spawned agent sessions.\n\nValues in `command`, `args`, and `env` support `${VAR}` interpolation,\nexpanded at spawn time from the operator process environment.\n\nWhen `discover_from` is set, operator reads an MCP server spec from that\nJSON sidecar file at spawn time. The sidecar must contain a top-level\n`mcpServer` object with `command`, `args`, and `env` fields. If the file\nis absent and `command` is empty, the server is silently skipped.", + "type": "object", + "properties": { + "name": { + "description": "Server name used as the key in the `mcpServers` JSON object\n(e.g., \"kanbots\"). Must be unique across all external servers.", + "type": "string" + }, + "command": { + "description": "Command to execute. Supports `${VAR}` interpolation.", + "type": "string", + "default": "" + }, + "args": { + "description": "Command arguments. Each element supports `${VAR}` interpolation.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "env": { + "description": "Environment variables passed to the MCP server process.\nValues support `${VAR}` interpolation.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "enabled": { + "description": "Whether this server is enabled. Allows disabling without removing config.", + "type": "boolean", + "default": true + }, + "discover_from": { + "description": "Path to a JSON sidecar discovery file. Relative paths resolve from\nthe project directory. The sidecar must contain `{ \"mcpServer\": { ... } }`.\nWhen the file exists, its `mcpServer` spec is used verbatim (overriding\n`command`/`args`/`env`). When absent and `command` is empty, the server\nis silently skipped.", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "name" + ] + }, + "AcpConfig": { + "description": "Agent Client Protocol (ACP) agent configuration.\n\nOperator runs as an ACP agent over stdio when editors (Zed, `JetBrains`,\nEmacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session\nmaps to an in-progress ACP ticket and a delegator subprocess.", + "type": "object", + "properties": { + "stdio_advertised": { + "description": "Whether the dashboard advertises the `operator acp` stdio entrypoint\n(and editor-config snippet actions). Set to false on machines that\nshouldn't be used as ACP agents.", + "type": "boolean", + "default": true + }, + "default_delegator": { + "description": "Name of the delegator (from `[[delegators]]`) to use for ACP prompts.\nIf unset or not found, falls back to the operator's default delegator\nresolution.", + "type": [ + "string", + "null" + ], + "default": null + }, + "max_concurrent_sessions": { + "description": "Maximum number of concurrent ACP sessions. New `session/new` requests\nbeyond this limit are rejected with a JSON-RPC error.", + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 8 + } + }, + "additionalProperties": false } } } \ No newline at end of file diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 7ff5b7fb..c7d97c1c 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -42,13 +42,15 @@ JSON Schema for the Operator configuration file (`config.toml`). | `tmux` | → `TmuxConfig` | No | | | `sessions` | → `SessionsConfig` | No | Session wrapper configuration (tmux, vscode, or cmux) | | `llm_tools` | → `LlmToolsConfig` | No | | -| `backstage` | → `BackstageConfig` | No | | | `rest_api` | → `RestApiConfig` | No | | | `git` | → `GitConfig` | No | | | `kanban` | → `KanbanConfig` | No | Kanban provider configuration for syncing issues from Jira, Linear, etc. | | `version_check` | → `VersionCheckConfig` | No | Version check configuration for automatic update notifications | | `delegators` | `array` | No | Agent delegator configurations for autonomous ticket launching | | `model_servers` | `array` | No | User-declared model servers (ollama, lmstudio, any OpenAI-compat host). Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. | +| `relay` | → `RelayConfig` | No | Relay MCP injection configuration | +| `mcp` | → `McpConfig` | No | Model Context Protocol (MCP) server configuration | +| `acp` | → `AcpConfig` | No | Agent Client Protocol (ACP) agent configuration | ## Type Definitions @@ -58,6 +60,7 @@ JSON Schema for the Operator configuration file (`config.toml`). | --- | --- | --- | --- | | `max_parallel` | `integer` | Yes | | | `cores_reserved` | `integer` | Yes | | +| `max_agents_per_repo` | `integer` | No | Maximum concurrent agents per project/repo (default: 1). Requires `git.use_worktrees` = true when > 1 to avoid conflicts. | | `health_check_interval` | `integer` | Yes | | | `generation_timeout_secs` | `integer` | No | Timeout in seconds for each agent generation (default: 300 = 5 min) | | `sync_interval` | `integer` | No | Interval in seconds between ticket-session syncs (default: 60) | @@ -349,46 +352,6 @@ Per-tool skill directory overrides | `global` | `array` | No | Additional global skill directories | | `project` | `array` | No | Additional project-relative skill directories | -### BackstageConfig - -Backstage integration configuration - -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `enabled` | `boolean` | No | Whether Backstage integration is enabled | -| `display` | `boolean` | No | Whether to show Backstage in the Connections status section | -| `port` | `integer` | No | Port for the Backstage server | -| `auto_start` | `boolean` | No | Auto-start Backstage server when TUI launches | -| `subpath` | `string` | No | Subdirectory within `state_path` for Backstage installation | -| `branding_subpath` | `string` | No | Subdirectory within backstage path for branding customization | -| `release_url` | `string` | No | Base URL for downloading backstage-server binary | -| `local_binary_path` | `string` \| `null` | No | Optional local path to backstage-server binary If set, this is used instead of downloading from `release_url` | -| `branding` | → `BrandingConfig` | No | Branding and theming configuration | - -### BrandingConfig - -Branding configuration for Backstage portal - -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `app_title` | `string` | No | App title shown in header | -| `org_name` | `string` | No | Organization name | -| `logo_path` | `string` \| `null` | No | Path to logo SVG (relative to branding path) | -| `colors` | → `ThemeColors` | No | Theme colors (uses Operator defaults if not set) | - -### ThemeColors - -Theme color configuration for Backstage -Default colors match Operator's tmux theme - -| Property | Type | Required | Description | -| --- | --- | --- | --- | -| `primary` | `string` | No | Primary/accent color (default: salmon #cc6c55) | -| `secondary` | `string` | No | Secondary color (default: dark teal #114145) | -| `accent` | `string` | No | Accent/highlight color (default: cream #f4dbb7) | -| `warning` | `string` | No | Warning/error color (default: coral #d46048) | -| `muted` | `string` | No | Muted text color (default: darker salmon #8a4a3a) | - ### RestApiConfig REST API server configuration @@ -479,6 +442,7 @@ Per-project/team sync configuration for a kanban provider | `sync_statuses` | `array` | No | Workflow statuses to sync (empty = default/first status only) | | `collection_name` | `string` \| `null` | No | Optional `IssueTypeCollection` name this project maps to. Not required for kanban onboarding or sync. | | `type_mappings` | `object` | No | Explicit mapping: kanban issue type ID → operator issue type key (e.g., TASK, FEAT, FIX). Multiple kanban types can map to the same operator template. | +| `bidirectional` | `boolean` | No | When true, operator pushes status changes and activity logs back to this kanban project. Ticket state changes (todo→doing, doing→done) and step completions with delegator info are reflected upstream. Default: false. | ### LinearConfig @@ -557,6 +521,7 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override. | `docker` | `boolean` \| `null` | No | Run in docker container (None = use global `launch.docker.enabled`) | | `prompt_prefix` | `string` \| `null` | No | Prompt text to prepend before the generated step prompt | | `prompt_suffix` | `string` \| `null` | No | Prompt text to append after the generated step prompt | +| `operator_relay` | `boolean` \| `null` | No | Override global relay auto-inject MCP setting per-delegator (None = use global setting) | ### ModelServer @@ -579,3 +544,57 @@ in config. | `extra_env` | `object` | No | Additional environment variables set when spawning agents that use this server | | `display_name` | `string` \| `null` | No | Optional display name for UI | +### RelayConfig + +Relay MCP injection configuration + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `auto_inject_mcp` | `boolean` | No | When true, automatically inject the relay MCP server for all delegators. When false (default), relay injection is opt-in per delegator. | + +### McpConfig + +Model Context Protocol (MCP) server configuration + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `http_enabled` | `boolean` | No | Whether to mount MCP HTTP/SSE endpoints on the REST API server. Toggling requires an API restart (no hot-swap of the axum router). | +| `stdio_advertised` | `boolean` | No | Whether the descriptor endpoint advertises the `operator mcp` stdio command. Set to false on multi-tenant/remote deployments where clients shouldn't spawn local subprocesses. | +| `expose_ticket_write_tools` | `boolean` | No | Whether to expose ticket-mutating tools (claim, complete, return-to-queue, create) over MCP. Defaults to `false` because any MCP client can call them. | +| `external_servers` | `array` | No | External MCP servers to inject into spawned agent sessions. Each entry produces a separate `--mcp-config` file alongside the relay config when launching Claude Code agents. | + +### ExternalMcpServer + +An external MCP server to inject into spawned agent sessions. + +Values in `command`, `args`, and `env` support `${VAR}` interpolation, +expanded at spawn time from the operator process environment. + +When `discover_from` is set, operator reads an MCP server spec from that +JSON sidecar file at spawn time. The sidecar must contain a top-level +`mcpServer` object with `command`, `args`, and `env` fields. If the file +is absent and `command` is empty, the server is silently skipped. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | Server name used as the key in the `mcpServers` JSON object (e.g., "kanbots"). Must be unique across all external servers. | +| `command` | `string` | No | Command to execute. Supports `${VAR}` interpolation. | +| `args` | `array` | No | Command arguments. Each element supports `${VAR}` interpolation. | +| `env` | `object` | No | Environment variables passed to the MCP server process. Values support `${VAR}` interpolation. | +| `enabled` | `boolean` | No | Whether this server is enabled. Allows disabling without removing config. | +| `discover_from` | `string` \| `null` | No | Path to a JSON sidecar discovery file. Relative paths resolve from the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. When the file exists, its `mcpServer` spec is used verbatim (overriding `command`/`args`/`env`). When absent and `command` is empty, the server is silently skipped. | + +### AcpConfig + +Agent Client Protocol (ACP) agent configuration. + +Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`, +Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session +maps to an in-progress ACP ticket and a delegator subprocess. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `stdio_advertised` | `boolean` | No | Whether the dashboard advertises the `operator acp` stdio entrypoint (and editor-config snippet actions). Set to false on machines that shouldn't be used as ACP agents. | +| `default_delegator` | `string` \| `null` | No | Name of the delegator (from `[[delegators]]`) to use for ACP prompts. If unset or not found, falls back to the operator's default delegator resolution. | +| `max_concurrent_sessions` | `integer` | No | Maximum number of concurrent ACP sessions. New `session/new` requests beyond this limit are rejected with a JSON-RPC error. | + diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md index 8668cc44..9a3247e3 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -169,6 +169,7 @@ Schema definition for a lifecycle step | `multi_model_config` | object | No | Configuration for multi-model steps (required when `type=multi_model`) | | `multi_prompt_config` | object | No | Configuration for multi-prompt steps (required when `type=multi_prompt`) | | `matrixed_config` | object | No | Configuration for matrixed steps (required when type=matrixed) | +| `pipeline_config` | object | No | Configuration for pipeline steps (required when type=pipeline) | ### Definition: StepTypeTag @@ -383,3 +384,38 @@ Configuration for matrixed work output steps (N x M delegators x prompts) Output format for matrixed steps +### Definition: PipelineConfig + +Configuration for pipeline steps: iterate a list of items through ordered +stages with no barrier (each item flows through all stages independently). + +The step graph stays linear — a pipeline step still has exactly one +`next_step`. The fan-out (N items x M stages) lives entirely inside this one +step; iteration is an intra-step concern, never a step-to-step edge. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `item_source` | → `ItemSource` | Yes | Where the iterated items come from. | +| `stages` | `array` | Yes | Ordered mini-steps each item flows through. Must be non-empty. | + +### Definition: ItemSource + +Where a pipeline's iterated items come from. The variant determines *when* +the list resolves: export-time (a literal array → static fan-out width in +the compiled graph) vs runtime (an identifier → symbolic width). + +### Definition: PipelineStage + +A single stage in a pipeline — deliberately flat (not a recursive +`StepSchema`): "prompt + optional agent/model/schema" only. It has no +`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the +step-graph linearity question. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `prompt` | `string` | Yes | Handlebars prompt. The per-item value is appended as a JS binding at export time (see `workflow_gen::export`), not via a Handlebars variable. | +| `agent` | `string` \| `null` | No | Optional agent/delegator name (falls back to the step/issuetype agent). | +| `model` | `string` \| `null` | No | Optional model pin (emitted as `{ model: … }`). | +| `jsonSchema` | object | No | Optional structured-output JSON schema (emitted as `{ schema: … }`). | +| `label` | `string` \| `null` | No | Optional display label override (defaults to `:`). | + diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 1f3f22e0..f2151de0 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -10,16 +10,188 @@ "license": { "name": "MIT" }, - "version": "0.1.30" + "version": "0.2.0" }, "paths": { + "/api/v1/agents/active": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get all active agents", + "description": "Returns a list of all currently running agents with their status and details.", + "operationId": "agents_active", + "responses": { + "200": { + "description": "Active agents list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActiveAgentsResponse" + } + } + } + } + } + } + }, + "/api/v1/agents/{agent_id}": { + "get": { + "tags": [ + "Agents" + ], + "summary": "Get details for a single agent by ID", + "description": "Returns full details for a specific agent, including all tracked state.", + "operationId": "agents_get_detail", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to look up", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Agent details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentDetailResponse" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/v1/agents/{agent_id}/approve": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Approve an agent's pending review", + "description": "Clears the review state and signals the agent to continue.\nThe agent must be in `awaiting_input` status with a pending review.", + "operationId": "agents_approve_review", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to approve", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Review approved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/v1/agents/{agent_id}/focus": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Focus the terminal session of a running agent in its session wrapper.", + "description": "The web UI's launch panel calls this for **cmux** launches: cmux exposes no\nbrowser URL scheme, so the operator control plane (which runs inside cmux)\nshells out to `cmux focus-workspace` for the agent's saved workspace ref to\nbring its pane to the foreground. Other wrappers are unsupported here — VS\nCode focuses through its extension's URI handler, and tmux/zellij are\ndisplay-only in the UI, so it never calls this for them.", + "operationId": "agents_focus_session", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID whose session to focus", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session focused" + }, + "400": { + "description": "Wrapper unsupported, or no session refs to focus" + }, + "404": { + "description": "Agent not found" + } + } + } + }, + "/api/v1/agents/{agent_id}/reject": { + "post": { + "tags": [ + "Agents" + ], + "summary": "Reject an agent's pending review", + "description": "Signals the agent that the review was rejected with feedback.\nThe agent should re-do the work based on the rejection reason.", + "operationId": "agents_reject_review", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to reject", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectReviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Review rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewResponse" + } + } + } + }, + "404": { + "description": "Agent not found" + } + } + } + }, "/api/v1/collections": { "get": { "tags": [ "Collections" ], "summary": "List all collections", - "operationId": "list", + "operationId": "collections_list", "responses": { "200": { "description": "List of all collections", @@ -43,7 +215,7 @@ "Collections" ], "summary": "Get the currently active collection", - "operationId": "get_active", + "operationId": "collections_get_active", "responses": { "200": { "description": "Active collection", @@ -74,7 +246,7 @@ "Collections" ], "summary": "Get a single collection by name", - "operationId": "get_one", + "operationId": "collections_get_one", "parameters": [ { "name": "name", @@ -116,7 +288,7 @@ "Collections" ], "summary": "Activate a collection", - "operationId": "activate", + "operationId": "collections_activate", "parameters": [ { "name": "name", @@ -152,13 +324,61 @@ } } }, + "/api/v1/configuration": { + "get": { + "tags": [ + "Configuration" + ], + "summary": "Get the current configuration", + "description": "Returns the full operator configuration as a JSON object. The body is left\nopaque in the OpenAPI spec because the `Config` tree is large and no client\nconsumes its OpenAPI schema (the TS `Config` type is generated separately by\nts-rs).", + "operationId": "configuration_get", + "responses": { + "200": { + "description": "Current configuration as a JSON object", + "content": { + "application/json": { + "schema": {} + } + } + } + } + }, + "put": { + "tags": [ + "Configuration" + ], + "summary": "Update configuration and save to disk", + "operationId": "configuration_update", + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated configuration as a JSON object", + "content": { + "application/json": { + "schema": {} + } + } + }, + "500": { + "description": "Failed to save configuration" + } + } + } + }, "/api/v1/delegators": { "get": { "tags": [ "Delegators" ], "summary": "List all configured delegators", - "operationId": "list", + "operationId": "delegators_list", "responses": { "200": { "description": "List of delegators", @@ -177,7 +397,7 @@ "Delegators" ], "summary": "Create a new delegator", - "operationId": "create", + "operationId": "delegators_create", "requestBody": { "content": { "application/json": { @@ -212,7 +432,7 @@ ], "summary": "Create a delegator from a detected LLM tool", "description": "Pre-populates delegator fields from the detected tool, requiring minimal input.", - "operationId": "create_from_tool", + "operationId": "delegators_create_from_tool", "requestBody": { "content": { "application/json": { @@ -249,7 +469,7 @@ "Delegators" ], "summary": "Get a single delegator by name", - "operationId": "get_one", + "operationId": "delegators_get_one", "parameters": [ { "name": "name", @@ -282,7 +502,7 @@ "Delegators" ], "summary": "Update an existing delegator", - "operationId": "update", + "operationId": "delegators_update", "parameters": [ { "name": "name", @@ -325,7 +545,7 @@ "Delegators" ], "summary": "Delete a delegator by name", - "operationId": "delete", + "operationId": "delegators_delete", "parameters": [ { "name": "name", @@ -360,7 +580,7 @@ "Health" ], "summary": "Health check endpoint", - "operationId": "health", + "operationId": "health_check", "responses": { "200": { "description": "Service is healthy", @@ -381,7 +601,7 @@ "Issue Types" ], "summary": "List all issue types", - "operationId": "list", + "operationId": "issuetypes_list", "responses": { "200": { "description": "List of all issue types", @@ -403,7 +623,7 @@ "Issue Types" ], "summary": "Create a new issue type", - "operationId": "create", + "operationId": "issuetypes_create", "requestBody": { "content": { "application/json": { @@ -454,7 +674,7 @@ "Issue Types" ], "summary": "Get a single issue type by key", - "operationId": "get_one", + "operationId": "issuetypes_get_one", "parameters": [ { "name": "key", @@ -494,7 +714,7 @@ "Issue Types" ], "summary": "Update an existing issue type", - "operationId": "update", + "operationId": "issuetypes_update", "parameters": [ { "name": "key", @@ -564,7 +784,7 @@ "Issue Types" ], "summary": "Delete an issue type", - "operationId": "delete", + "operationId": "issuetypes_delete", "parameters": [ { "name": "key", @@ -609,7 +829,7 @@ "Steps" ], "summary": "List all steps for an issue type", - "operationId": "list", + "operationId": "steps_list", "parameters": [ { "name": "key", @@ -654,7 +874,7 @@ "Steps" ], "summary": "Get a single step by name", - "operationId": "get_one", + "operationId": "steps_get_one", "parameters": [ { "name": "key", @@ -703,7 +923,7 @@ "Steps" ], "summary": "Update a step", - "operationId": "update", + "operationId": "steps_update", "parameters": [ { "name": "key", @@ -778,20 +998,42 @@ } } }, - "/api/v1/llm-tools": { + "/api/v1/issuetypes/{key}/workflow-preview": { "get": { "tags": [ - "LLM Tools" + "Workflow" + ], + "summary": "Preview the workflow for an issue type (no ticket required).", + "description": "Renders the issue type's step structure into a Claude dynamic workflow using\nplaceholder field values. Used by the UI to visualize an issue type's\nworkflow shape.", + "operationId": "workflow_preview", + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Issue type key (e.g., FEAT, FIX)", + "required": true, + "schema": { + "type": "string" + } + } ], - "summary": "List detected LLM tools with model aliases", - "operationId": "list", "responses": { "200": { - "description": "List of detected LLM tools", + "description": "Generated preview workflow", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LlmToolsResponse" + "$ref": "#/components/schemas/WorkflowPreviewResponse" + } + } + } + }, + "404": { + "description": "Issue type not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -799,37 +1041,51 @@ } } }, - "/api/v1/llm-tools/default": { - "get": { + "/api/v1/kanban/config": { + "put": { "tags": [ - "LLM Tools" + "Kanban" ], - "summary": "Get the current default LLM tool and model", - "operationId": "get_default", + "summary": "PUT /`api/v1/kanban/config`", + "description": "Write or upsert a kanban provider+project section into `config.toml`.\nDoes NOT receive the actual secret — only the env var name (`api_key_env`).", + "operationId": "kanban_write_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteKanbanConfigRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Current default LLM", + "description": "Config section written/upserted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DefaultLlmResponse" + "$ref": "#/components/schemas/WriteKanbanConfigResponse" } } } } } - }, - "put": { + } + }, + "/api/v1/kanban/projects": { + "post": { "tags": [ - "LLM Tools" + "Kanban" ], - "summary": "Set the global default LLM tool and model", - "operationId": "set_default", + "summary": "POST /`api/v1/kanban/projects`", + "description": "List available projects/teams for the given provider using ephemeral\ncredentials. No persistence side effects.", + "operationId": "kanban_list_projects", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetDefaultLlmRequest" + "$ref": "#/components/schemas/ListKanbanProjectsRequest" } } }, @@ -837,36 +1093,36 @@ }, "responses": { "200": { - "description": "Default LLM set", + "description": "Available projects/teams for the provider", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DefaultLlmResponse" + "$ref": "#/components/schemas/ListKanbanProjectsResponse" } } } - }, - "404": { - "description": "Tool not detected" } } } }, - "/api/v1/mcp/descriptor": { + "/api/v1/kanban/providers": { "get": { "tags": [ - "MCP" + "Kanban" ], - "summary": "MCP descriptor endpoint", - "description": "Returns metadata for building a VS Code MCP deep link.\nThe transport URL is derived from the request Host header\nso it reflects the actual running port.", - "operationId": "descriptor", + "summary": "GET /`api/v1/kanban/providers`", + "description": "Returns the catalog of supported kanban providers (Jira, Linear, GitHub),\neach flagged with whether it is already configured. This is the shared\nsource of truth for the web `/#/kanban` list view and the VS Code\nonboarding picker.", + "operationId": "kanban_provider_catalog", "responses": { "200": { - "description": "MCP server descriptor", + "description": "Supported kanban providers", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/McpDescriptorResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanProviderCatalogEntry" + } } } } @@ -874,37 +1130,51 @@ } } }, - "/api/v1/model-servers": { - "get": { + "/api/v1/kanban/session-env": { + "post": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "List all model servers (user-declared + implicit builtins)", - "operationId": "list", + "summary": "POST /`api/v1/kanban/session-env`", + "description": "Set kanban env vars on the server process for the current session so\nsubsequent `from_config()` calls find the API key. Returns a\n`shell_export_block` with placeholder values for the client to display.", + "operationId": "kanban_set_session_env", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetKanbanSessionEnvRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of model servers", + "description": "Session env vars set; returns a shell export block", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServersResponse" + "$ref": "#/components/schemas/SetKanbanSessionEnvResponse" } } } } } - }, + } + }, + "/api/v1/kanban/validate": { "post": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "Create a new model server", - "operationId": "create", + "summary": "POST /`api/v1/kanban/validate`", + "description": "Validate credentials against the live provider API without persisting\nanything. Auth failures return `valid: false` with an `error` string\nrather than a 4xx/5xx status so clients can display errors inline.", + "operationId": "kanban_validate_credentials", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateModelServerRequest" + "$ref": "#/components/schemas/ValidateKanbanCredentialsRequest" } } }, @@ -912,67 +1182,91 @@ }, "responses": { "200": { - "description": "Model server created", + "description": "Validation result (valid flag + optional error)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "$ref": "#/components/schemas/ValidateKanbanCredentialsResponse" } } } - }, - "409": { - "description": "Model server already exists" } } } }, - "/api/v1/model-servers/{name}": { + "/api/v1/kanban/{provider}/{project_key}/issuetypes": { "get": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "Get a single model server by name", - "operationId": "get_one", + "summary": "GET /`api/v1/kanban/:provider/:project_key/issuetypes`", + "description": "Returns kanban issue types from the persisted catalog for a given provider/project.\nFalls back to fetching live from the provider if no catalog exists.", + "operationId": "kanban_external_issue_types", "parameters": [ { - "name": "name", + "name": "provider", "in": "path", - "description": "Model server name", + "description": "Kanban provider name (e.g. jira, linear, github)", "required": true, "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "Model server details", + }, + { + "name": "project_key", + "in": "path", + "description": "Provider project/team key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "External issue types", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalIssueTypeSummary" + } } } } }, - "404": { - "description": "Model server not found" + "400": { + "description": "Unknown provider/project" + }, + "500": { + "description": "Failed to read catalog or fetch from provider" } } - }, - "delete": { + } + }, + "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync": { + "post": { "tags": [ - "ModelServers" + "Kanban" ], - "summary": "Delete a user-declared model server by name", - "description": "Implicit builtin servers cannot be deleted.", - "operationId": "delete", + "summary": "POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync`", + "description": "Refreshes the local kanban issue type catalog from the provider.", + "operationId": "kanban_sync_issue_types", "parameters": [ { - "name": "name", + "name": "provider", "in": "path", - "description": "Model server name", + "description": "Kanban provider name (e.g. jira, linear, github)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_key", + "in": "path", + "description": "Provider project/team key", "required": true, "schema": { "type": "string" @@ -981,38 +1275,38 @@ ], "responses": { "200": { - "description": "Model server deleted", + "description": "Synced issue types", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ModelServerResponse" + "$ref": "#/components/schemas/SyncKanbanIssueTypesResponse" } } } }, - "404": { - "description": "Model server not found" + "400": { + "description": "Unknown provider/project" }, - "409": { - "description": "Cannot delete implicit builtin server" + "500": { + "description": "Failed to sync from provider" } } } }, - "/api/v1/skills": { + "/api/v1/llm-tools": { "get": { "tags": [ - "Skills" + "LLM Tools" ], - "summary": "List all discovered skills across LLM tools", - "operationId": "list", + "summary": "List detected LLM tools with model aliases", + "operationId": "llm_tools_list", "responses": { "200": { - "description": "List of discovered skill files", + "description": "List of detected LLM tools", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SkillsResponse" + "$ref": "#/components/schemas/LlmToolsResponse" } } } @@ -1020,40 +1314,203 @@ } } }, - "/api/v1/status": { + "/api/v1/llm-tools/default": { "get": { "tags": [ - "Health" + "LLM Tools" ], - "summary": "Get service status with registry info", - "operationId": "status", + "summary": "Get the current default LLM tool and model", + "operationId": "llm_tools_get_default", "responses": { "200": { - "description": "Service status with registry info", + "description": "Current default LLM", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StatusResponse" + "$ref": "#/components/schemas/DefaultLlmResponse" } } } } } + }, + "put": { + "tags": [ + "LLM Tools" + ], + "summary": "Set the global default LLM tool and model", + "operationId": "llm_tools_set_default", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDefaultLlmRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default LLM set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultLlmResponse" + } + } + } + }, + "404": { + "description": "Tool not detected" + } + } } }, - "/api/v1/tickets/{id}/launch": { + "/api/v1/mcp/descriptor": { + "get": { + "tags": [ + "MCP" + ], + "summary": "MCP descriptor endpoint", + "description": "Returns metadata for registering operator with an MCP-capable client.\nThe transport URL is derived from the request Host header so it reflects\nthe actual running port; the stdio entrypoint reflects this binary's path.", + "operationId": "mcp_descriptor", + "responses": { + "200": { + "description": "MCP server descriptor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpDescriptorResponse" + } + } + } + } + } + } + }, + "/api/v1/model-servers": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "List all model servers (user-declared + implicit builtins)", + "operationId": "model_servers_list", + "responses": { + "200": { + "description": "List of model servers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServersResponse" + } + } + } + } + } + }, "post": { "tags": [ - "Launch" + "ModelServers" ], - "summary": "Launch a ticket from the queue", - "description": "Claims the ticket, sets up worktree if needed, generates the LLM command,\nand returns all details needed to execute in an external terminal (VS Code, etc.).", - "operationId": "launch_ticket", + "summary": "Create a new model server", + "operationId": "model_servers_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateModelServerRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Model server created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "409": { + "description": "Model server already exists" + } + } + } + }, + "/api/v1/model-servers/kinds": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "The catalog of supported model-server kinds (single source of truth).", + "operationId": "model_servers_kinds", + "responses": { + "200": { + "description": "Supported model-server kinds", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelServerKindEntry" + } + } + } + } + } + } + } + }, + "/api/v1/model-servers/{name}": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "Get a single model server by name", + "operationId": "model_servers_get_one", "parameters": [ { - "name": "id", + "name": "name", "in": "path", - "description": "Ticket ID to launch", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model server details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + } + } + }, + "put": { + "tags": [ + "ModelServers" + ], + "summary": "Update an existing user-declared model server", + "operationId": "model_servers_update", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", "required": true, "schema": { "type": "string" @@ -1064,7 +1521,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketRequest" + "$ref": "#/components/schemas/UpdateModelServerRequest" } } }, @@ -1072,519 +1529,2332 @@ }, "responses": { "200": { - "description": "Ticket launched successfully", + "description": "Model server updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketResponse" + "$ref": "#/components/schemas/ModelServerResponse" } } } }, - "400": { - "description": "Invalid request" - }, "404": { - "description": "Ticket not found" + "description": "Model server not found" }, "409": { - "description": "Ticket already in progress" + "description": "Cannot update implicit builtin server" } } - } - } - }, - "components": { - "schemas": { - "CollectionResponse": { - "type": "object", - "description": "Response for a collection", - "required": [ - "name", - "description", - "types", - "is_active" + }, + "delete": { + "tags": [ + "ModelServers" ], - "properties": { - "description": { + "summary": "Delete a user-declared model server by name", + "description": "Implicit builtin servers cannot be deleted.", + "operationId": "model_servers_delete", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Model server deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + }, + "409": { + "description": "Cannot delete implicit builtin server" + } + } + } + }, + "/api/v1/model-servers/{name}/models": { + "get": { + "tags": [ + "ModelServers" + ], + "summary": "List the models a server offers, via a live probe of its inference endpoint.", + "description": "The probe doubles as a reachability check — `reachable: false` with an `error`\nwhen the endpoint is unreachable or rejects the request.", + "operationId": "model_servers_models", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Model server name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Models offered by the server", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelServerModelsResponse" + } + } + } + }, + "404": { + "description": "Model server not found" + } + } + } + }, + "/api/v1/projects": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List all configured projects with analysis data", + "operationId": "projects_list", + "responses": { + "200": { + "description": "List of projects with analysis data", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectSummary" + } + } + } + } + } + } + } + }, + "/api/v1/projects/{name}/assess": { + "post": { + "tags": [ + "Projects" + ], + "summary": "Create an ASSESS ticket for a project", + "operationId": "projects_assess", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Project name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "ASSESS ticket created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssessTicketResponse" + } + } + } + }, + "404": { + "description": "Project not found" + } + } + } + }, + "/api/v1/queue/kanban": { + "get": { + "tags": [ + "Queue" + ], + "summary": "Get kanban board data with tickets grouped by status column", + "description": "Returns tickets organized into four columns: queue, running, awaiting, done.\nTickets are sorted by priority within each column, then by timestamp (FIFO).", + "operationId": "queue_kanban", + "responses": { + "200": { + "description": "Kanban board data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanBoardResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/pause": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Pause queue processing", + "description": "Sets the queue paused state to true, stopping automatic ticket launches.", + "operationId": "queue_pause", + "responses": { + "200": { + "description": "Queue paused successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueControlResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/resume": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Resume queue processing", + "description": "Sets the queue paused state to false, resuming automatic ticket launches.", + "operationId": "queue_resume", + "responses": { + "200": { + "description": "Queue resumed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueControlResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/status": { + "get": { + "tags": [ + "Queue" + ], + "summary": "Get queue status with ticket counts", + "description": "Returns counts of tickets in each state plus breakdown by type.", + "operationId": "queue_status", + "responses": { + "200": { + "description": "Queue status with counts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueStatusResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/sync": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Sync kanban collections", + "description": "Fetches issues from configured external kanban providers (Jira, Linear, etc.)\nand creates local tickets in the queue.", + "operationId": "queue_sync", + "responses": { + "200": { + "description": "Kanban sync completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanSyncResponse" + } + } + } + } + } + } + }, + "/api/v1/queue/sync/{provider}/{project_key}": { + "post": { + "tags": [ + "Queue" + ], + "summary": "Sync a specific kanban collection", + "description": "Fetches issues from a single provider/project combination and creates\nlocal tickets in the queue.", + "operationId": "queue_sync_collection", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "Provider name (jira or linear)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "project_key", + "in": "path", + "description": "Project/team key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Collection sync completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KanbanSyncResponse" + } + } + } + } + } + } + }, + "/api/v1/sections": { + "get": { + "tags": [ + "Status" + ], + "summary": "List the canonical status sections with health + child rows.", + "description": "Returns all sections (with a `met` flag) rather than hiding unmet ones, so\nthe web UI can render every section and style locked ones. The section logic\nis injected by the binary; without a provider (lib-only/test) this is empty.", + "operationId": "sections_list", + "responses": { + "200": { + "description": "Canonical status sections", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SectionDto" + } + } + } + } + } + } + } + }, + "/api/v1/skills": { + "get": { + "tags": [ + "Skills" + ], + "summary": "List all discovered skills across LLM tools", + "operationId": "skills_list", + "responses": { + "200": { + "description": "List of discovered skill files", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillsResponse" + } + } + } + } + } + } + }, + "/api/v1/status": { + "get": { + "tags": [ + "Health" + ], + "summary": "Get service status with registry info", + "operationId": "health_status", + "responses": { + "200": { + "description": "Service status with registry info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + } + } + } + }, + "/api/v1/tickets/{id}": { + "get": { + "tags": [ + "Tickets" + ], + "summary": "Get full ticket details by ID", + "description": "Returns complete ticket data including content, metadata, step history,\nand session information. Searches queue, in-progress, and completed directories.", + "operationId": "tickets_get_one", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID (e.g., FEAT-7598)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ticket details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetailResponse" + } + } + } + }, + "404": { + "description": "Ticket not found" + } + } + } + }, + "/api/v1/tickets/{id}/launch": { + "post": { + "tags": [ + "Launch" + ], + "summary": "Launch a ticket from the queue", + "description": "Claims the ticket, sets up worktree if needed, generates the LLM command,\nand returns all details needed to execute in an external terminal (VS Code, etc.).", + "operationId": "launch_launch_ticket", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID to launch", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket launched successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchTicketResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "404": { + "description": "Ticket not found" + }, + "409": { + "description": "Ticket already in progress" + } + } + } + }, + "/api/v1/tickets/{id}/status": { + "put": { + "tags": [ + "Tickets" + ], + "summary": "Update a ticket's status", + "description": "Moves a ticket between queue directories based on the target status.\nValid transitions: queued, running, awaiting, done.", + "operationId": "tickets_update_status", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID to update", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTicketStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket status updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTicketStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid status value" + }, + "404": { + "description": "Ticket not found" + } + } + } + }, + "/api/v1/tickets/{id}/steps/{step}/complete": { + "post": { + "tags": [ + "Launch" + ], + "summary": "Report step completion from opr8r wrapper", + "description": "Called by the opr8r wrapper when an LLM command completes.\nReturns next step info and whether to auto-proceed.", + "operationId": "launch_complete_step", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "step", + "in": "path", + "description": "Step name that completed", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StepCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Step completion recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StepCompleteResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "404": { + "description": "Ticket not found" + } + } + } + }, + "/api/v1/tickets/{id}/workflow-export": { + "post": { + "tags": [ + "Workflow" + ], + "summary": "Export a ticket to a Claude dynamic workflow.", + "description": "Resolves the ticket (searching queue, in-progress, and completed), looks up\nits issue type in the registry, and returns the rendered `.js` plus a\nsuggested filename.", + "operationId": "workflow_export", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Ticket ID (e.g., FEAT-7598)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Generated workflow", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowExportResponse" + } + } + } + }, + "404": { + "description": "Ticket or issue type not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ActiveAgentResponse": { + "type": "object", + "description": "A single active agent", + "required": [ + "id", + "ticket_id", + "ticket_type", + "project", + "status", + "mode", + "started_at" + ], + "properties": { + "current_step": { + "type": [ + "string", + "null" + ], + "description": "Current workflow step" + }, + "id": { + "type": "string", + "description": "Agent ID (e.g., \"op-gamesvc-001\")" + }, + "mode": { + "type": "string", + "description": "Execution mode: autonomous, paired" + }, + "project": { + "type": "string", + "description": "Project being worked on" + }, + "session_context_ref": { + "type": [ + "string", + "null" + ], + "description": "Session context reference (e.g. cmux workspace, zellij session)" + }, + "session_pane_ref": { + "type": [ + "string", + "null" + ], + "description": "Session pane reference (e.g. cmux surface, zellij pane)" + }, + "session_window_ref": { + "type": [ + "string", + "null" + ], + "description": "Session window reference ID (e.g. cmux window, tmux session)" + }, + "session_wrapper": { + "type": [ + "string", + "null" + ], + "description": "Which session wrapper is in use: \"tmux\", \"vscode\", \"cmux\", or \"zellij\"" + }, + "started_at": { + "type": "string", + "description": "When the agent started (ISO 8601)" + }, + "status": { + "type": "string", + "description": "Agent status: running, `awaiting_input`, completing" + }, + "ticket_id": { + "type": "string", + "description": "Associated ticket ID (e.g., \"FEAT-042\")" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + } + } + }, + "ActiveAgentsResponse": { + "type": "object", + "description": "Response for active agents list", + "required": [ + "agents", + "count" + ], + "properties": { + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveAgentResponse" + }, + "description": "List of active agents" + }, + "count": { + "type": "integer", + "description": "Total count of active agents", + "minimum": 0 + } + } + }, + "AgentDetailResponse": { + "type": "object", + "description": "Full details for a single agent", + "required": [ + "id", + "ticket_id", + "ticket_type", + "project", + "status", + "started_at", + "last_activity", + "completed_steps", + "paired" + ], + "properties": { + "completed_steps": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Completed steps for this ticket" + }, + "current_step": { + "type": [ + "string", + "null" + ], + "description": "Current workflow step" + }, + "id": { + "type": "string", + "description": "Agent ID (UUID)" + }, + "last_activity": { + "type": "string", + "description": "Last activity timestamp (ISO 8601)" + }, + "launch_mode": { + "type": [ + "string", + "null" + ], + "description": "Launch mode: \"default\", \"yolo\", \"docker\", \"docker-yolo\"" + }, + "llm_model": { + "type": [ + "string", + "null" + ], + "description": "LLM model alias (e.g., \"opus\", \"sonnet\", \"gpt-4o\")" + }, + "llm_tool": { + "type": [ + "string", + "null" + ], + "description": "LLM tool used (e.g., \"claude\", \"gemini\", \"codex\")" + }, + "paired": { + "type": "boolean", + "description": "Whether this is a paired (interactive) agent" + }, + "pr_status": { + "type": [ + "string", + "null" + ], + "description": "Last known PR status (\"open\", \"approved\", \"`changes_requested`\", \"merged\", \"closed\")" + }, + "pr_url": { + "type": [ + "string", + "null" + ], + "description": "PR URL if created during \"pr\" step" + }, + "project": { + "type": "string", + "description": "Project being worked on" + }, + "review_state": { + "type": [ + "string", + "null" + ], + "description": "Review state for `awaiting_input` agents" + }, + "session_wrapper": { + "type": [ + "string", + "null" + ], + "description": "Which session wrapper is in use: \"tmux\", \"vscode\", \"cmux\", or \"zellij\"" + }, + "started_at": { + "type": "string", + "description": "When the agent started (ISO 8601)" + }, + "status": { + "type": "string", + "description": "Agent status: running, `awaiting_input`, completing, orphaned" + }, + "ticket_id": { + "type": "string", + "description": "Associated ticket ID (e.g., \"FEAT-042\")" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + }, + "worktree_path": { + "type": [ + "string", + "null" + ], + "description": "Path to the git worktree for this ticket" + } + } + }, + "AssessTicketResponse": { + "type": "object", + "description": "Response from creating an ASSESS ticket", + "required": [ + "ticket_id", + "ticket_path", + "project_name" + ], + "properties": { + "project_name": { + "type": "string", + "description": "Project name that was assessed" + }, + "ticket_id": { + "type": "string", + "description": "Ticket ID (e.g., \"ASSESS-1234\")" + }, + "ticket_path": { + "type": "string", + "description": "Path to the created ticket file" + } + } + }, + "CollectionResponse": { + "type": "object", + "description": "Response for a collection", + "required": [ + "name", + "description", + "types", + "is_active" + ], + "properties": { + "description": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CreateDelegatorFromToolRequest": { + "type": "object", + "description": "Request to create a delegator from a detected LLM tool\n\nPre-populates delegator fields from the detected tool, requiring minimal input.\nIf `name` is omitted, auto-generates as `\"{tool_name}-{model}\"`.\nIf `model` is omitted, uses the tool's first model alias.", + "required": [ + "tool_name" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ], + "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias." + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`." + }, + "tool_name": { + "type": "string", + "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")" + } + } + }, + "CreateDelegatorRequest": { + "type": "object", + "description": "Request to create a new delegator", + "required": [ + "name", + "llm_tool", + "model" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "llm_tool": { + "type": "string", + "description": "LLM tool name (must match a detected tool)" + }, + "model": { + "type": "string", + "description": "Model alias" + }, + "model_properties": { + "type": "object", + "description": "Arbitrary model properties", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": "string", + "description": "Unique name for the delegator" + } + } + }, + "CreateFieldRequest": { + "type": "object", + "description": "Request to create a field", + "required": [ + "name", + "description" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "field_type": { + "type": "string" + }, + "max_length": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "name": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "user_editable": { + "type": "boolean" + } + } + }, + "CreateIssueTypeRequest": { + "type": "object", + "description": "Request to create a new issue type", + "required": [ + "key", + "name", + "description", + "glyph", + "steps" + ], + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateFieldRequest" + } + }, + "glyph": { + "type": "string" + }, + "key": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "project_required": { + "type": "boolean" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateStepRequest" + } + } + } + }, + "CreateModelServerRequest": { + "type": "object", + "description": "Request to create a new model server", + "required": [ + "name", + "kind" + ], + "properties": { + "api_key_env": { + "type": [ + "string", + "null" + ], + "description": "Name of an env var providing the API key" + }, + "base_url": { + "type": [ + "string", + "null" + ], + "description": "Base URL of the inference endpoint" + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" + }, + "extra_env": { + "type": "object", + "description": "Additional environment variables", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + }, + "name": { + "type": "string", + "description": "Unique name for this model server" + } + } + }, + "CreateStepRequest": { + "type": "object", + "description": "Request to create a step", + "required": [ + "name", + "prompt" + ], + "properties": { + "allowed_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "next_step": { + "type": [ + "string", + "null" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "permission_mode": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "review_type": { + "type": "string", + "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" + } + } + }, + "DefaultLlmResponse": { + "type": "object", + "description": "Response with the current default LLM tool and model", + "required": [ + "tool", + "model" + ], + "properties": { + "model": { + "type": "string", + "description": "Default model alias (empty string if not set)" + }, + "tool": { + "type": "string", + "description": "Default tool name (empty string if not set)" + } + } + }, + "DelegatorLaunchConfigDto": { + "type": "object", + "description": "Launch configuration DTO for delegators\n\nOptional fields use tri-state semantics: `None` = inherit global config,\n`Some(true/false)` = explicit override per-delegator.", + "properties": { + "create_branch": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to create a git branch for the ticket (None = default behavior)" + }, + "docker": { + "type": [ + "boolean", + "null" + ], + "description": "Run in docker container (None = use global `launch.docker.enabled`)" + }, + "flags": { + "type": "array", + "items": { + "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` (None = use global setting)" + }, + "yolo": { + "type": "boolean", + "description": "Run in YOLO mode" + } + } + }, + "DelegatorResponse": { + "type": "object", + "description": "Response for a single delegator", + "required": [ + "name", + "llm_tool", + "model", + "model_properties" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name" + }, + "launch_config": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/DelegatorLaunchConfigDto", + "description": "Optional launch configuration" + } + ] + }, + "llm_tool": { + "type": "string", + "description": "LLM tool name (e.g., \"claude\")" + }, + "model": { + "type": "string", + "description": "Model alias (e.g., \"opus\")" + }, + "model_properties": { + "type": "object", + "description": "Arbitrary model properties", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "model_server": { + "type": [ + "string", + "null" + ], + "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + }, + "name": { + "type": "string", + "description": "Unique name" + } + } + }, + "DelegatorsResponse": { + "type": "object", + "description": "Response listing all delegators", + "required": [ + "delegators", + "total" + ], + "properties": { + "delegators": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DelegatorResponse" + }, + "description": "List of delegators" + }, + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 + } + } + }, + "DetectedTool": { + "type": "object", + "description": "A detected CLI tool (e.g., claude binary)", + "required": [ + "name", + "path", + "version" + ], + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ToolCapabilities", + "description": "Tool capabilities" + }, + "command_template": { + "type": "string", + "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" + }, + "min_version": { + "type": [ + "string", + "null" + ], + "description": "Minimum required version for Operator compatibility" + }, + "model_aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])" + }, + "name": { + "type": "string", + "description": "Tool name (e.g., \"claude\")" + }, + "path": { + "type": "string", + "description": "Path to the binary" + }, + "version": { + "type": "string", + "description": "Version string" + }, + "version_ok": { + "type": "boolean", + "description": "Whether the installed version meets the minimum requirement" + }, + "yolo_flags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "CLI flags for YOLO (auto-accept) mode" + } + } + }, + "ErrorResponse": { + "type": "object", + "description": "Error response body", + "required": [ + "error", + "message" + ], + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ExternalIssueTypeSummary": { + "type": "object", + "description": "Summary of an issue type from an external kanban provider (Jira, Linear)", + "required": [ + "id", + "name" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ], + "description": "Description of the issue type" + }, + "icon_url": { + "type": [ + "string", + "null" + ], + "description": "Icon/avatar URL from the provider" + }, + "id": { + "type": "string", + "description": "Provider-specific unique identifier" + }, + "name": { + "type": "string", + "description": "Issue type name (e.g., \"Bug\", \"Story\", \"Task\")" + } + } + }, + "FieldResponse": { + "type": "object", + "description": "Response for a field", + "required": [ + "name", + "description", + "field_type", + "required", + "user_editable" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "field_type": { + "type": "string" + }, + "max_length": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "name": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "user_editable": { + "type": "boolean" + } + } + }, + "GithubCredentials": { + "type": "object", + "description": "Ephemeral GitHub Projects credentials supplied by a client during onboarding.\n\nThe token must have `project` (or `read:project`) scope. A repo-only token\n(the kind used for `GITHUB_TOKEN` and operator's git provider) will be\nrejected at validation time with a friendly \"lacks `project` scope\" error.", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "GitHub PAT, fine-grained PAT, or app installation token" + } + } + }, + "GithubProjectInfoDto": { + "type": "object", + "description": "A GitHub Project v2 surfaced during onboarding for project picker UIs.", + "required": [ + "node_id", + "number", + "title", + "owner_login", + "owner_kind" + ], + "properties": { + "node_id": { + "type": "string", + "description": "`GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key" + }, + "number": { + "type": "integer", + "format": "int32", + "description": "Project number (e.g., 42) within the owner" + }, + "owner_kind": { + "type": "string", + "description": "\"Organization\" or \"User\"" + }, + "owner_login": { + "type": "string", + "description": "Owner login (org or user name)" + }, + "title": { + "type": "string", + "description": "Human-readable project title" + } + } + }, + "GithubSessionEnv": { + "type": "object", + "description": "GitHub Projects session env body — includes the actual secret to set in env.", + "required": [ + "token", + "api_key_env" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, + "GithubValidationDetailsDto": { + "type": "object", + "description": "GitHub-specific validation details (returned on success).", + "required": [ + "user_login", + "user_id", + "projects", + "resolved_env_var" + ], + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GithubProjectInfoDto" + }, + "description": "All Projects v2 visible to the token (across viewer + organizations)" + }, + "resolved_env_var": { + "type": "string", + "description": "The env var name the validated token came from. Used by clients to\ndisplay \"Connected via `OPERATOR_GITHUB_TOKEN`\" so users can rotate the\nright token. See Token Disambiguation in the kanban github docs." + }, + "user_id": { + "type": "string", + "description": "Authenticated user's numeric `databaseId` as a string (used as `sync_user_id`)" + }, + "user_login": { + "type": "string", + "description": "Authenticated user's login (e.g., \"octocat\")" + } + } + }, + "HealthResponse": { + "type": "object", + "description": "Health check response", + "required": [ + "status", + "version" + ], + "properties": { + "status": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "IssueTypeResponse": { + "type": "object", + "description": "Response for a single issue type", + "required": [ + "key", + "name", + "description", + "mode", + "glyph", + "project_required", + "source", + "fields", + "steps" + ], + "properties": { + "color": { + "type": [ + "string", + "null" + ] + }, + "description": { "type": "string" }, - "is_active": { - "type": "boolean" + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldResponse" + } + }, + "glyph": { + "type": "string" + }, + "key": { + "type": "string" + }, + "mode": { + "type": "string" }, "name": { "type": "string" }, - "types": { + "project_required": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "steps": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/StepResponse" } } } }, - "CreateDelegatorFromToolRequest": { + "IssueTypeSummary": { "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.", + "description": "Summary response for listing issue types", "required": [ - "tool_name" + "key", + "name", + "description", + "mode", + "glyph", + "source", + "stepCount" ], "properties": { - "display_name": { + "color": { "type": [ "string", "null" - ], - "description": "Optional display name for UI" - }, - "launch_config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DelegatorLaunchConfigDto", - "description": "Optional launch configuration" - } ] }, - "model": { + "description": { + "type": "string" + }, + "glyph": { + "type": "string" + }, + "key": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "stepCount": { + "type": "integer", + "minimum": 0 + } + } + }, + "JiraCredentials": { + "type": "object", + "description": "Ephemeral Jira credentials supplied by a client during onboarding.\n\nThese are never persisted to disk by the onboarding endpoints that take\nthis struct — the actual secret stays in the env var named in\n`api_key_env` once set via `/api/v1/kanban/session-env`.", + "required": [ + "domain", + "email", + "api_token" + ], + "properties": { + "api_token": { + "type": "string", + "description": "API token / personal access token" + }, + "domain": { + "type": "string", + "description": "Jira Cloud domain (e.g., \"acme.atlassian.net\")" + }, + "email": { + "type": "string", + "description": "Atlassian account email for Basic Auth" + } + } + }, + "JiraSessionEnv": { + "type": "object", + "description": "Jira session env body — includes the actual secret to set in env.", + "required": [ + "domain", + "email", + "api_token", + "api_key_env" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "JiraValidationDetailsDto": { + "type": "object", + "description": "Jira-specific validation details (returned on success).", + "required": [ + "account_id", + "display_name" + ], + "properties": { + "account_id": { + "type": "string", + "description": "Atlassian accountId (used as `sync_user_id`)" + }, + "display_name": { + "type": "string", + "description": "User display name" + } + } + }, + "KanbanBoardResponse": { + "type": "object", + "description": "Kanban board response with tickets grouped by column", + "required": [ + "queue", + "running", + "awaiting", + "done", + "total_count", + "last_updated" + ], + "properties": { + "awaiting": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Tickets awaiting review or input" + }, + "done": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Completed tickets" + }, + "last_updated": { + "type": "string", + "description": "ISO 8601 timestamp of last data refresh" + }, + "queue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Tickets in queue (not yet started)" + }, + "running": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanTicketCard" + }, + "description": "Tickets currently being worked on" + }, + "total_count": { + "type": "integer", + "description": "Total ticket count across all columns", + "minimum": 0 + } + } + }, + "KanbanIssueTypeResponse": { + "type": "object", + "description": "A synced kanban issue type from the persisted catalog.", + "required": [ + "id", + "name", + "provider", + "project", + "source_kind", + "synced_at" + ], + "properties": { + "description": { "type": [ "string", "null" ], - "description": "Model alias to use (e.g., \"opus\"). If omitted, uses the tool's first model alias." + "description": "Description from the provider" }, - "model_server": { + "icon_url": { "type": [ "string", "null" ], - "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + "description": "Icon/avatar URL from the provider" + }, + "id": { + "type": "string", + "description": "Provider-specific ID (Jira type ID, Linear label ID)" }, "name": { - "type": [ - "string", - "null" - ], - "description": "Custom delegator name. If omitted, auto-generates as `\"{tool_name}-{model}\"`." + "type": "string", + "description": "Display name (e.g., \"Bug\", \"Story\", \"Task\")" }, - "tool_name": { + "project": { "type": "string", - "description": "Name of the detected tool (e.g., \"claude\", \"codex\", \"gemini\")" + "description": "Project/team key" + }, + "provider": { + "type": "string", + "description": "Provider name (\"jira\", \"linear\", or \"github\")" + }, + "source_kind": { + "type": "string", + "description": "What this type represents in the provider (\"issuetype\" or \"label\")" + }, + "synced_at": { + "type": "string", + "description": "ISO 8601 timestamp of last sync" } } }, - "CreateDelegatorRequest": { + "KanbanProjectInfo": { "type": "object", - "description": "Request to create a new delegator", + "description": "A project/team entry returned by `list_projects`.", "required": [ - "name", - "llm_tool", - "model" + "id", + "key", + "name" ], "properties": { + "id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "KanbanProviderCatalogEntry": { + "type": "object", + "description": "One supported kanban provider, as advertised by\n`GET /api/v1/kanban/providers`.\n\nThis is the single source of truth (derived from\n`KanbanProviderType::ALL`) that the web `/#/kanban` list view and the VS\nCode onboarding picker both render against, so the available options can't\ndrift between surfaces.", + "required": [ + "slug", + "display_name", + "description", + "setup_url", + "icon", + "configured" + ], + "properties": { + "configured": { + "type": "boolean", + "description": "Whether at least one instance of this provider is already configured." + }, + "description": { + "type": "string", + "description": "One-line connect description shown next to the provider." + }, "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name" + "type": "string", + "description": "Human-readable name (e.g. \"Jira Cloud\", \"GitHub Projects\")." }, - "launch_config": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/DelegatorLaunchConfigDto", - "description": "Optional launch configuration" - } - ] + "icon": { + "type": "string", + "description": "VS Code codicon hint (rendered as `$(icon)` in the picker)." }, - "llm_tool": { + "setup_url": { "type": "string", - "description": "LLM tool name (must match a detected tool)" + "description": "Credential/token page opened when the user chooses to configure it." }, - "model": { + "slug": { + "type": "string", + "description": "Stable lowercase slug (\"jira\" | \"linear\" | \"github\")." + } + } + }, + "KanbanProviderKind": { + "type": "string", + "description": "Which kanban provider an onboarding request targets.", + "enum": [ + "jira", + "linear", + "github" + ] + }, + "KanbanSyncResponse": { + "type": "object", + "description": "Response for kanban sync operations", + "required": [ + "created", + "skipped", + "errors", + "total_processed" + ], + "properties": { + "created": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ticket IDs that were created" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Error messages for failed syncs" + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ticket IDs that were skipped (already exist)" + }, + "total_processed": { + "type": "integer", + "description": "Total count of issues processed", + "minimum": 0 + } + } + }, + "KanbanTicketCard": { + "type": "object", + "description": "A ticket card for the kanban board", + "required": [ + "id", + "summary", + "ticket_type", + "project", + "status", + "step", + "priority", + "timestamp" + ], + "properties": { + "id": { + "type": "string", + "description": "Ticket ID (e.g., \"FEAT-7598\")" + }, + "priority": { + "type": "string", + "description": "Priority: P0-critical, P1-high, P2-medium, P3-low" + }, + "project": { + "type": "string", + "description": "Project name" + }, + "status": { "type": "string", - "description": "Model alias" + "description": "Current status: queued, running, awaiting, completed" }, - "model_properties": { - "type": "object", - "description": "Arbitrary model properties", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } + "step": { + "type": "string", + "description": "Current step name" }, - "model_server": { + "step_display_name": { "type": [ "string", "null" ], - "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." + "description": "Human-readable step name" }, - "name": { + "summary": { "type": "string", - "description": "Unique name for the delegator" + "description": "Ticket summary/title" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + }, + "timestamp": { + "type": "string", + "description": "Timestamp for sorting (YYYYMMDD-HHMM format)" } } }, - "CreateFieldRequest": { + "LaunchTicketRequest": { "type": "object", - "description": "Request to create a field", - "required": [ - "name", - "description" - ], + "description": "Request to launch a ticket", "properties": { - "default": { + "delegator": { "type": [ "string", "null" - ] - }, - "description": { - "type": "string" + ], + "description": "Named delegator to use (takes precedence over provider/model)" }, - "field_type": { - "type": "string" + "model": { + "type": [ + "string", + "null" + ], + "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator" }, - "max_length": { + "model_server": { "type": [ - "integer", + "string", "null" ], - "minimum": 0 + "description": "Ad-hoc model server to target (e.g. \"ollama-local\") — legacy fallback when\nno delegator. Injects the server's base URL / API key env at spawn." }, - "name": { - "type": "string" + "provider": { + "type": [ + "string", + "null" + ], + "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator" }, - "options": { - "type": "array", - "items": { - "type": "string" - } + "resume_session_id": { + "type": [ + "string", + "null" + ], + "description": "Existing session ID to resume (for continuing from where it left off)" }, - "placeholder": { + "retry_reason": { "type": [ "string", "null" - ] + ], + "description": "Feedback for relaunch (what went wrong on previous attempt)" }, - "required": { - "type": "boolean" + "wrapper": { + "type": [ + "string", + "null" + ], + "description": "Session wrapper type: \"vscode\", \"tmux\", \"cmux\", \"terminal\"" }, - "user_editable": { - "type": "boolean" + "yolo_mode": { + "type": "boolean", + "description": "Run in YOLO mode (auto-accept all prompts)" } } }, - "CreateIssueTypeRequest": { + "LaunchTicketResponse": { "type": "object", - "description": "Request to create a new issue type", + "description": "Response from launching a ticket", "required": [ - "key", - "name", - "description", - "glyph", - "steps" + "agent_id", + "ticket_id", + "working_directory", + "command", + "terminal_name", + "tmux_session_name", + "session_id", + "worktree_created" ], "properties": { - "color": { + "agent_id": { + "type": "string", + "description": "Agent ID assigned to this launch" + }, + "branch": { "type": [ "string", "null" - ] - }, - "description": { - "type": "string" - }, - "fields": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateFieldRequest" - } - }, - "glyph": { - "type": "string" - }, - "key": { - "type": "string" - }, - "mode": { - "type": "string" - }, - "name": { - "type": "string" + ], + "description": "Branch name (if worktree was created)" }, - "project_required": { - "type": "boolean" + "command": { + "type": "string", + "description": "Command to execute in terminal" }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateStepRequest" - } - } - } - }, - "CreateModelServerRequest": { - "type": "object", - "description": "Request to create a new model server", - "required": [ - "name", - "kind" - ], - "properties": { - "api_key_env": { + "session_context_ref": { "type": [ "string", "null" ], - "description": "Name of an env var providing the API key" + "description": "Session context reference (e.g. cmux workspace, zellij session)" }, - "base_url": { + "session_id": { + "type": "string", + "description": "Session UUID for the LLM tool" + }, + "session_window_ref": { "type": [ "string", "null" ], - "description": "Base URL of the inference endpoint" + "description": "Session window reference ID (e.g. cmux window, tmux session)" }, - "display_name": { + "session_wrapper": { "type": [ "string", "null" ], - "description": "Optional display name for UI" + "description": "Which session wrapper was used: \"tmux\", \"vscode\", or \"cmux\"" }, - "extra_env": { - "type": "object", - "description": "Additional environment variables", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } + "terminal_name": { + "type": "string", + "description": "Terminal name to use (same value as `tmux_session_name`)" }, - "kind": { + "ticket_id": { "type": "string", - "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + "description": "Ticket ID that was launched" }, - "name": { + "tmux_session_name": { "type": "string", - "description": "Unique name for this model server" + "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)" + }, + "working_directory": { + "type": "string", + "description": "Working directory (worktree if created, else project path)" + }, + "worktree_created": { + "type": "boolean", + "description": "Whether a worktree was created" } } }, - "CreateStepRequest": { + "LinearCredentials": { "type": "object", - "description": "Request to create a step", + "description": "Ephemeral Linear credentials supplied by a client during onboarding.", "required": [ - "name", - "prompt" + "api_key" ], "properties": { - "allowed_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "next_step": { - "type": [ - "string", - "null" - ] - }, - "outputs": { - "type": "array", - "items": { - "type": "string" - } - }, - "permission_mode": { + "api_key": { + "type": "string", + "description": "Linear API key (prefixed `lin_api_`)" + } + } + }, + "LinearSessionEnv": { + "type": "object", + "description": "Linear session env body — includes the actual secret to set in env.", + "required": [ + "api_key", + "api_key_env" + ], + "properties": { + "api_key": { "type": "string" }, - "prompt": { + "api_key_env": { "type": "string" - }, - "review_type": { - "type": "string", - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" } } }, - "DefaultLlmResponse": { + "LinearTeamInfoDto": { "type": "object", - "description": "Response with the current default LLM tool and model", + "description": "A Linear team exposed to onboarding clients for project selection.", "required": [ - "tool", - "model" + "id", + "key", + "name" ], "properties": { - "model": { - "type": "string", - "description": "Default model alias (empty string if not set)" + "id": { + "type": "string" + }, + "key": { + "type": "string" }, - "tool": { - "type": "string", - "description": "Default tool name (empty string if not set)" + "name": { + "type": "string" } } }, - "DelegatorLaunchConfigDto": { + "LinearValidationDetailsDto": { "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.", + "description": "Linear-specific validation details (returned on success).", + "required": [ + "user_id", + "user_name", + "org_name", + "teams" + ], "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`)" + "org_name": { + "type": "string" }, - "flags": { + "teams": { "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" + "$ref": "#/components/schemas/LinearTeamInfoDto" + } }, - "use_worktrees": { - "type": [ - "boolean", - "null" - ], - "description": "Override global `git.use_worktrees` (None = use global setting)" + "user_id": { + "type": "string", + "description": "Linear viewer user ID (used as `sync_user_id`)" }, - "yolo": { - "type": "boolean", - "description": "Run in YOLO mode" + "user_name": { + "type": "string" } } }, - "DelegatorResponse": { + "ListKanbanProjectsRequest": { "type": "object", - "description": "Response for a single delegator", + "description": "Request to list projects/teams from a provider using ephemeral creds.", "required": [ - "name", - "llm_tool", - "model", - "model_properties" + "provider" ], "properties": { - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name" - }, - "launch_config": { + "github": { "oneOf": [ { "type": "null" }, { - "$ref": "#/components/schemas/DelegatorLaunchConfigDto", - "description": "Optional launch configuration" + "$ref": "#/components/schemas/GithubCredentials" } ] }, - "llm_tool": { - "type": "string", - "description": "LLM tool name (e.g., \"claude\")" + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraCredentials" + } + ] }, - "model": { - "type": "string", - "description": "Model alias (e.g., \"opus\")" + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearCredentials" + } + ] }, - "model_properties": { - "type": "object", - "description": "Arbitrary model properties", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "ListKanbanProjectsResponse": { + "type": "object", + "description": "Response wrapper for list-projects (wrapped for utoipa compatibility).", + "required": [ + "projects" + ], + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanProjectInfo" } - }, - "model_server": { - "type": [ - "string", - "null" - ], - "description": "Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default." - }, - "name": { - "type": "string", - "description": "Unique name" } } }, - "DelegatorsResponse": { + "LlmToolsResponse": { "type": "object", - "description": "Response listing all delegators", + "description": "Response listing detected LLM tools", "required": [ - "delegators", + "tools", "total" ], "properties": { - "delegators": { + "tools": { "type": "array", "items": { - "$ref": "#/components/schemas/DelegatorResponse" + "$ref": "#/components/schemas/DetectedTool" }, - "description": "List of delegators" + "description": "Detected CLI tools with model aliases and capabilities" }, "total": { "type": "integer", @@ -1593,528 +3863,779 @@ } } }, - "DetectedTool": { + "McpDescriptorResponse": { "type": "object", - "description": "A detected CLI tool (e.g., claude binary)", + "description": "MCP server descriptor for client discovery", "required": [ - "name", - "path", - "version" + "server_name", + "server_id", + "version", + "transport_url", + "label" ], "properties": { - "capabilities": { - "$ref": "#/components/schemas/ToolCapabilities", - "description": "Tool capabilities" - }, - "command_template": { + "label": { "type": "string", - "description": "Command template with {{model}}, {{`session_id`}}, {{`prompt_file`}} placeholders" + "description": "Human-readable label for the server" }, - "min_version": { + "openapi_url": { "type": [ "string", "null" ], - "description": "Minimum required version for Operator compatibility" + "description": "URL of the OpenAPI spec for reference" }, - "model_aliases": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Available model aliases (e.g., [\"opus\", \"sonnet\", \"haiku\"])" + "server_id": { + "type": "string", + "description": "Unique server identifier (e.g. \"operator-mcp\")" }, - "name": { + "server_name": { "type": "string", - "description": "Tool name (e.g., \"claude\")" + "description": "Server name used in MCP registration (e.g. \"operator\")" }, - "path": { + "stdio": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/StdioCommand", + "description": "Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`.\nClients may spawn this as a subprocess instead of using `transport_url`." + } + ] + }, + "transport_url": { "type": "string", - "description": "Path to the binary" + "description": "Full URL of the MCP SSE transport endpoint" }, "version": { "type": "string", - "description": "Version string" + "description": "Server version from Cargo.toml" + } + } + }, + "ModelEntry": { + "type": "object", + "description": "A single model offered by a server (from a live probe).", + "required": [ + "id" + ], + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Display name when the server provides one" }, - "version_ok": { + "id": { + "type": "string", + "description": "Model id passed to `--model`" + } + } + }, + "ModelServerKindEntry": { + "type": "object", + "description": "A model-server kind from the shared catalog (single source of truth).", + "required": [ + "slug", + "display_name", + "description", + "setup_url", + "icon", + "is_builtin" + ], + "properties": { + "description": { + "type": "string", + "description": "One-line connect blurb" + }, + "display_name": { + "type": "string", + "description": "Human-friendly display name" + }, + "icon": { + "type": "string", + "description": "Codicon hint" + }, + "is_builtin": { "type": "boolean", - "description": "Whether the installed version meets the minimum requirement" + "description": "Whether this is an implicit vendor builtin (always present, not deletable)" }, - "yolo_flags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "CLI flags for YOLO (auto-accept) mode" + "setup_url": { + "type": "string", + "description": "Help/credential setup page" + }, + "slug": { + "type": "string", + "description": "Stable slug stored as `ModelServer.kind` (e.g. \"ollama\")" } } }, - "ErrorResponse": { + "ModelServerModelsResponse": { "type": "object", - "description": "Error response body", + "description": "Response listing the models a server offers, plus reachability.", "required": [ - "error", - "message" + "server", + "reachable", + "models" ], "properties": { "error": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "Error message when unreachable" }, - "message": { - "type": "string" + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelEntry" + }, + "description": "Models offered (empty when unreachable)" + }, + "reachable": { + "type": "boolean", + "description": "Whether the endpoint was reachable" + }, + "server": { + "type": "string", + "description": "Server name probed" } } }, - "FieldResponse": { + "ModelServerResponse": { "type": "object", - "description": "Response for a field", + "description": "Response for a single model server", "required": [ "name", - "description", - "field_type", - "required", - "user_editable" + "kind", + "extra_env", + "user_declared" ], "properties": { - "default": { + "api_key_env": { "type": [ "string", "null" - ] - }, - "description": { - "type": "string" - }, - "field_type": { - "type": "string" + ], + "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)" }, - "max_length": { + "base_url": { "type": [ - "integer", + "string", "null" ], - "minimum": 0 + "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)" }, - "name": { - "type": "string" + "display_name": { + "type": [ + "string", + "null" + ], + "description": "Optional display name for UI" }, - "options": { - "type": "array", - "items": { + "extra_env": { + "type": "object", + "description": "Additional environment variables set when spawning agents that use this server", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { "type": "string" } }, - "placeholder": { - "type": [ - "string", - "null" - ] + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" }, - "required": { - "type": "boolean" + "name": { + "type": "string", + "description": "Unique name (e.g., \"ollama-local\")" }, - "user_editable": { - "type": "boolean" + "user_declared": { + "type": "boolean", + "description": "Whether this is a user-declared server (true) or an implicit builtin (false)" } } }, - "HealthResponse": { + "ModelServersResponse": { "type": "object", - "description": "Health check response", + "description": "Response listing all model servers (declared + implicit builtins)", "required": [ - "status", - "version" + "servers", + "total" ], "properties": { - "status": { - "type": "string" + "servers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelServerResponse" + }, + "description": "List of model servers" }, - "version": { - "type": "string" + "total": { + "type": "integer", + "description": "Total count", + "minimum": 0 } } }, - "IssueTypeResponse": { + "NextStepInfo": { "type": "object", - "description": "Response for a single issue type", + "description": "Information about the next step in the workflow", "required": [ - "key", "name", - "description", - "mode", - "glyph", - "project_required", - "source", - "fields", - "steps" + "display_name", + "review_type" ], "properties": { - "color": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": "string" - }, - "fields": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FieldResponse" - } - }, - "glyph": { - "type": "string" - }, - "key": { - "type": "string" - }, - "mode": { - "type": "string" + "display_name": { + "type": "string", + "description": "Display name for the step" }, "name": { - "type": "string" - }, - "project_required": { - "type": "boolean" + "type": "string", + "description": "Step name" }, - "source": { - "type": "string" + "prompt": { + "type": [ + "string", + "null" + ], + "description": "Prompt template for the step" }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StepResponse" - } + "review_type": { + "type": "string", + "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\"" } } }, - "IssueTypeSummary": { + "OperatorOutput": { "type": "object", - "description": "Summary response for listing issue types", + "description": "Standardized agent output for progress tracking and step transitions.\n\nAgents output a status block in their response which is parsed into this structure.\nUsed for progress tracking, loop detection, and intelligent step transitions.", "required": [ - "key", - "name", - "description", - "mode", - "glyph", - "source", - "stepCount" + "status", + "exit_signal" ], "properties": { - "color": { + "blockers": { "type": [ - "string", + "array", "null" - ] - }, - "description": { - "type": "string" - }, - "glyph": { - "type": "string" - }, - "key": { - "type": "string" + ], + "items": { + "type": "string" + }, + "description": "Issues preventing progress (signals intervention needed)" }, - "mode": { - "type": "string" + "confidence": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Agent's confidence in completion (0-100%)", + "minimum": 0 }, - "name": { - "type": "string" + "error_count": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Number of errors encountered", + "minimum": 0 }, - "source": { - "type": "string" + "exit_signal": { + "type": "boolean", + "description": "Agent signals done with step (true) or more work remains (false)" }, - "stepCount": { - "type": "integer", - "minimum": 0 - } - } - }, - "LaunchTicketRequest": { - "type": "object", - "description": "Request to launch a ticket", - "properties": { - "delegator": { + "files_modified": { "type": [ - "string", + "integer", "null" ], - "description": "Named delegator to use (takes precedence over provider/model)" + "format": "int32", + "description": "Number of files changed this iteration", + "minimum": 0 }, - "model": { + "recommendation": { "type": [ "string", "null" ], - "description": "Model to use (e.g., \"sonnet\", \"opus\") — legacy fallback when no delegator" + "description": "Suggested next action (max 200 chars)" }, - "provider": { + "status": { + "type": "string", + "description": "Current work status: `in_progress`, complete, blocked, failed" + }, + "summary": { "type": [ "string", "null" ], - "description": "LLM provider to use (e.g., \"claude\") — legacy fallback when no delegator" + "description": "Brief description of work done (max 500 chars)" }, - "resume_session_id": { + "tasks_completed": { "type": [ - "string", + "integer", "null" ], - "description": "Existing session ID to resume (for continuing from where it left off)" + "format": "int32", + "description": "Number of sub-tasks completed this iteration", + "minimum": 0 }, - "retry_reason": { + "tasks_remaining": { "type": [ - "string", + "integer", "null" ], - "description": "Feedback for relaunch (what went wrong on previous attempt)" + "format": "int32", + "description": "Estimated remaining sub-tasks", + "minimum": 0 }, - "wrapper": { + "tests_status": { "type": [ "string", "null" ], - "description": "Session wrapper type: \"vscode\", \"tmux\", \"cmux\", \"terminal\"" - }, - "yolo_mode": { - "type": "boolean", - "description": "Run in YOLO mode (auto-accept all prompts)" + "description": "Test suite status: passing, failing, skipped, `not_run`" } } }, - "LaunchTicketResponse": { + "ProjectSummary": { "type": "object", - "description": "Response from launching a ticket", + "description": "Summary of a project with analysis data", "required": [ - "agent_id", - "ticket_id", - "working_directory", - "command", - "terminal_name", - "tmux_session_name", - "session_id", - "worktree_created" + "project_name", + "project_path", + "exists", + "has_catalog_info", + "has_project_context", + "languages", + "frameworks", + "databases", + "ports", + "env_var_count", + "entry_point_count", + "commands" ], "properties": { - "agent_id": { - "type": "string", - "description": "Agent ID assigned to this launch" + "commands": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available command names (start, dev, test, etc.)" + }, + "databases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Database display names" + }, + "entry_point_count": { + "type": "integer", + "description": "Number of entry points", + "minimum": 0 + }, + "env_var_count": { + "type": "integer", + "description": "Number of environment variables", + "minimum": 0 + }, + "exists": { + "type": "boolean", + "description": "Whether the project directory exists on disk" + }, + "frameworks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Framework display names" + }, + "has_catalog_info": { + "type": "boolean", + "description": "Whether catalog-info.yaml exists" }, - "branch": { + "has_docker": { "type": [ - "string", + "boolean", "null" ], - "description": "Branch name (if worktree was created)" + "description": "Has Dockerfile or docker-compose" }, - "command": { - "type": "string", - "description": "Command to execute in terminal" + "has_project_context": { + "type": "boolean", + "description": "Whether project-context.json exists" }, - "session_context_ref": { + "has_tests": { "type": [ - "string", + "boolean", "null" ], - "description": "Session context reference (e.g. cmux workspace, zellij session)" - }, - "session_id": { - "type": "string", - "description": "Session UUID for the LLM tool" + "description": "Has test frameworks detected" }, - "session_window_ref": { + "kind": { "type": [ "string", "null" ], - "description": "Session window reference ID (e.g. cmux window, tmux session)" + "description": "Primary Kind from `kind_assessment` (e.g., \"microservice\")" }, - "session_wrapper": { + "kind_confidence": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Kind confidence score 0.0-1.0" + }, + "kind_tier": { "type": [ "string", "null" ], - "description": "Which session wrapper was used: \"tmux\", \"vscode\", or \"cmux\"" + "description": "Taxonomy tier (e.g., \"engines\")" }, - "terminal_name": { - "type": "string", - "description": "Terminal name to use (same value as `tmux_session_name`)" + "languages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Language display names" }, - "ticket_id": { + "ports": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "Detected port numbers" + }, + "project_name": { "type": "string", - "description": "Ticket ID that was launched" + "description": "Project directory name" }, - "tmux_session_name": { + "project_path": { "type": "string", - "description": "Tmux session name for attaching (same value as `terminal_name`, kept for backward compat)" + "description": "Absolute path to project root" + } + } + }, + "QueueByType": { + "type": "object", + "description": "Ticket counts by type for queue status", + "required": [ + "inv", + "fix", + "feat", + "spike" + ], + "properties": { + "feat": { + "type": "integer", + "minimum": 0 }, - "working_directory": { + "fix": { + "type": "integer", + "minimum": 0 + }, + "inv": { + "type": "integer", + "minimum": 0 + }, + "spike": { + "type": "integer", + "minimum": 0 + } + } + }, + "QueueControlResponse": { + "type": "object", + "description": "Response for queue pause/resume operations", + "required": [ + "paused", + "message" + ], + "properties": { + "message": { "type": "string", - "description": "Working directory (worktree if created, else project path)" + "description": "Human-readable message about the operation" }, - "worktree_created": { + "paused": { "type": "boolean", - "description": "Whether a worktree was created" + "description": "Whether the queue is currently paused" } } }, - "LlmToolsResponse": { + "QueueStatusResponse": { "type": "object", - "description": "Response listing detected LLM tools", + "description": "Queue status response with ticket counts", "required": [ - "tools", - "total" + "queued", + "in_progress", + "awaiting", + "completed", + "by_type" ], "properties": { - "tools": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DetectedTool" - }, - "description": "Detected CLI tools with model aliases and capabilities" + "awaiting": { + "type": "integer", + "description": "Tickets awaiting review or input", + "minimum": 0 }, - "total": { + "by_type": { + "$ref": "#/components/schemas/QueueByType", + "description": "Breakdown by ticket type" + }, + "completed": { "type": "integer", - "description": "Total count", + "description": "Completed tickets (today)", + "minimum": 0 + }, + "in_progress": { + "type": "integer", + "description": "Tickets currently being worked on", + "minimum": 0 + }, + "queued": { + "type": "integer", + "description": "Tickets waiting in queue", "minimum": 0 } } }, - "McpDescriptorResponse": { + "RejectReviewRequest": { "type": "object", - "description": "MCP server descriptor for client discovery", + "description": "Request to reject an agent's review", "required": [ - "server_name", - "server_id", - "version", - "transport_url", - "label" + "reason" ], "properties": { - "label": { + "reason": { "type": "string", - "description": "Human-readable label for the server" - }, - "openapi_url": { - "type": [ - "string", - "null" - ], - "description": "URL of the OpenAPI spec for reference" - }, - "server_id": { + "description": "Reason for rejection (feedback for the agent)" + } + } + }, + "ReviewResponse": { + "type": "object", + "description": "Response for agent review operations (approve/reject)", + "required": [ + "agent_id", + "status", + "message" + ], + "properties": { + "agent_id": { "type": "string", - "description": "Unique server identifier (e.g. \"operator-mcp\")" + "description": "Agent ID that was reviewed" }, - "server_name": { + "message": { "type": "string", - "description": "Server name used in MCP registration (e.g. \"operator\")" + "description": "Human-readable message about the operation" }, - "transport_url": { + "status": { "type": "string", - "description": "Full URL of the MCP SSE transport endpoint" + "description": "Review status: \"approved\" or \"rejected\"" + } + } + }, + "RowActionDto": { + "type": "object", + "description": "A browser-openable action on a status section row (e.g. \"Open Web UI\",\n\"Swagger\"). Only URL-style actions surface to the web UI; TUI-only actions\n(toggles, env edits) are omitted.", + "required": [ + "label", + "url" + ], + "properties": { + "label": { + "type": "string", + "description": "Display label for the action button/link." }, - "version": { + "url": { "type": "string", - "description": "Server version from Cargo.toml" + "description": "Browser URL the action opens." } } }, - "ModelServerResponse": { + "SectionDto": { "type": "object", - "description": "Response for a single model server", + "description": "A status section with its health and child rows.", "required": [ - "name", - "kind", - "extra_env", - "user_declared" + "id", + "label", + "health", + "description", + "prerequisites", + "met", + "children" ], "properties": { - "api_key_env": { - "type": [ - "string", - "null" - ], - "description": "Name of an env var providing the API key (e.g., `OLLAMA_API_KEY`)" - }, - "base_url": { - "type": [ - "string", - "null" - ], - "description": "Base URL of the inference endpoint (e.g., `http://localhost:11434`)" - }, - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Optional display name for UI" - }, - "extra_env": { - "type": "object", - "description": "Additional environment variables set when spawning agents that use this server", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SectionRowDto" } }, - "kind": { + "description": { + "type": "string" + }, + "health": { "type": "string", - "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" + "description": "Health: \"green\" | \"yellow\" | \"red\" | \"gray\"." }, - "name": { + "id": { "type": "string", - "description": "Unique name (e.g., \"ollama-local\")" + "description": "Stable section id (e.g. \"config\", \"connections\", \"kanban\")." }, - "user_declared": { + "label": { + "type": "string" + }, + "met": { "type": "boolean", - "description": "Whether this is a user-declared server (true) or an implicit builtin (false)" + "description": "Whether all prerequisites are met. Sections are always returned (the web\nUI styles unmet ones as locked) rather than hidden by progressive disclosure." + }, + "prerequisites": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Section ids that must be Green before this section is usable." } } }, - "ModelServersResponse": { + "SectionRowDto": { "type": "object", - "description": "Response listing all model servers (declared + implicit builtins)", + "description": "A child row within a status section.", "required": [ - "servers", - "total" + "id", + "depth", + "label", + "description", + "icon", + "health", + "actions" ], "properties": { - "servers": { + "actions": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelServerResponse" + "$ref": "#/components/schemas/RowActionDto" }, - "description": "List of model servers" + "description": "Browser-openable actions for this row (links shown in the web UI)." + }, + "depth": { + "type": "integer", + "format": "int32", + "description": "Nesting depth within the section (1 = direct child, 2 = grandchild).\nLets clients rebuild the tree (e.g. LLM tools → model aliases).", + "minimum": 0 + }, + "description": { + "type": "string" + }, + "health": { + "type": "string", + "description": "Health: \"green\" | \"yellow\" | \"red\" | \"gray\"." + }, + "icon": { + "type": "string", + "description": "Icon hint (e.g. \"check\", \"warning\", \"tool\", \"folder\")." + }, + "id": { + "type": "string", + "description": "Stable, section-scoped row id. Clients use it as a tree key and to route\nrow-specific commands without matching on the (mutable) display label.\nDynamic rows carry their entity key (issue-type key, project name);\nstatic rows carry a fixed slug (e.g. \"git-token\")." + }, + "label": { + "type": "string" + } + } + }, + "SetDefaultLlmRequest": { + "type": "object", + "description": "Request to set the global default LLM tool and model", + "required": [ + "tool", + "model" + ], + "properties": { + "model": { + "type": "string", + "description": "Model alias (e.g., \"opus\", \"sonnet\")" + }, + "tool": { + "type": "string", + "description": "Tool name (must match a detected tool, e.g., \"claude\")" + } + } + }, + "SetKanbanSessionEnvRequest": { + "type": "object", + "description": "Request to set kanban-related env vars on the server for the current\nsession so subsequent `from_config` calls find the API key.", + "required": [ + "provider" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubSessionEnv" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraSessionEnv" + } + ] }, - "total": { - "type": "integer", - "description": "Total count", - "minimum": 0 + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearSessionEnv" + } + ] + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" } } }, - "SetDefaultLlmRequest": { + "SetKanbanSessionEnvResponse": { "type": "object", - "description": "Request to set the global default LLM tool and model", + "description": "Response from setting session env vars.\n\n`shell_export_block` uses `` placeholders, NOT the actual\nsecret — it is meant for the user to copy into their shell profile.", "required": [ - "tool", - "model" + "env_vars_set", + "shell_export_block" ], "properties": { - "model": { - "type": "string", - "description": "Model alias (e.g., \"opus\", \"sonnet\")" + "env_vars_set": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names (not values) of env vars that were set in the server process." }, - "tool": { + "shell_export_block": { "type": "string", - "description": "Tool name (must match a detected tool, e.g., \"claude\")" + "description": "Multi-line `export FOO=\"\"` block for the user to copy\ninto `~/.zshrc` / `~/.bashrc`." } } }, @@ -2198,6 +4719,172 @@ } } }, + "StdioCommand": { + "type": "object", + "description": "Stdio entrypoint advertised in the descriptor when\n`[mcp].stdio_advertised = true`. Clients use this to spawn operator\nas an MCP subprocess instead of (or alongside) the SSE transport.", + "required": [ + "command", + "args", + "cwd" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Args to pass: typically `[\"mcp\"]`" + }, + "command": { + "type": "string", + "description": "Absolute path to the operator binary (the same binary serving this descriptor)" + }, + "cwd": { + "type": "string", + "description": "Working directory the client should set when spawning. Defaults to the\noperator process's current working directory." + } + } + }, + "StepCompleteRequest": { + "type": "object", + "description": "Request to report step completion (from opr8r wrapper)", + "required": [ + "exit_code", + "duration_secs" + ], + "properties": { + "duration_secs": { + "type": "integer", + "format": "int64", + "description": "Duration of the step in seconds", + "minimum": 0 + }, + "exit_code": { + "type": "integer", + "format": "int32", + "description": "Exit code from the LLM command" + }, + "output": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OperatorOutput", + "description": "Structured output from agent (parsed `OPERATOR_STATUS` block)" + } + ] + }, + "output_sample": { + "type": [ + "string", + "null" + ], + "description": "Sample of the output (first N chars for debugging)" + }, + "output_schema_errors": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "List of validation errors (if `output_valid` is false)" + }, + "output_valid": { + "type": "boolean", + "description": "Whether output validation passed (if schema was specified)" + }, + "session_id": { + "type": [ + "string", + "null" + ], + "description": "Session ID from the LLM session" + } + } + }, + "StepCompleteResponse": { + "type": "object", + "description": "Response from step completion endpoint", + "required": [ + "status", + "auto_proceed" + ], + "properties": { + "auto_proceed": { + "type": "boolean", + "description": "Whether to automatically proceed to the next step" + }, + "circuit_state": { + "type": "string", + "description": "Circuit breaker state: closed (normal), `half_open` (monitoring), open (halted)" + }, + "cumulative_errors": { + "type": "integer", + "format": "int32", + "description": "Cumulative errors across iterations", + "minimum": 0 + }, + "cumulative_files_modified": { + "type": "integer", + "format": "int32", + "description": "Cumulative files modified across iterations", + "minimum": 0 + }, + "iteration_count": { + "type": "integer", + "format": "int32", + "description": "How many times this step has run (for circuit breaker)", + "minimum": 0 + }, + "next_command": { + "type": [ + "string", + "null" + ], + "description": "Command to execute for the next step (opr8r wrapped)" + }, + "next_step": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NextStepInfo", + "description": "Information about the next step (if any)" + } + ] + }, + "output_valid": { + "type": "boolean", + "description": "Whether `OperatorOutput` was successfully parsed from agent output" + }, + "previous_recommendation": { + "type": [ + "string", + "null" + ], + "description": "Recommendation from previous step's `OperatorOutput`" + }, + "previous_summary": { + "type": [ + "string", + "null" + ], + "description": "Summary from previous step's `OperatorOutput`" + }, + "should_iterate": { + "type": "boolean", + "description": "Agent has more work (`exit_signal=false`) - indicates iteration needed" + }, + "status": { + "type": "string", + "description": "Status of the step: \"completed\", \"`awaiting_review`\", \"failed\", \"iterate\"" + } + } + }, "StepResponse": { "type": "object", "description": "Response for a step", @@ -2249,6 +4936,155 @@ } } }, + "SyncKanbanIssueTypesResponse": { + "type": "object", + "description": "Response from syncing kanban issue types from a provider.", + "required": [ + "synced", + "types" + ], + "properties": { + "synced": { + "type": "integer", + "description": "Number of issue types synced", + "minimum": 0 + }, + "types": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KanbanIssueTypeResponse" + }, + "description": "The synced issue types" + } + } + }, + "TicketDetailResponse": { + "type": "object", + "description": "Full ticket details including content and metadata", + "required": [ + "id", + "summary", + "ticket_type", + "project", + "status", + "step", + "priority", + "timestamp", + "content", + "filename", + "filepath", + "sessions", + "step_delegators" + ], + "properties": { + "branch": { + "type": [ + "string", + "null" + ], + "description": "Git branch name" + }, + "content": { + "type": "string", + "description": "Full markdown content of the ticket" + }, + "external_id": { + "type": [ + "string", + "null" + ], + "description": "External issue ID from kanban provider" + }, + "external_provider": { + "type": [ + "string", + "null" + ], + "description": "Provider name (e.g., \"jira\", \"linear\")" + }, + "external_url": { + "type": [ + "string", + "null" + ], + "description": "URL to the issue in the external provider" + }, + "filename": { + "type": "string", + "description": "Ticket filename" + }, + "filepath": { + "type": "string", + "description": "Full filesystem path" + }, + "id": { + "type": "string", + "description": "Ticket ID (e.g., \"FEAT-7598\")" + }, + "priority": { + "type": "string", + "description": "Priority: P0-critical, P1-high, P2-medium, P3-low" + }, + "project": { + "type": "string", + "description": "Project name" + }, + "sessions": { + "type": "object", + "description": "Session IDs per step (`step_name` -> `session_uuid`)", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "status": { + "type": "string", + "description": "Current status: queued, running, awaiting, completed" + }, + "step": { + "type": "string", + "description": "Current step name" + }, + "step_delegators": { + "type": "object", + "description": "Delegator used per step (`step_name` -> `delegator_name`)", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "step_display_name": { + "type": [ + "string", + "null" + ], + "description": "Human-readable step name" + }, + "summary": { + "type": "string", + "description": "Ticket summary/title" + }, + "ticket_type": { + "type": "string", + "description": "Ticket type: FEAT, FIX, INV, SPIKE" + }, + "timestamp": { + "type": "string", + "description": "Timestamp (YYYYMMDD-HHMM format)" + }, + "worktree_path": { + "type": [ + "string", + "null" + ], + "description": "Path to git worktree (if created)" + } + } + }, "ToolCapabilities": { "type": "object", "description": "Tool capabilities", @@ -2308,18 +5144,62 @@ }, "project_required": { "type": [ - "boolean", + "boolean", + "null" + ] + }, + "steps": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/CreateStepRequest" + } + } + } + }, + "UpdateModelServerRequest": { + "type": "object", + "description": "Request to update an existing user-declared model server.\n\nAll fields except `kind` are replaced; `name` is taken from the path.", + "required": [ + "kind" + ], + "properties": { + "api_key_env": { + "type": [ + "string", + "null" + ], + "description": "Name of an env var providing the API key" + }, + "base_url": { + "type": [ + "string", "null" - ] + ], + "description": "Base URL of the inference endpoint" }, - "steps": { + "display_name": { "type": [ - "array", + "string", "null" ], - "items": { - "$ref": "#/components/schemas/CreateStepRequest" + "description": "Optional display name for UI" + }, + "extra_env": { + "type": "object", + "description": "Additional environment variables", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" } + }, + "kind": { + "type": "string", + "description": "Kind: \"ollama\", \"openai-compat\", \"anthropic-api\", \"openai-api\", \"google-api\", \"lmstudio\"" } } }, @@ -2377,6 +5257,328 @@ "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\"" } } + }, + "UpdateTicketStatusRequest": { + "type": "object", + "description": "Request to update a ticket's status", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "description": "Target status: queued, running, awaiting, done" + } + } + }, + "UpdateTicketStatusResponse": { + "type": "object", + "description": "Response from updating a ticket's status", + "required": [ + "id", + "previous_status", + "status", + "message" + ], + "properties": { + "id": { + "type": "string", + "description": "Ticket ID" + }, + "message": { + "type": "string", + "description": "Human-readable message" + }, + "previous_status": { + "type": "string", + "description": "Previous status before the update" + }, + "status": { + "type": "string", + "description": "New status after the update" + } + } + }, + "ValidateKanbanCredentialsRequest": { + "type": "object", + "description": "Request to validate kanban credentials without persisting them.", + "required": [ + "provider" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubCredentials" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraCredentials" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearCredentials" + } + ] + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "ValidateKanbanCredentialsResponse": { + "type": "object", + "description": "Response from validating kanban credentials.\n\n`valid: false` is returned for auth failures — never a 4xx/5xx HTTP\nstatus — so clients can display `error` inline without exception handling.", + "required": [ + "valid" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GithubValidationDetailsDto" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JiraValidationDetailsDto" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LinearValidationDetailsDto" + } + ] + }, + "valid": { + "type": "boolean" + } + } + }, + "WorkflowExportResponse": { + "type": "object", + "description": "Response for exporting a ticket to a Claude dynamic workflow (`.js`).", + "required": [ + "ticket_id", + "issuetype_key", + "suggested_filename", + "contents" + ], + "properties": { + "contents": { + "type": "string", + "description": "The generated `.js` workflow source." + }, + "issuetype_key": { + "type": "string", + "description": "The issue type key that supplied the step structure." + }, + "suggested_filename": { + "type": "string", + "description": "Suggested filename for saving the workflow (`.workflow.js`)." + }, + "ticket_id": { + "type": "string", + "description": "The ticket the workflow was generated from." + } + } + }, + "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.", + "required": [ + "issuetype_key", + "suggested_filename", + "contents" + ], + "properties": { + "contents": { + "type": "string", + "description": "The generated `.js` workflow source (placeholder ticket values)." + }, + "issuetype_key": { + "type": "string", + "description": "The issue type key the preview was generated from." + }, + "suggested_filename": { + "type": "string", + "description": "Suggested filename for saving the preview (`.preview.workflow.js`)." + } + } + }, + "WriteGithubConfigBody": { + "type": "object", + "description": "Body for writing a GitHub Projects v2 config section.", + "required": [ + "owner", + "api_key_env", + "project_key", + "sync_user_id" + ], + "properties": { + "api_key_env": { + "type": "string", + "description": "Env var name where the project-scoped token is set\n(default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN`\n— see Token Disambiguation in the kanban github docs." + }, + "owner": { + "type": "string", + "description": "GitHub owner login (user or org), used as the workspace key" + }, + "project_key": { + "type": "string", + "description": "`GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`)" + }, + "sync_user_id": { + "type": "string", + "description": "Numeric GitHub `databaseId` of the user whose items to sync" + } + } + }, + "WriteJiraConfigBody": { + "type": "object", + "description": "Body for writing a Jira project config section.", + "required": [ + "domain", + "email", + "api_key_env", + "project_key", + "sync_user_id" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "email": { + "type": "string" + }, + "project_key": { + "type": "string" + }, + "sync_user_id": { + "type": "string" + } + } + }, + "WriteKanbanConfigRequest": { + "type": "object", + "description": "Request to write or upsert a kanban config section.\n\nThis endpoint does NOT take the secret — only the env var NAME\n(`api_key_env`). The secret is set via `/api/v1/kanban/session-env`.", + "required": [ + "provider" + ], + "properties": { + "github": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteGithubConfigBody" + } + ] + }, + "jira": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteJiraConfigBody" + } + ] + }, + "linear": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/WriteLinearConfigBody" + } + ] + }, + "provider": { + "$ref": "#/components/schemas/KanbanProviderKind" + } + } + }, + "WriteKanbanConfigResponse": { + "type": "object", + "description": "Response after writing a kanban config section.", + "required": [ + "written_path", + "section_header" + ], + "properties": { + "section_header": { + "type": "string", + "description": "Header of the top-level section that was upserted\n(e.g., `[kanban.jira.\"acme.atlassian.net\"]`)" + }, + "written_path": { + "type": "string", + "description": "Filesystem path that was written (e.g., \".tickets/operator/config.toml\")" + } + } + }, + "WriteLinearConfigBody": { + "type": "object", + "description": "Body for writing a Linear project/team config section.", + "required": [ + "workspace_key", + "api_key_env", + "project_key", + "sync_user_id" + ], + "properties": { + "api_key_env": { + "type": "string" + }, + "project_key": { + "type": "string" + }, + "sync_user_id": { + "type": "string" + }, + "workspace_key": { + "type": "string" + } + } } } }, @@ -2385,6 +5587,10 @@ "name": "Health", "description": "Health check and status endpoints" }, + { + "name": "Status", + "description": "Canonical status sections (TUI / VS Code parity)" + }, { "name": "Issue Types", "description": "Issue type CRUD operations" @@ -2397,10 +5603,18 @@ "name": "Collections", "description": "Issue type collection management" }, + { + "name": "Tickets", + "description": "Ticket CRUD and status management" + }, { "name": "Launch", "description": "Ticket launch operations" }, + { + "name": "Workflow", + "description": "Export tickets to Claude dynamic workflows" + }, { "name": "Skills", "description": "Skill discovery across LLM tools" @@ -2416,6 +5630,26 @@ { "name": "MCP", "description": "Model Context Protocol integration" + }, + { + "name": "Queue", + "description": "Ticket queue board, status, and control" + }, + { + "name": "Agents", + "description": "Active agent tracking and review actions" + }, + { + "name": "Projects", + "description": "Project discovery and ticket assessment" + }, + { + "name": "Configuration", + "description": "Operator configuration read/write" + }, + { + "name": "Kanban", + "description": "Kanban provider issue types and onboarding" } ] } \ No newline at end of file diff --git a/docs/schemas/project_analysis.json b/docs/schemas/project_analysis.json index fbd2ca21..a983e695 100644 --- a/docs/schemas/project_analysis.json +++ b/docs/schemas/project_analysis.json @@ -915,5 +915,5 @@ } }, "$id": "https://gbqr.us/operator/project-analysis.schema.json", - "$comment": "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema" + "$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/docs/shortcuts/index.md b/docs/shortcuts/index.md index b0956df7..e99d3d49 100644 --- a/docs/shortcuts/index.md +++ b/docs/shortcuts/index.md @@ -32,7 +32,7 @@ Operator uses vim-style keybindings for navigation and actions. This reference d | `S` | Sync kanban collections | Dashboard | | `Y/y` | Approve review (agents panel) | Dashboard | | `X/x` | Reject review (agents panel) | Dashboard | -| `W/w` | Toggle Backstage server | Dashboard | +| `W/w` | Open web UI in browser | Dashboard | | `V/v` | Show session preview | Dashboard | | `F` | Focus cmux window | Dashboard | | `C` | Create new ticket | Dashboard | @@ -92,7 +92,7 @@ These shortcuts are available in the main dashboard view. | `S` | Sync kanban collections | | `Y/y` | Approve review (agents panel) | | `X/x` | Reject review (agents panel) | -| `W/w` | Toggle Backstage server | +| `W/w` | Open web UI in browser | | `V/v` | Show session preview | | `F` | Focus cmux window | diff --git a/docs/superpowers/plans/2026-05-16-acp-agent.md b/docs/superpowers/plans/2026-05-16-acp-agent.md new file mode 100644 index 00000000..76fddd8a --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-acp-agent.md @@ -0,0 +1,977 @@ +# Operator as an ACP Agent — Editor-Hosted Sessions over Stdio + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. + +**Goal:** Make operator runnable as an Agent Client Protocol (ACP) agent so editors that speak ACP — Zed, JetBrains (via the ACP Agent Registry), Emacs (`agent-shell`), Kiro, OpenCode, marimo, and Eclipse — can launch operator as a subprocess and host kanban-aware sessions inside the IDE. The editor becomes the chat surface; operator owns the ticket lifecycle and routes work to the configured delegator. + +**Architecture:** ACP is JSON-RPC 2.0 over stdio, bidirectional (both sides may initiate requests), with a session-based model. Editor spawns operator via `operator acp`, sends `initialize`, then `session/new` with a working directory, then `session/prompt` with user text. Operator responds with streaming `session/update` notifications and a final prompt response. Operator's v1 strategy is **bridge mode**: each ACP session corresponds to one operator ticket, and `session/prompt` launches a delegator subprocess (Claude Code, Codex CLI, Gemini CLI — whatever is configured) whose output is translated into ACP `session/update` chunks. Operator does **not** try to be the LLM-driving agent itself; it's the orchestrator that owns "which ticket, which delegator, which project." Lifecycle, config, and status integration mirror the existing `RestApiServer` pattern (`src/rest/server.rs`). + +**Tech Stack:** Rust 1.88+, tokio (async stdio + subprocess), `agent-client-protocol` crate (official Rust SDK from `github.com/agentclientprotocol/agent-client-protocol`), serde_json, clap (CLI), ratatui (status integration), the existing delegator infrastructure under `src/agents/`. + +--- + +## Critical Structural Approach + +Four decisions lock the rest of the plan: + +1. **Depend on the official `agent-client-protocol` Rust crate.** Operator already hand-rolls MCP JSON-RPC types because MCP is simple and the surface is small. ACP is bidirectional and has dozens of method shapes — hand-rolling is a maintenance trap. The Zed-published crate provides the `Agent` trait and message types; operator implements the trait. Verify the latest crate version on crates.io before pinning. + +2. **One ACP session = one operator ticket.** When the editor calls `session/new`, operator either (a) parses the working directory and prompt to attach to an existing in-progress ticket, or (b) creates a new ticket from a system prompt. The `sessionId` returned to the editor is the ticket UUID. This makes the ACP session traceable in the kanban board and lets the same session be resumed via `session/load` after a restart. + +3. **`session/prompt` does not run an LLM in-process — it delegates.** Operator spawns its configured delegator (Claude Code, Codex CLI, Gemini CLI) via the existing `src/agents/launcher.rs` infrastructure. The delegator's stdout/stderr is translated, line by line, into ACP `session/update` notifications. This keeps operator's orchestration role honest: it does not compete with the agent runtimes; it composes them. + +4. **Mirror the `RestApiServer` lifecycle for the stdio listener.** Even though `operator acp` is typically spawned by an editor (so its lifetime is bound to one editor connection), operator's TUI may also launch ACP listeners for inspection/testing. The `AcpAgentServer` handle (Status enum, `Arc>`, oneshot shutdown, session-file in `.operator/acp-session.json`) follows the same shape as `src/rest/server.rs:75-218`. The status panel (`ConnectionsSection`) gets an "ACP" row alongside "Operator API" and "MCP". + +The first task verifies the crate name and version. Everything else hangs off task 1. + +--- + +## File Structure + +**Create:** +- `src/acp/mod.rs` — module root, public re-exports +- `src/acp/agent.rs` — `OperatorAcpAgent` struct implementing the crate's `Agent` trait +- `src/acp/session.rs` — `AcpSession` (sessionId ↔ ticket ↔ delegator subprocess) +- `src/acp/translator.rs` — converts delegator stdout lines into ACP `session/update` notifications +- `src/acp/server.rs` — `AcpAgentServer` lifecycle handle (mirrors `RestApiServer`) +- `src/acp/client_configs.rs` — config snippets for Zed, JetBrains, Emacs, Kiro +- `tests/acp_integration.rs` — spawn `operator acp`, send initialize + session/new, assert response shape + +**Modify:** +- `Cargo.toml` — add `agent-client-protocol` dependency +- `src/main.rs:24-35` (modules), `:131-266` (Commands), `:281-362` (match arm), bottom (`cmd_acp`) +- `src/config.rs` — add `AcpConfig` struct + field on `Config` +- `src/ui/status_panel.rs` — add ACP fields to `StatusSnapshot`, new `StatusAction` variants +- `src/ui/sections/connections_section.rs` — add an ACP row + +--- + +## Tasks + +### Task 1: Add the `agent-client-protocol` crate dependency + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Find the latest crate version** + +Run: `cargo search agent-client-protocol --limit 5` +Expected output (similar): `agent-client-protocol = "0.21.0" # Rust SDK for ACP` + +Record the latest version. If the search fails or returns no results, fall back to checking the GitHub releases page at `https://github.com/agentclientprotocol/agent-client-protocol/releases` and reading the `Cargo.toml` in the `rust/` subdirectory. + +- [ ] **Step 2: Add to `Cargo.toml`** + +Edit `Cargo.toml`. Under `[dependencies]`, add: + +```toml +agent-client-protocol = "0.21" # pin to the version from Step 1 +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: clean build (just downloads + compiles the new crate; no operator code uses it yet). + +- [ ] **Step 4: Skim the crate's `Agent` trait** + +Run: `cargo doc --open --package agent-client-protocol` +Read the `Agent` trait and its associated message types. The remaining tasks reference method names from this trait. If the trait has changed shape relative to this plan (rename, new required method), pause and update the plan before proceeding. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 2: Scaffold `src/acp/` and wire it into the crate + +**Files:** +- Create: `src/acp/mod.rs` +- Modify: `src/main.rs:24-35` (module list) + +- [ ] **Step 1: Create the module file** + +Create `src/acp/mod.rs`: + +```rust +//! Agent Client Protocol (ACP) integration for Operator. +//! +//! Operator runs as an ACP agent that editors (Zed, JetBrains, Emacs, +//! Kiro, etc.) launch as a stdio subprocess. Each ACP session maps to +//! one operator ticket. Prompts are delegated to the configured runtime +//! (Claude Code, Codex CLI, Gemini CLI), and the delegator's stream is +//! translated into ACP `session/update` notifications. +//! +//! See: https://agentclientprotocol.com/ + +pub mod agent; +pub mod client_configs; +pub mod server; +pub mod session; +pub mod translator; +``` + +- [ ] **Step 2: Register the module** + +In `src/main.rs` around line 27 (alongside `mod mcp;`), add: + +```rust +mod acp; +``` + +- [ ] **Step 3: Verify** + +Run: `cargo build` +Expected: FAIL with "unresolved module" for each of `agent`, `client_configs`, `server`, `session`, `translator` — files don't exist yet. Comment out the unresolved lines, leaving only `pub mod agent;` (the first one we'll fill in). Or proceed straight to Task 3. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 3: Implement `OperatorAcpAgent` skeleton — initialize + capabilities + +**Files:** +- Create: `src/acp/agent.rs` + +The exact trait method signatures depend on the crate version pinned in Task 1. Adjust if the crate's `Agent` trait differs from what's shown here. Consult `cargo doc --open --package agent-client-protocol`. + +- [ ] **Step 1: Write a failing test for the initialize response** + +Create `src/acp/agent.rs`: + +```rust +//! Operator's implementation of the ACP `Agent` trait. + +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::acp::session::SessionRegistry; +use crate::config::Config; + +/// The operator-side ACP agent. +/// +/// Holds operator state needed to handle ACP requests: config, the +/// per-session ticket registry, and a handle to the delegator launcher. +pub struct OperatorAcpAgent { + pub config: Config, + pub sessions: Arc>, +} + +impl OperatorAcpAgent { + pub fn new(config: Config) -> Self { + Self { + config, + sessions: Arc::new(Mutex::new(SessionRegistry::default())), + } + } +} + +// ---- ACP Agent trait implementation ---- +// +// The exact trait shape and method signatures come from the +// `agent-client-protocol` crate. Look at the trait definition (cargo doc) +// and implement each required method. The skeleton below shows the four +// methods we need for v1: +// +// - initialize: return capabilities +// - new_session: create a session + ticket, return sessionId +// - prompt: delegate to the configured runtime, stream updates +// - cancel: signal the in-flight delegator to stop +// +// Use the crate's request/response types verbatim — do not re-define them. + +#[async_trait::async_trait] +impl agent_client_protocol::Agent for OperatorAcpAgent { + async fn initialize( + &self, + _params: agent_client_protocol::InitializeParams, + ) -> Result { + Ok(agent_client_protocol::InitializeResponse { + protocol_version: agent_client_protocol::PROTOCOL_VERSION, + agent_capabilities: agent_client_protocol::AgentCapabilities { + load_session: false, // v1: no resume + prompt_capabilities: Default::default(), + }, + auth_methods: vec![], + }) + } + + async fn new_session( + &self, + params: agent_client_protocol::NewSessionParams, + ) -> Result { + let session_id = self.sessions.lock().await.create_session(&self.config, ¶ms).await + .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; + Ok(agent_client_protocol::NewSessionResponse { session_id }) + } + + async fn prompt( + &self, + _params: agent_client_protocol::PromptParams, + ) -> Result { + // Implemented in Task 5 — for now return a placeholder so the trait compiles. + Err(agent_client_protocol::Error::method_not_supported( + "session/prompt not yet implemented", + )) + } + + async fn cancel( + &self, + _params: agent_client_protocol::CancelParams, + ) -> Result<(), agent_client_protocol::Error> { + Err(agent_client_protocol::Error::method_not_supported( + "session/cancel not yet implemented", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_initialize_advertises_v1_capabilities() { + let agent = OperatorAcpAgent::new(Config::default()); + let resp = agent.initialize(agent_client_protocol::InitializeParams::default()).await.unwrap(); + // v1 does not implement session/load — explicitly assert that + assert!(!resp.agent_capabilities.load_session); + } +} +``` + +- [ ] **Step 2: Run the test (expect compilation issues)** + +Run: `cargo test acp::agent` +Expected: Likely FAIL due to type/method-name mismatches with whatever version of the crate is pinned. Read the compile errors, look at `cargo doc`, and adjust the types/field names to match the crate's actual API. **Do not invent type names**; mirror what the crate exports. + +- [ ] **Step 3: Verify the test passes** + +Run: `cargo test acp::agent` +Expected: PASS. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 4: Implement `SessionRegistry` — sessionId ↔ ticket mapping + +**Files:** +- Create: `src/acp/session.rs` + +- [ ] **Step 1: Write a failing test** + +```rust +//! ACP session registry. +//! +//! Maps ACP session IDs (UUIDs) to operator tickets and the spawned +//! delegator subprocess for the in-flight prompt. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::process::Child; +use tokio::sync::Mutex; + +use crate::config::Config; + +#[derive(Default)] +pub struct SessionRegistry { + sessions: HashMap, +} + +pub struct AcpSession { + pub session_id: String, + pub ticket_id: String, + pub working_directory: PathBuf, + pub delegator: Option>>, +} + +impl SessionRegistry { + /// Create a new session, allocating a ticket. + /// + /// The ticket is created in `.tickets/in-progress/` immediately + /// (because the editor is actively using it) using the existing + /// `services::ticket_manager`. + pub async fn create_session( + &mut self, + config: &Config, + params: &agent_client_protocol::NewSessionParams, + ) -> Result { + let session_id = uuid::Uuid::new_v4().to_string(); + let ticket_id = crate::services::ticket_manager::create_in_progress( + config, + ¶ms.cwd, + &format!("ACP session from {}", params.cwd.display()), + ).await?; + let session = AcpSession { + session_id: session_id.clone(), + ticket_id, + working_directory: params.cwd.clone(), + delegator: None, + }; + self.sessions.insert(session_id.clone(), session); + Ok(session_id) + } + + pub fn get(&self, session_id: &str) -> Option<&AcpSession> { + self.sessions.get(session_id) + } + + pub fn get_mut(&mut self, session_id: &str) -> Option<&mut AcpSession> { + self.sessions.get_mut(session_id) + } + + pub fn remove(&mut self, session_id: &str) -> Option { + self.sessions.remove(session_id) + } + + pub fn len(&self) -> usize { + self.sessions.len() + } + + pub fn is_empty(&self) -> bool { + self.sessions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_session_registry_create_and_lookup() { + let mut reg = SessionRegistry::default(); + let temp = tempfile::TempDir::new().unwrap(); + let config = { + let mut c = Config::default(); + c.paths.tickets_dir = temp.path().to_string_lossy().to_string(); + c + }; + let params = agent_client_protocol::NewSessionParams { + cwd: temp.path().to_path_buf(), + mcp_servers: vec![], + }; + let session_id = reg.create_session(&config, ¶ms).await.unwrap(); + assert!(reg.get(&session_id).is_some()); + assert_eq!(reg.len(), 1); + } +} +``` + +- [ ] **Step 2: Add the supporting `ticket_manager::create_in_progress` function** + +Grep for the existing ticket creation surface (`rg "fn create_ticket|fn write_ticket" src/`). If `create_in_progress` doesn't exist, add it alongside the existing creation function. It should write `.tickets/in-progress/{id}.md` with minimal frontmatter and return the ticket ID. + +- [ ] **Step 3: Run the test** + +Run: `cargo test acp::session` +Expected: PASS. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 5: Implement `session/prompt` — bridge to delegator + stream updates + +**Files:** +- Modify: `src/acp/agent.rs` (replace the placeholder `prompt` impl) +- Create: `src/acp/translator.rs` + +This is the core of operator-as-ACP. When the editor calls `session/prompt`, operator (a) looks up the session, (b) spawns the configured delegator with the prompt as input, (c) reads delegator stdout line-by-line, (d) emits ACP `session/update` notifications for each chunk, (e) returns the final response when the delegator exits. + +- [ ] **Step 1: Implement the translator** + +Create `src/acp/translator.rs`: + +```rust +//! Translate delegator subprocess output into ACP session/update notifications. +//! +//! Different delegators (Claude Code, Codex CLI, Gemini CLI) have different +//! stdout formats. This module hosts per-delegator translators. v1 implements +//! the simplest case: treat each non-empty line as an `assistant_message_chunk`. + +use agent_client_protocol::SessionUpdate; + +pub fn line_to_update(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + // Future: parse JSON-formatted output from `claude --output-format stream-json` + // and emit structured tool-call / tool-result updates. For v1, plain text. + Some(SessionUpdate::AssistantMessageChunk { + content: agent_client_protocol::ContentBlock::Text { + text: format!("{}\n", trimmed), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_blank_line_ignored() { + assert!(line_to_update("").is_none()); + assert!(line_to_update(" ").is_none()); + } + + #[test] + fn test_text_line_becomes_chunk() { + let update = line_to_update("hello").unwrap(); + match update { + SessionUpdate::AssistantMessageChunk { content } => match content { + agent_client_protocol::ContentBlock::Text { text } => { + assert!(text.contains("hello")); + } + _ => panic!("expected text content"), + }, + _ => panic!("expected assistant message chunk"), + } + } +} +``` + +(Field names like `SessionUpdate::AssistantMessageChunk` and `ContentBlock::Text` come from the crate — adjust if the crate uses different names.) + +- [ ] **Step 2: Replace the placeholder `prompt` in `agent.rs`** + +Replace the placeholder `prompt` impl from Task 3 with: + +```rust + async fn prompt( + &self, + params: agent_client_protocol::PromptParams, + notifier: agent_client_protocol::Notifier, + ) -> Result { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let session_id = params.session_id.clone(); + let (cwd, ticket_id) = { + let sessions = self.sessions.lock().await; + let s = sessions.get(&session_id).ok_or_else(|| { + agent_client_protocol::Error::invalid_params(format!("Unknown session: {session_id}")) + })?; + (s.working_directory.clone(), s.ticket_id.clone()) + }; + + // Build the delegator command from operator's configured default + let delegator = crate::agents::launcher::resolve_default_delegator(&self.config) + .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; + let prompt_text = params.prompt.iter() + .filter_map(|block| match block { + agent_client_protocol::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + + let mut child = tokio::process::Command::new(&delegator.command) + .args(&delegator.args) + .current_dir(&cwd) + .env("OPERATOR_TICKET_ID", &ticket_id) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| agent_client_protocol::Error::internal(format!("spawn delegator: {e}")))?; + + // Pipe the prompt to the delegator's stdin + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin.write_all(prompt_text.as_bytes()).await; + let _ = stdin.write_all(b"\n").await; + } + + // Stream stdout → session/update notifications + if let Some(stdout) = child.stdout.take() { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(update) = crate::acp::translator::line_to_update(&line) { + notifier.session_update(&session_id, update).await.ok(); + } + } + } + + let status = child.wait().await + .map_err(|e| agent_client_protocol::Error::internal(e.to_string()))?; + + Ok(agent_client_protocol::PromptResponse { + stop_reason: if status.success() { + agent_client_protocol::StopReason::EndTurn + } else { + agent_client_protocol::StopReason::Refusal + }, + }) + } +``` + +(`agents::launcher::resolve_default_delegator` may need to be added if not present — it returns a `Delegator` struct with `command` and `args` based on operator's `[delegators]` config.) + +- [ ] **Step 3: Add a smoke test using `cat` as the delegator** + +Append to `src/acp/agent.rs::tests`: + +```rust + #[tokio::test] + async fn test_prompt_uses_cat_as_delegator_smoke() { + // Verifies the pipe-through path end-to-end using /bin/cat as the + // fake delegator. Skipped on non-unix. + #[cfg(unix)] + { + // ... configure agent with a Delegator { command: "cat", args: [] } + // ... call agent.prompt with text "hello" + // ... assert at least one AssistantMessageChunk with "hello" + } + } +``` + +(Filled in by the engineer using whatever mocking surface the crate's `Notifier` provides — likely a `MockNotifier` collected into a `Vec`.) + +- [ ] **Step 4: Run** + +Run: `cargo test acp::` +Expected: PASS. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 6: `operator acp` CLI subcommand + +**Files:** +- Create: `src/acp/server.rs` +- Modify: `src/main.rs:131-266` (Commands), `:281-362` (match), bottom (`cmd_acp`) + +- [ ] **Step 1: Implement the stdio entrypoint in `src/acp/server.rs`** + +```rust +//! ACP server lifecycle — runs the stdio listener. +//! +//! Mirrors the shape of `src/rest/server.rs:RestApiServer` so the TUI can +//! query status / start / stop. + +use std::sync::{Arc, Mutex}; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::config::Config; + +#[derive(Debug, Clone, PartialEq)] +pub enum AcpStatus { + Stopped, + Starting, + Running { active_sessions: usize }, + Stopping, + Error(String), +} + +pub struct AcpAgentServer { + config: Config, + status: Arc>, + shutdown_tx: Arc>>>, + task_handle: Arc>>>, +} + +impl AcpAgentServer { + pub fn new(config: Config) -> Self { + Self { + config, + status: Arc::new(Mutex::new(AcpStatus::Stopped)), + shutdown_tx: Arc::new(Mutex::new(None)), + task_handle: Arc::new(Mutex::new(None)), + } + } + + pub fn status(&self) -> AcpStatus { + self.status.lock().unwrap().clone() + } + + pub fn is_running(&self) -> bool { + matches!(self.status(), AcpStatus::Running { .. }) + } +} + +/// Run the ACP stdio listener using the given reader/writer. +/// +/// Production callers pass `tokio::io::stdin()` / `tokio::io::stdout()`. +pub async fn run(config: Config, reader: R, writer: W) -> anyhow::Result<()> +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let agent = crate::acp::agent::OperatorAcpAgent::new(config); + // Use the crate's stdio adapter to wire the Agent impl onto a stdio + // transport. Exact function name from `cargo doc --open --package agent-client-protocol`. + agent_client_protocol::stdio::serve(agent, reader, writer).await?; + Ok(()) +} +``` + +- [ ] **Step 2: Add the CLI subcommand** + +In `src/main.rs`, add an `Acp` variant after `Mcp`: + +```rust + /// Run as an ACP agent over stdio (for use by Zed, JetBrains, Emacs, Kiro, etc.). + Acp, +``` + +Add the match arm: + +```rust + Some(Commands::Acp) => { + cmd_acp(&config).await?; + } +``` + +Add `cmd_acp` at the bottom of main.rs: + +```rust +async fn cmd_acp(config: &Config) -> Result<()> { + tracing::info!("Starting ACP stdio agent"); + crate::acp::server::run(config.clone(), tokio::io::stdin(), tokio::io::stdout()).await?; + tracing::info!("ACP agent stopped (stdin closed)"); + Ok(()) +} +``` + +- [ ] **Step 3: Verify the binary runs** + +Run: `cargo build --release` +Run: +``` +printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' | ./target/release/operator acp +``` +Expected: One line of JSON containing the operator agent's `initialize` response. (Exact shape determined by the crate.) + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 7: Add `[acp]` config section + +**Files:** +- Modify: `src/config.rs` + +- [ ] **Step 1: Add `AcpConfig`** + +```rust +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AcpConfig { + /// Whether to advertise the ACP stdio entrypoint in the status panel. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Default delegator to use when an ACP session/prompt arrives. + /// If None, falls back to `delegators[0]`. + #[serde(default)] + pub default_delegator: Option, + /// Maximum number of concurrent ACP sessions. Defaults to 4. + #[serde(default = "default_max_sessions")] + pub max_concurrent_sessions: usize, +} + +impl Default for AcpConfig { + fn default() -> Self { + Self { + stdio_advertised: true, + default_delegator: None, + max_concurrent_sessions: 4, + } + } +} + +fn default_max_sessions() -> usize { 4 } +``` + +- [ ] **Step 2: Add the field to `Config`** + +```rust + #[serde(default)] + pub acp: AcpConfig, +``` + +Update `Config::default()`. + +- [ ] **Step 3: Regenerate config docs** + +Run: `cargo run -- docs --only config` +Verify the generated docs describe `[acp]`. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 8: ACP editor config snippet generator + +**Files:** +- Create: `src/acp/client_configs.rs` + +Editors integrate ACP agents by registering them in editor-specific config files. Generate the right snippet for each. + +- [ ] **Step 1: Implement** + +```rust +//! Generates copy-paste ACP agent registrations for various editors. + +use serde_json::{json, Value}; +use std::path::PathBuf; + +fn exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +/// Zed: agent_servers entry in settings.json +pub fn zed_snippet() -> Value { + json!({ + "agent_servers": { + "operator": { + "command": exe().to_string_lossy(), + "args": ["acp"], + "env": {} + } + } + }) +} + +/// JetBrains: registered via the ACP Agent Registry; the operator entry +/// is a JSON object that JetBrains imports. +pub fn jetbrains_snippet() -> Value { + json!({ + "name": "operator", + "displayName": "Operator (Kanban Orchestrator)", + "command": exe().to_string_lossy(), + "args": ["acp"], + "icon": "https://operator.untra.io/icon.png" + }) +} + +/// Emacs (agent-shell): elisp form to add to init +pub fn emacs_snippet() -> String { + format!( + "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))", + exe().display() + ) +} + +/// Kiro CLI: ~/.kiro/agents.toml entry +pub fn kiro_snippet() -> String { + format!( + "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n", + exe().display() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zed_snippet_shape() { + let snippet = zed_snippet(); + assert_eq!(snippet["agent_servers"]["operator"]["args"][0], "acp"); + } + + #[test] + fn test_emacs_snippet_is_valid_elisp() { + let snippet = emacs_snippet(); + assert!(snippet.starts_with("(add-to-list")); + assert!(snippet.contains("acp")); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test acp::client_configs` +Expected: PASS. + +- [ ] **Step 3: Stop for commit review** + +--- + +### Task 9: Integrate ACP into `StatusSnapshot` and `ConnectionsSection` + +**Files:** +- Modify: `src/ui/status_panel.rs` +- Modify: `src/ui/sections/connections_section.rs` + +- [ ] **Step 1: Add ACP fields to `StatusSnapshot`** + +```rust + /// Whether the `[acp]` stdio entrypoint is advertised. + pub acp_stdio_advertised: bool, + /// Active ACP sessions (only relevant if operator launched an ACP listener itself). + pub acp_active_sessions: usize, +``` + +- [ ] **Step 2: Add new `StatusAction` variants** + +```rust + /// Copy an ACP editor config snippet to the clipboard. + /// `editor` is one of: "zed", "jetbrains", "emacs", "kiro". + CopyAcpEditorConfig { editor: String }, + /// Open ACP setup docs in the browser. + OpenAcpDocs, +``` + +- [ ] **Step 3: Add an ACP row to `ConnectionsSection::children`** + +After the MCP row added by the MCP plan (Task 9), append: + +```rust + rows.push(TreeRow { + section_id: SectionId::Connections, + depth: 1, + label: "ACP".into(), + description: if snapshot.acp_stdio_advertised { + if snapshot.acp_active_sessions > 0 { + format!("stdio · {} sessions", snapshot.acp_active_sessions) + } else { + "stdio ready".into() + } + } else { + "Disabled".into() + }, + icon: if snapshot.acp_stdio_advertised { StatusIcon::Plug } else { StatusIcon::Cross }, + is_header: false, + actions: ActionSet { + primary: StatusAction::CopyAcpEditorConfig { editor: "zed".to_string() }, + back: StatusAction::None, + special: StatusAction::CopyAcpEditorConfig { editor: "jetbrains".to_string() }, + special_meta: Some(ActionMeta { title: "JBrn", tooltip: "Copy JetBrains ACP registry snippet" }), + refresh: StatusAction::OpenAcpDocs, + refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open ACP setup docs" }), + }, + health: SectionHealth::Gray, + }); +``` + +- [ ] **Step 4: Update `StatusSnapshot` construction site** + +Same pattern as the MCP plan's Task 9 Step 4: populate the new fields. `acp_active_sessions` defaults to `0` unless operator is also hosting an ACP listener itself (uncommon — the editor typically hosts). + +- [ ] **Step 5: Update test snapshots** + +Search test files for `StatusSnapshot {` (now including the MCP fields from the MCP plan) and add `acp_stdio_advertised: true, acp_active_sessions: 0`. + +- [ ] **Step 6: Add a test for the ACP row** + +```rust + #[test] + fn test_connections_acp_row_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let row = children.iter().find(|r| r.label == "ACP"); + assert!(row.is_some(), "ACP row should always be present"); + } +``` + +- [ ] **Step 7: Wire the action handlers** + +Find the `StatusAction` dispatch site (grep `StatusAction::StartApi =>`). Add: +- `CopyAcpEditorConfig { editor }` — generate via `crate::acp::client_configs`, write to clipboard +- `OpenAcpDocs` — open `https://operator.untra.io/acp/` via the existing `OpenUrl` helper + +- [ ] **Step 8: Verify** + +Run: `cargo fmt && cargo clippy -- -D warnings && cargo test` +Expected: green. + +- [ ] **Step 9: Stop for commit review** + +--- + +### Task 10: End-to-end integration test + +**Files:** +- Create: `tests/acp_integration.rs` + +- [ ] **Step 1: Write the test** + +```rust +//! Spawn `operator acp` and roundtrip an initialize request. + +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn test_operator_acp_initialize_roundtrip() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + // Initialize message — exact shape depends on the ACP crate version + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} +"#; + stdin.write_all(init).await.unwrap(); + stdin.flush().await.unwrap(); + + let line = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()) + .await.unwrap().unwrap().unwrap(); + let resp: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(resp["id"], 1); + assert!(resp["result"].is_object()); + + drop(stdin); + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test --test acp_integration -- --nocapture` +Expected: PASS. + +- [ ] **Step 3: Final verification** + +Run: +``` +cargo fmt +cargo clippy -- -D warnings +cargo test +``` +Expected: green. + +- [ ] **Step 4: Stop for user to commit** + +--- + +## Self-Review + +**Spec coverage:** +- ACP crate dependency (Task 1) — covered +- `Agent` trait impl with initialize / new_session / prompt / cancel (Tasks 3, 5) — covered (`session/cancel` is left as a stub in Task 3; v1.1 should implement it by signaling the delegator subprocess) +- Session ↔ ticket mapping (Task 4) — covered +- Delegator bridge + stream translation (Task 5) — covered +- CLI subcommand (Task 6) — covered +- Config section (Task 7) — covered +- Editor config snippets (Task 8) — covered +- Status integration (Task 9) — covered +- End-to-end test (Task 10) — covered + +**Open assumptions to verify before starting:** +1. Exact crate name, version, and trait shape of `agent-client-protocol`. Task 1 validates. +2. The `Notifier` injection on `Agent::prompt` — the trait method signature in Task 3 shows `_params` only, but Task 5 uses `notifier: Notifier`. Confirm the real signature; the crate likely passes the notifier via a `&self` field or an additional parameter. +3. The existence of `agents::launcher::resolve_default_delegator` — may need to be added. +4. `services::ticket_manager::create_in_progress` — may need to be added. + +**v1 limitations explicitly accepted:** +- No `session/load` — ACP sessions don't survive operator restart. (Easy to add later: the registry writes to `.operator/acp-sessions.json`.) +- No `session/cancel` — a v1.1 task. +- No structured tool-call translation. The delegator's raw text becomes `AssistantMessageChunk`. When using Claude Code with `--output-format stream-json`, parse and emit structured `ToolCall` updates instead. (Task 5's translator is the single chokepoint to extend.) +- No `fs/*` request forwarding. The delegator subprocess does its own filesystem access. This means file edits don't surface as approvable actions in the editor — an explicit v1 tradeoff. If a target editor needs approval routing, switch from "delegator owns FS" to "operator owns FS, asks editor for permission" in v2. +- Single-tenant: operator runs in the project directory the editor opens it in. Multi-project sessions need a v2 design decision (do separate editor windows share an operator process, or each get their own?). diff --git a/docs/superpowers/plans/2026-05-16-acp-zed-extension.md b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md new file mode 100644 index 00000000..d8b570a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-acp-zed-extension.md @@ -0,0 +1,320 @@ +# Plan: ACP Integration for the Operator Zed Extension + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. + +## Context + +A sibling plan, `docs/superpowers/plans/2026-05-16-acp-agent.md`, wires the **operator binary** itself as an ACP agent (`operator acp` over stdio). This follow-up plan picks up where that one ends: integrate ACP into the **`zed-extension/`** package so Zed users can launch Operator from the agent panel — not just via slash commands. + +Today the Zed extension (`zed-extension/src/lib.rs`, `extension.toml`) is a WASM-sandboxed slash-command bridge: 11 `/op-*` commands shell out to `curl` against the local REST API (`http://localhost:7008`). It does not register an ACP agent. The Zed agent panel surface is unused. + +This plan adds ACP-agent registration to the existing extension so: +- Zed's agent panel shows **Operator** as a selectable agent alongside Claude / Codex / Gemini CLI +- Selecting Operator and opening a new thread spawns `operator acp` in the project root, wired to Zed via JSON-RPC stdio +- The existing `/op-*` slash commands stay — they cover different needs (status queries, queue inspection) and complement the agent thread + +Workflow this enables: a developer in a Zed window for project X opens the agent panel, picks Operator, and chats. Operator (per the upstream ACP plan) creates a ticket from that chat, picks the next queued ticket if one matches, and delegates to Claude Code / Codex / Gemini under the hood — streaming the delegator's output back as `session/update` notifications visible inside Zed. + +## Hard Dependency + +This plan **assumes the operator-side ACP plan is complete and merged.** Specifically: +- `operator acp` subcommand exists and serves a working `Agent` impl over stdio +- `initialize`, `session/new`, and `session/prompt` roundtrip cleanly +- `tests/acp_integration.rs` is green + +If `operator acp` doesn't exist yet, **execute the upstream plan first.** This plan adds Zed-side packaging on top. + +## How Zed Discovers ACP Agents (Key Facts) + +From `https://zed.dev/docs/extensions/agent-servers` and the user-config docs: + +1. **Manifest registration:** A Zed extension declares ACP agents via `[agent_servers.]` blocks in `extension.toml`. Each block has `name`, optional `icon`, optional `env`, plus per-platform `targets.-` entries with `archive` (download URL), `cmd`, `args`, and recommended `sha256`. +2. **User override:** Users can override extension-provided agents (or add custom ones) under `agent_servers` in `settings.json`. The custom form is `{"type": "custom", "command": "...", "args": [...], "env": {...}}`. The registry form is `{"type": "registry", ...}` for curated entries. +3. **Lifecycle:** Zed spawns the configured command as a subprocess with `cwd` = the project root and pipes JSON-RPC over its stdio. No WASM API call is required from the extension code (`src/lib.rs`). +4. **Forwarded context:** Zed passes the project root as `cwd` in `session/new`, plus MCP server configurations, and forwards model/mode selection if the agent advertises support. + +Consequence: the **majority of this plan is `extension.toml` + docs + a release pipeline** — `src/lib.rs` does not need ACP code, because ACP runs in the operator binary, not in the WASM sandbox. + +## Critical Files + +**Modify:** +- `zed-extension/extension.toml` — add `[agent_servers.operator]` block with platform targets +- `zed-extension/README.md` — document the agent panel flow alongside slash commands +- `zed-extension/TODO.md` — mark ACP agent panel as ✅ implemented; audit "not possible" entries against what ACP unblocks +- `bump-version.sh` — bump the extension's version when shipping +- `.github/workflows/*.yml` (or equivalent) — publish per-platform operator archives whose URLs are referenced from `extension.toml` + +**Create:** +- `zed-extension/docs/acp-setup.md` — short walkthrough: install the extension, configure `agent_servers` in `settings.json` for dev, or use the bundled archive in release mode +- `zed-extension/tests/acp_smoke.sh` (or a CI step) — end-to-end smoke that builds operator, starts it under `operator acp`, sends `initialize`, asserts the JSON response +- `src/integrations/inventory.rs` (operator crate) — single source-of-truth list of operator capabilities exposed across surfaces +- `tests/surface_parity.rs` (operator crate) — enforces that every capability has both a slash-command and an ACP-tool entry point + +**Do NOT modify:** +- `zed-extension/src/lib.rs` — slash commands stay as-is. ACP runs out-of-process in the operator binary, not in the extension WASM. + +## Tasks + +### Task 1: Confirm operator-side ACP is functional + +- [ ] **Step 1:** Run `cargo run -- acp < /tmp/init.json` from operator root with a hand-rolled JSON-RPC `initialize` request. Assert it produces a valid `InitializeResponse` containing `agentCapabilities` with `loadSession: false` (per upstream plan v1). +- [ ] **Step 2:** Run `cargo test --test acp_integration` and confirm green. +- [ ] **Step 3:** If either fails, **stop** — the upstream plan is the blocker; finish that first. + +--- + +### Task 2: Add `[agent_servers.operator]` to `extension.toml` + +**Files:** +- Modify: `zed-extension/extension.toml` + +- [ ] **Step 1:** Add the agent_servers block after `[slash_commands]`: + +```toml +[agent_servers.operator] +name = "Operator" +icon = "https://operator.untra.io/icon.png" + +[agent_servers.operator.targets.darwin-aarch64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-aarch64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" + +[agent_servers.operator.targets.darwin-x86_64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-darwin-x86_64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" + +[agent_servers.operator.targets.linux-x86_64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-x86_64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" + +[agent_servers.operator.targets.linux-aarch64] +archive = "https://github.com/untra/operator/releases/download/v{VERSION}/operator-linux-aarch64.tar.gz" +cmd = "./operator" +args = ["acp"] +sha256 = "{SHA256}" +``` + +- [ ] **Step 2:** Pin `{VERSION}` to the operator version that first contains `operator acp`. Bake `{SHA256}` per target at release time via `bump-version.sh` (or accept manual updates in Task 3). +- [ ] **Step 3:** Confirm `extension.toml` parses by building the extension: + ```bash + cd zed-extension && cargo build --release --target wasm32-wasip1 + ``` +- [ ] **Step 4:** Stop for commit review. + +--- + +### Task 3: Update the release pipeline to ship operator archives + +The `archive` URLs in Task 2 must resolve to real artifacts. Inventory `.github/workflows/` and `bump-version.sh` first to see what exists; add the missing pieces. + +**Files:** +- Modify: `.github/workflows/*.yml` (release workflow) +- Modify: `bump-version.sh` + +- [ ] **Step 1:** Add a CI step that, on a tagged release, produces `operator-{os}-{arch}.tar.gz` for the four target tuples in Task 2. Each archive contains the `operator` binary at the archive root (so `cmd = "./operator"` resolves). +- [ ] **Step 2:** Add a CI step that computes each archive's `sha256` and rewrites `zed-extension/extension.toml` with the real `{SHA256}` and `{VERSION}` values before publishing the extension. +- [ ] **Step 3:** Verify by tagging a pre-release and downloading one archive locally: + ```bash + tar -tzf operator-darwin-aarch64.tar.gz | head + ``` + Expected: `operator` appears at the top level. +- [ ] **Step 4:** Stop for commit review. + +--- + +### Task 4: Document the dev-mode override + +Most operator developers will not consume the archive — they'll point Zed at their local debug build. Document this clearly so the extension is usable before the release pipeline is finished. + +**Files:** +- Create: `zed-extension/docs/acp-setup.md` + +- [ ] **Step 1:** Write the setup doc, including: + + ````markdown + # Operator ACP Setup + + ## Dev mode (local binary) + + Add to `~/.config/zed/settings.json` (or per-project `.zed/settings.json`): + + ```jsonc + { + "agent_servers": { + "operator": { + "type": "custom", + "command": "/Users/you/Documents/gbqr-us/operator/target/debug/operator", + "args": ["acp"], + "env": { + "RUST_LOG": "operator=debug" + } + } + } + } + ``` + + This override takes precedence over the extension-provided `[agent_servers.operator]` block, so you can run an unreleased build of operator without rebuilding the extension. + + ## Verify it works + + 1. Open Zed's agent panel + 2. Pick **Operator** from the agent selector + 3. Open a new thread + 4. Type `hello` — you should see streamed output + + ## Release mode + + Install the extension from the Zed extension registry. Zed fetches the matching `operator-{os}-{arch}.tar.gz` archive automatically; no `settings.json` changes needed. + + ## Known issues + + (Populated as Task 7 surfaces them.) + ```` + +- [ ] **Step 2:** Cross-link from `zed-extension/README.md` and the operator-side `docs/cli/index.md` ACP section. +- [ ] **Step 3:** Stop for commit review. + +--- + +### Task 5: Rewrite README + TODO to reflect dual-surface + +`zed-extension/README.md` and `zed-extension/TODO.md` currently document only the slash-command surface and list many features as "Not Possible in Zed." ACP unblocks several. Update them honestly. + +**Files:** +- Modify: `zed-extension/README.md` +- Modify: `zed-extension/TODO.md` + +- [ ] **Step 1:** In `README.md`, add a top-level "Two ways to use the extension" section: + 1. **Slash commands** — existing, REST-backed status queries surfaced in the AI assistant + 2. **Agent panel** — new, ACP-backed full sessions inside Zed +- [ ] **Step 2:** State explicitly that the two surfaces are intentionally parallel — they cover the same operator concepts (queue, tickets, agents, kanban) but from different entry points. Task 6 enforces this with tests. +- [ ] **Step 3:** In `TODO.md`, audit each "Not Possible in Zed" row honestly: + - Sidebar Views → still N/A (ACP doesn't help here) + - Webhook Server → still N/A + - **Terminal Management** → N/A in extension, but ACP sessions provide an in-IDE chat surface + - **Status Bar** → still N/A + - **File System Watching** → still N/A in WASM, but the ACP path lets the agent see filesystem state via `fs/read_text_file` requests routed to Zed +- [ ] **Step 4:** Be specific about what ACP does and doesn't add. Don't oversell. +- [ ] **Step 5:** Stop for commit review. + +--- + +### Task 6: Structural-parity tests between slash-command and ACP surfaces + +The two surfaces (slash commands, ACP threads) must stay in structural sync: adding a new operator capability shouldn't expose it on only one side. Drive both surfaces from a single shared inventory of operator capabilities and let CI enforce the contract. + +**Files:** +- Create: `src/integrations/inventory.rs` (operator crate) +- Create: `tests/surface_parity.rs` (operator crate) +- Create: `zed-extension/tests/acp_smoke.sh` + +- [ ] **Step 1: Add `src/integrations/inventory.rs`** (operator-side, not WASM) enumerating user-facing operator capabilities. One entry per: + ```rust + pub struct Capability { + pub id: &'static str, // e.g. "queue.list" + pub description: &'static str, + pub rest_endpoint: Option<&'static str>, // path matched against OpenAPI + pub slash_command_id: Option<&'static str>, // e.g. "op-queue" + pub acp_tool_id: Option<&'static str>, // e.g. "operator__queue_list" + } + + pub const INVENTORY: &[Capability] = &[ /* ... */ ]; + ``` + Use the existing OpenAPI generation output as the source-of-truth for `rest_endpoint` values. + +- [ ] **Step 2: Add `tests/surface_parity.rs`** asserting: + 1. Every `slash_command_id` in the inventory corresponds to a registered slash command in `zed-extension/extension.toml` (parse the TOML, check the `[slash_commands]` table). + 2. Every `acp_tool_id` is exposed by the operator ACP agent. The exact mechanism depends on what the upstream ACP plan ships — if v1 only delegates to Claude/Codex/Gemini, the ACP surface may expose operator-specific tools through the co-shipped MCP server (covered by the MCP plan at `2026-05-16-mcp-stdio-and-tickets.md`). + 3. Every entry has BOTH a `slash_command_id` AND an `acp_tool_id`, OR an explicit allow-list reason in a separate `tests/fixtures/surface_exceptions.toml`. The default is parity; deviations require an explicit rationale. + +- [ ] **Step 3: Add `zed-extension/tests/acp_smoke.sh`** for runtime smoke (separate from parity): + ```bash + #!/usr/bin/env bash + set -euo pipefail + cd "$(dirname "$0")/../.." + cargo build --bin operator + printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}\n' \ + | ./target/debug/operator acp \ + | head -1 \ + | jq -e '.result.agentCapabilities' + ``` + +- [ ] **Step 4: Wire both into CI.** Parity runs as `cargo test --test surface_parity`. Smoke runs as a shell job. Any failure blocks releases. + +- [ ] **Step 5: Document the contract in `zed-extension/README.md`:** "Adding a new operator capability requires registering it in `src/integrations/inventory.rs`. CI will fail if the new entry lacks either a slash command or an ACP tool (without an explicit exception entry)." + +- [ ] **Step 6:** Run full validation: + ```bash + cargo fmt && cargo clippy -- -D warnings && cargo test + bash zed-extension/tests/acp_smoke.sh + ``` + Expected: green. + +- [ ] **Step 7:** Stop for commit review. + +--- + +### Task 7: User-facing verification in Zed + +Before declaring the integration shipped, manually verify in the real editor — CI cannot prove this. + +- [ ] **Step 1:** Install the extension as dev: + ```bash + cd zed-extension && cargo build --release --target wasm32-wasip1 + mkdir -p ~/.local/share/zed/extensions/installed/operator-dev/ + cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/ + cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm + ``` +- [ ] **Step 2:** Apply the `settings.json` override from Task 4. +- [ ] **Step 3:** Open a Zed project that has `.tickets/` (the operator repo itself works). +- [ ] **Step 4:** Open agent panel → confirm **Operator** appears in the agent selector. +- [ ] **Step 5:** Open a thread → confirm the prompt arrives and streams a response from the configured delegator. +- [ ] **Step 6:** Cancel a thread mid-stream → confirm the delegator process exits (per upstream plan's `session/cancel` task — may be a v1.1 follow-up). +- [ ] **Step 7:** Document any rough edges in `zed-extension/docs/acp-setup.md` under "Known issues." +- [ ] **Step 8:** Stop for user to commit. + +--- + +## Verification + +End-to-end acceptance passes when: +1. `cargo build --release --target wasm32-wasip1` from `zed-extension/` produces a valid WASM artifact. +2. `bash zed-extension/tests/acp_smoke.sh` exits 0. +3. `cargo test --test surface_parity` exits 0. +4. In Zed with the dev override: opening the agent panel → Operator → new thread → typing `hello` → streamed text returns. (Human verification — primary gate.) +5. The four release archive URLs in `extension.toml` resolve to real artifacts whose SHA256 matches. + +## Self-Review + +**Spec coverage:** +- Operator-side ACP confirmation (Task 1) — covered +- Extension manifest (Task 2) — covered +- Release pipeline (Task 3) — covered +- Dev-mode override docs (Task 4) — covered +- README + TODO updates (Task 5) — covered +- Structural-parity tests + smoke (Task 6) — covered +- Manual Zed verification (Task 7) — covered + +**Open assumptions:** +- The operator-side ACP plan ships first. This plan is gated on `operator acp` working — without it there's nothing for Zed to connect to. +- Operator's release pipeline can produce per-target archives. If today's pipeline only produces a single platform, Task 3 expands. +- The Zed `[agent_servers.]` manifest schema is stable. Zed documents it publicly, but the schema is newer than the slash-command API and may shift. + +**Explicit non-goals (v1):** +- No JetBrains, Emacs, Kiro, etc. integration. The operator-side plan generates config snippets (Task 8 there) covering those — they don't need a per-editor extension because they read user config directly. +- No deprecation of slash commands. They serve different workflows. +- No sidebar / status bar / file watcher work — ACP doesn't unblock these in Zed's current extension API. +- No per-ticket "Open in Operator agent panel" deep-link from slash commands. Conceivable but out of scope here. + +**Resolved decisions:** +1. Release archives are in scope for v1 (Task 3 ships per-platform tarballs + SHA256 + `extension.toml` pinning). +2. Slash commands stay as a parallel surface. Task 6 enforces structural parity between the two surfaces with tests so they cannot drift silently. +3. Plan file lives at `docs/superpowers/plans/2026-05-16-acp-zed-extension.md` (sibling of the operator-side ACP plan). diff --git a/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md new file mode 100644 index 00000000..2004b80c --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-mcp-stdio-and-tickets.md @@ -0,0 +1,1580 @@ +# Operator MCP — Stdio Transport, Ticket Tools, and Status Integration + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Commit policy:** User handles all git commits manually. Where steps say "Commit", surface the diff to the user and let them run `git commit`. Do not commit automatically. + +**Goal:** Add stdio transport for operator's MCP server, expand the tool surface to cover ticket queue read/write operations, advertise the stdio entrypoint through the existing `McpDescriptorResponse` so the vscode-extension can pick it up, and surface MCP lifecycle through operator's existing `StatusSection` pattern so users can toggle it and copy client configs from the dashboard. + +**Architecture:** The HTTP/SSE MCP transport (`src/mcp/transport.rs`) already implements the protocol bound to `ApiState` and exposes seven read-only tools. `src/mcp/descriptor.rs` already publishes a discovery endpoint that the vscode-extension consumes. The structural move is to (1) extract the JSON-RPC dispatch core into a transport-agnostic module, (2) add a stdio transport that reads line-delimited JSON-RPC from stdin and writes to stdout, (3) add an `operator mcp` CLI subcommand as the entrypoint MCP clients launch, (4) expand the tool surface with ticket-queue operations that call into the existing `src/queue/Queue` (sync API, wrapped via `tokio::task::spawn_blocking`) and `TicketCreator`, (5) extend the existing descriptor with an optional `stdio: StdioCommand` field so IDE extensions can switch transports without a new endpoint, and (6) add a row to `ConnectionsSection` mirroring the `Operator API` lifecycle pattern. Stdio is the dominant MCP transport across Claude Code, Cursor, VS Code, Zed, and JetBrains; the existing HTTP transport stays as-is for network use. + +**Tech Stack:** Rust 1.88+, tokio, serde_json, axum (existing for HTTP), clap (CLI), ratatui (status integration), ts-rs + schemars (for config + descriptor binding regeneration). No new top-level dependencies required — `tokio::io::{AsyncBufReadExt, AsyncWriteExt}` is sufficient for the stdio loop, and `EditFile` action + existing file I/O cover the client-config snippet delivery (no clipboard dependency). + +--- + +## Pre-Flight (verify before starting) + +Run each of these from the operator project root and confirm the output matches the assumption. If any differ, fix the relevant task before implementing. + +1. `rg -n "pub fn claim_ticket|pub fn complete_ticket|pub fn list_queue" src/queue/` + → should find sync methods in `src/queue/mod.rs` around lines 134-158. +2. `rg -n "pub struct McpDescriptorResponse" src/mcp/` + → should find `src/mcp/descriptor.rs:14`. Confirms the descriptor already exists (do not re-create it). +3. `rg -n "mcp_sessions" src/rest/state.rs` + → should find the field on `ApiState` typed `Arc>>` where `Mutex` is `tokio::sync::Mutex` (line 7 imports). +4. `head -50 vscode-extension/src/mcp-connect.ts` + → confirm the consumer reads `server_name`, `transport_url` from the descriptor. The descriptor extension in Task 6.5 must stay additive (Option field with `skip_serializing_if`). +5. `cargo build --release && ls target/release/operator` + → confirms the binary path that `client_configs::current_exe()` will return. +6. `rg -n "pub struct TicketCreator|pub fn create_ticket_with_values" src/queue/creator.rs` + → should find the existing creator at lines 16-68. Confirms the headless variant in Task 5.5 is additive. + +--- + +## Critical Structural Approach + +Three decisions lock the rest of the plan: + +1. **Transport-agnostic handler.** The function `handle_jsonrpc(&JsonRpcRequest, &ApiState) -> JsonRpcResponse` in `src/mcp/transport.rs` already has the right shape but lives in a file named after HTTP. Extract it to `src/mcp/handler.rs` unchanged. Both transports import it. + +2. **`ApiState` is the shared substrate; `Queue` is the ticket-write surface.** Existing tools call `routes::*` handlers, which take `State`. New ticket-queue tools should construct `crate::queue::Queue::new(&state.config)` and call its **sync** methods (`list_queue`, `claim_ticket(&Ticket)`, `complete_ticket(&Ticket)`, `return_to_queue(&Ticket)`) inside `tokio::task::spawn_blocking`. Creation uses `crate::queue::creator::TicketCreator` via a new headless variant (Task 5.5). Do **not** introduce an in-process HTTP roundtrip. + +3. **No new server lifecycle for stdio.** Stdio MCP is spawned by the client (Claude Code, Cursor, VS Code, …) as a subprocess — it does not run inside the operator TUI. The HTTP-MCP toggle (`config.mcp.http_enabled`) is implemented by conditionally including the MCP routes in `build_router`; flipping it requires an API restart. There is **no** `McpStdioServer` struct, no shutdown channels for stdio. Status display in `ConnectionsSection` reflects (a) whether HTTP MCP routes are mounted on the current API server and (b) whether the stdio entrypoint is advertised in the descriptor. + +--- + +## File Structure + +**Create:** +- `src/mcp/handler.rs` — transport-agnostic `handle_jsonrpc` + JSON-RPC types +- `src/mcp/stdio.rs` — line-delimited stdio JSON-RPC loop +- `src/mcp/tickets.rs` — ticket-queue tools (separated from REST-wrapping `tools.rs`) +- `src/mcp/resources.rs` — MCP resources (tickets exposed as URIs) +- `src/mcp/client_configs.rs` — generates copy-paste config snippets for Claude Code, Claude Desktop, Cursor, VS Code, Zed +- `tests/mcp_stdio_integration.rs` — end-to-end test: spawn `operator mcp`, send init + tools/list over a pipe + +**Modify:** +- `src/mcp/mod.rs` — add `handler`, `stdio`, `tickets`, `resources`, `client_configs` modules +- `src/mcp/transport.rs` — import `handle_jsonrpc` from `handler.rs`; delete the local copy +- `src/mcp/tools.rs` — merge ticket tools from `tickets.rs` into `all_tool_definitions` and `execute_tool`; update tool-count assertion +- `src/mcp/descriptor.rs` — extend existing `McpDescriptorResponse` with `stdio: Option`; inject `State` into the handler so it can read `config.mcp.stdio_advertised` +- `src/rest/mod.rs` — gate MCP route mounting on `config.mcp.http_enabled` +- `src/queue/creator.rs` — add `create_ticket_headless` (no editor launch) +- `src/main.rs` — add `Commands::Mcp` variant and `cmd_mcp` async fn +- `src/config.rs` — add `McpConfig` struct (fields: `http_enabled`, `stdio_advertised`, `expose_ticket_write_tools`) with `JsonSchema + TS` derives, and field on `Config` +- `src/ui/status_panel.rs` — add `mcp_http_status: McpHttpStatus` + `mcp_stdio_advertised: bool` + `mcp_active_sessions: usize` to `StatusSnapshot`; add new `StatusAction` variants: `ToggleMcpHttp`, `WriteAndOpenMcpClientConfig { client: String }`, `OpenMcpDocs` +- `src/ui/sections/connections_section.rs` — add an "MCP" row after the "Operator API" row +- `src/ui/dashboard.rs` — populate the new `StatusSnapshot` fields at the construction site +- `src/app/status_actions.rs` — handle the three new `StatusAction` variants + +Session files live under `/operator/` (see `src/rest/server.rs:27`). Generated client-config snippets go to `/operator/mcp/.json`. + +--- + +## Tasks + +### Task 1: Extract the JSON-RPC handler to a transport-agnostic module + +**Files:** +- Create: `src/mcp/handler.rs` +- Modify: `src/mcp/transport.rs` +- Modify: `src/mcp/mod.rs:7-9` + +- [ ] **Step 1: Move types and dispatch to `handler.rs`** + +Create `src/mcp/handler.rs` with the contents below. These are the existing types from `transport.rs:24-51` plus the existing `handle_jsonrpc` fn from `transport.rs:136-233`, with `pub` added to `handle_jsonrpc` and `JsonRpcResponse`/`JsonRpcError` so other transports can use them. + +```rust +//! Transport-agnostic JSON-RPC handler for MCP. +//! +//! Both the HTTP/SSE transport (`transport.rs`) and the stdio transport +//! (`stdio.rs`) dispatch through `handle_jsonrpc`. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::mcp::tools; +use crate::rest::state::ApiState; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + #[allow(dead_code)] + pub jsonrpc: String, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, +} + +pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { + let id = request.id.clone().unwrap_or(Value::Null); + match request.method.as_str() { + "initialize" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {}, "resources": { "subscribe": false, "listChanged": false } }, + "serverInfo": { "name": "operator", "version": env!("CARGO_PKG_VERSION") } + })), + error: None, + }, + "notifications/initialized" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({})), + error: None, + }, + "tools/list" => { + let tool_defs = tools::all_tool_definitions(); + let tools_json: Vec = tool_defs.into_iter().map(|t| json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema, + })).collect(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "tools": tools_json })), + error: None, + } + } + "tools/call" => { + let tool_name = request.params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let arguments = request.params.get("arguments").cloned().unwrap_or_else(|| json!({})); + match tools::execute_tool(tool_name, arguments, state).await { + Ok(result) => { + let text = serde_json::to_string_pretty(&result).unwrap_or_default(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "content": [{ "type": "text", "text": text }] })), + error: None, + } + } + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { code: -32000, message: e }), + }, + } + } + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + }), + }, + } +} +``` + +Note: the `initialize` capabilities object already advertises `resources` here so Task 6 doesn't need to re-edit it. + +- [ ] **Step 2: Update `transport.rs` to import from `handler.rs`** + +Replace lines 24-51 and the entire `handle_jsonrpc` function (lines 136-233) in `src/mcp/transport.rs` with: + +```rust +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +``` + +at the top, and update the `message_handler` call site (line 125) to use the imported `handle_jsonrpc`. The local `JsonRpcResponse` and `JsonRpcError` types are no longer needed in `transport.rs` — delete them. + +- [ ] **Step 3: Wire the new module into `src/mcp/mod.rs`** + +Edit `src/mcp/mod.rs` to add the new modules (some don't exist yet — comment them out until the corresponding task creates the file, or add them all and let `cargo check` fail until the files land): + +```rust +//! Model Context Protocol (MCP) integration for Operator. + +pub mod client_configs; +pub mod descriptor; +pub mod handler; +pub mod resources; +pub mod stdio; +pub mod tickets; +pub mod tools; +pub mod transport; +``` + +- [ ] **Step 4: Move the existing handler tests to `handler.rs`** + +The six tests in `src/mcp/transport.rs:236-371` (`test_handle_initialize`, `test_handle_tools_list`, `test_handle_tools_call_health`, `test_handle_tools_call_unknown`, `test_handle_unknown_method`, `test_handle_notifications_initialized`) all test `handle_jsonrpc` directly. Move them verbatim to a `#[cfg(test)] mod tests { ... }` block in `src/mcp/handler.rs`. Update `test_handle_initialize` to also assert the `resources` capability is present. + +- [ ] **Step 5: Verify** + +Run: `cargo test mcp::handler` +Expected: All six tests PASS (with the updated capabilities assertion). + +Run: `cargo test mcp::transport` +Expected: Compiles, no tests left in transport.rs. + +Run: `cargo clippy -- -D warnings` +Expected: No warnings. + +- [ ] **Step 6: Stop for user commit review** + +This task is structurally complete (refactor only, plus the additive resources capability). Surface the diff to the user. + +--- + +### Task 2: Add stdio transport + +**Files:** +- Create: `src/mcp/stdio.rs` +- Test: inline `#[cfg(test)]` block + +- [ ] **Step 1: Write a failing test for one round-trip** + +In `src/mcp/stdio.rs`, write the function shell and a test that pipes a JSON-RPC request through it. The test uses a `Vec` for both input and output. Tests use `tempfile::TempDir` because `ApiState::new` initializes templates on disk. + +```rust +//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout. +//! +//! Each line on stdin is one JSON-RPC request. Each response is one JSON +//! object written to stdout terminated by `\n`. Logs and diagnostics go to +//! stderr (via `tracing`). This is the transport MCP clients use when they +//! spawn `operator mcp` as a subprocess. + +use std::io; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +use crate::rest::state::ApiState; + +/// Run the stdio MCP loop until stdin closes. +/// +/// `reader`/`writer` are generic for testability; production callers pass +/// `tokio::io::stdin()` and `tokio::io::stdout()`. +pub async fn run(state: ApiState, reader: R, mut writer: W) -> io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut lines = BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let request: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, line = %line, "Malformed JSON-RPC request"); + continue; + } + }; + let response = handle_jsonrpc(&request, &state).await; + let json = serde_json::to_string(&response) + .unwrap_or_else(|_| r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"#.to_string()); + writer.write_all(json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + // ApiState::new writes default templates into tickets_path; tempdir handles cleanup. + ApiState::new(Config::default(), temp.path().to_path_buf()) + } + + #[tokio::test] + async fn test_stdio_roundtrip_initialize() { + let state = test_state(); + let input = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}} +"#; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + + let response_str = std::str::from_utf8(&output).unwrap(); + let response: serde_json::Value = serde_json::from_str(response_str.trim()).unwrap(); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["serverInfo"]["name"], "operator"); + } + + #[tokio::test] + async fn test_stdio_ignores_blank_lines() { + let state = test_state(); + let input = b"\n\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + assert!(output.is_empty()); + } + + #[tokio::test] + async fn test_stdio_malformed_line_is_skipped() { + let state = test_state(); + let input = b"not json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + let response_str = std::str::from_utf8(&output).unwrap(); + // Only one response should be present (the valid one) + assert_eq!(response_str.matches('\n').count(), 1); + } +} +``` + +Confirm `tempfile` is already a dev-dependency (it's used elsewhere in the project). If not, add `tempfile = "3"` under `[dev-dependencies]`. + +- [ ] **Step 2: Run the tests** + +Run: `cargo test mcp::stdio` +Expected: 3 tests PASS. + +- [ ] **Step 3: Stop for commit review** + +--- + +### Task 3: Add `operator mcp` CLI subcommand + +**Files:** +- Modify: `src/main.rs` (add variant, match arm, async fn) + +- [ ] **Step 1: Add the `Mcp` variant to the `Commands` enum** + +Edit `src/main.rs`. Insert after the `Api { port: Option }` variant (around line 230): + +```rust + /// Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, JetBrains, etc.). + /// + /// Reads line-delimited JSON-RPC from stdin and writes responses to stdout. + /// Log output goes to stderr. Intended to be spawned by an MCP-capable client. + Mcp, +``` + +- [ ] **Step 2: Add the match arm** + +In the main `match cli.command` block (around `src/main.rs:281`), add a new arm before `Some(Commands::Setup { ... })`: + +```rust + Some(Commands::Mcp) => { + cmd_mcp(&config).await?; + } +``` + +- [ ] **Step 3: Implement `cmd_mcp`** + +Add to the bottom of `src/main.rs`, alongside `cmd_api`: + +```rust +async fn cmd_mcp(config: &Config) -> Result<()> { + use crate::rest::state::ApiState; + let state = ApiState::new(config.clone(), config.tickets_path()); + tracing::info!("Starting MCP stdio server"); + crate::mcp::stdio::run(state, tokio::io::stdin(), tokio::io::stdout()).await?; + tracing::info!("MCP stdio server stopped (stdin closed)"); + Ok(()) +} +``` + +- [ ] **Step 4: Verify it runs and responds** + +Run: `cargo build --release` +Run interactively in a shell: +``` +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | ./target/release/operator mcp +``` +Expected: One line of JSON output containing `"serverInfo":{"name":"operator"`. Process exits cleanly after stdin closes. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 4: Add ticket-queue MCP read tool (`operator_list_tickets`) + +**Files:** +- Create: `src/mcp/tickets.rs` +- Modify: `src/mcp/tools.rs:23-99` (definitions), `:101-150` (dispatch), `:162` (count assertion) + +- [ ] **Step 1: Write a failing test for `operator_list_tickets`** + +In `src/mcp/tickets.rs`: + +```rust +//! Ticket-queue MCP tools. +//! +//! Reads/writes via `crate::queue::Queue` which uses blocking `std::fs`, +//! so all calls are wrapped in `tokio::task::spawn_blocking`. + +use serde_json::{json, Value}; + +use crate::queue::ticket::Ticket; +use crate::queue::Queue; +use crate::rest::state::ApiState; + +fn ticket_to_json(t: &Ticket) -> Value { + json!({ + "id": t.id, + "filename": t.filename, + "project": t.project, + "ticket_type": t.ticket_type, + "summary": t.summary, + "priority": t.priority, + "status": t.status, + "branch": t.branch, + "external_id": t.external_id, + "external_url": t.external_url, + "external_provider": t.external_provider, + }) +} + +pub async fn list_tickets(args: Value, state: &ApiState) -> Result { + let status = args.get("status").and_then(|v| v.as_str()).unwrap_or("queue").to_string(); + let config = (*state.config).clone(); + let tickets = tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + match status.as_str() { + "queue" => queue.list_queue().map_err(|e| e.to_string()), + "in-progress" => queue.list_in_progress().map_err(|e| e.to_string()), + "completed" => queue.list_completed().map_err(|e| e.to_string()), + other => Err(format!("Unknown ticket status: {other}")), + } + }) + .await + .map_err(|e| e.to_string())??; + + let json_tickets: Vec = tickets.iter().map(ticket_to_json).collect(); + Ok(json!({ "tickets": json_tickets, "count": json_tickets.len() })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + // Leak the tempdir so it survives the test; in real test isolation use a guard. + let path = temp.into_path(); + ApiState::new(Config::default(), path) + } + + #[tokio::test] + async fn test_list_tickets_empty_queue() { + let state = test_state(); + let result = list_tickets(json!({}), &state).await.unwrap(); + assert_eq!(result["count"], 0); + } + + #[tokio::test] + async fn test_list_tickets_unknown_status_errors() { + let state = test_state(); + let err = list_tickets(json!({ "status": "bogus" }), &state).await.unwrap_err(); + assert!(err.contains("Unknown ticket status")); + } +} +``` + +Verify the actual `Queue::new` signature first (Pre-Flight #1). If it takes a different argument (e.g. `&Config` vs. owned `Config`), adjust the closure capture. The `(*state.config).clone()` pattern handles the `Arc` deref. + +Run: `cargo test mcp::tickets::tests::test_list_tickets_empty_queue` +Expected: PASS. + +- [ ] **Step 2: Register the tool in `tools.rs`** + +In `src/mcp/tools.rs:23-99`, append to the `vec!` in `all_tool_definitions`: + +```rust + McpToolDefinition { + name: "operator_list_tickets".to_string(), + description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["queue", "in-progress", "completed"], "default": "queue" } + }, + "required": [] + }), + }, +``` + +In `execute_tool` (around `src/mcp/tools.rs:103`), add the dispatch arm before the catch-all `_ =>`: + +```rust + "operator_list_tickets" => crate::mcp::tickets::list_tickets(args, state).await, +``` + +- [ ] **Step 3: Update the tool-count assertions** + +In `src/mcp/handler.rs::tests::test_handle_tools_list` (moved in Task 1, Step 4) and `src/mcp/tools.rs:162`'s count assertion, change the expected count from `7` to `8`. + +- [ ] **Step 4: Run everything** + +Run: `cargo test mcp::` +Expected: All MCP tests PASS. + +- [ ] **Step 5: Stop for commit review** + +--- + +### Task 5: Add ticket-queue MCP write tools (claim, complete, return-to-queue) + +**Files:** +- Modify: `src/mcp/tickets.rs` (three new fns + tests) +- Modify: `src/mcp/tools.rs` (three definitions, three dispatch arms, count assertion → 11) + +All three write tools follow the same pattern: look up the ticket by `id` in the appropriate source list, call the corresponding `Queue` method on it, return the new path. They share a permission gate on `config.mcp.expose_ticket_write_tools` (added in Task 7). + +- [ ] **Step 1: Add the shared lookup helper in `tickets.rs`** + +```rust +async fn find_ticket(state: &ApiState, id: &str, in_status: &str) -> Result { + let id = id.to_string(); + let in_status = in_status.to_string(); + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match in_status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + list.into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("Ticket {id} not found in {in_status}")) + }) + .await + .map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 2: Implement `claim_ticket`** + +```rust +pub async fn claim_ticket(args: Value, state: &ApiState) -> Result { + let id = args.get("id").and_then(|v| v.as_str()).ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "queue").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.claim_ticket(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "in-progress" })) +} +``` + +Add test that creates a temp tickets dir, writes a fake ticket file into `queue/`, calls `claim_ticket`, then asserts the file exists in `in-progress/` and not in `queue/`. Use a real timestamped filename matching the project's expected pattern (see `src/queue/ticket.rs` for parse rules). + +- [ ] **Step 3: Implement `complete_ticket` and `return_to_queue`** + +Identical shape — source status is `"in-progress"` for both, target differs: + +```rust +pub async fn complete_ticket(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.complete_ticket */ } +pub async fn return_to_queue(args: Value, state: &ApiState) -> Result { /* lookup in-progress, call queue.return_to_queue */ } +``` + +Add a test for each. + +- [ ] **Step 4: Register the three tools in `tools.rs` with the permission gate** + +Append three `McpToolDefinition` entries to `all_tool_definitions`: + +```rust + McpToolDefinition { + name: "operator_claim_ticket".to_string(), + description: "Move a ticket from queue to in-progress. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string", "description": "Ticket id (e.g. FEAT-1234)" } }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_complete_ticket".to_string(), + description: "Move a ticket from in-progress to completed.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_return_to_queue".to_string(), + description: "Move a ticket from in-progress back to queue (un-claim).".to_string(), + input_schema: json!({ + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"] + }), + }, +``` + +In `execute_tool`, add three dispatch arms with the shared permission gate. Extract the gate to a helper: + +```rust +fn require_write_tools(state: &ApiState) -> Result<(), String> { + if !state.config.mcp.expose_ticket_write_tools { + Err("Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)".to_string()) + } else { + Ok(()) + } +} +``` + +```rust + "operator_claim_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::claim_ticket(args, state).await + } + "operator_complete_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::complete_ticket(args, state).await + } + "operator_return_to_queue" => { + require_write_tools(state)?; + crate::mcp::tickets::return_to_queue(args, state).await + } +``` + +- [ ] **Step 5: Add a gate test** + +In `tickets.rs::tests`, assert that `claim_ticket` returns the gate error when `config.mcp.expose_ticket_write_tools = false`. Construct the state with a config where the flag is false, then call `execute_tool("operator_claim_ticket", ...)` and assert the error string. + +- [ ] **Step 6: Update tool-count assertions to 11** + +(8 existing + 3 new write tools = 11. Task 5.5 will add a 12th, Task 6 doesn't add tools.) + +- [ ] **Step 7: Verify** + +Run: `cargo test mcp::` +Expected: All MCP tests PASS. + +- [ ] **Step 8: Stop for commit review** + +--- + +### Task 5.5: Add `operator_create_ticket` write tool + +**Files:** +- Modify: `src/queue/creator.rs` (add `create_ticket_headless`) +- Modify: `src/mcp/tickets.rs` (add `create_ticket` MCP fn) +- Modify: `src/mcp/tools.rs` (one definition, one dispatch arm, count → 12) + +The existing `TicketCreator::create_ticket_with_values` (lines 33-68) opens `$EDITOR` after writing the file. That's wrong for MCP — there's no terminal. Add a headless variant that returns the path without launching an editor. + +- [ ] **Step 1: Add `create_ticket_headless` to `TicketCreator`** + +In `src/queue/creator.rs`, beside the existing `create_ticket_with_values`, add: + +```rust +/// Create a ticket without opening it in an editor (for MCP / API use). +pub fn create_ticket_headless( + &self, + template_type: TemplateType, + values: &HashMap, +) -> Result { + let now = Utc::now(); + let timestamp = now.format("%Y%m%d-%H%M").to_string(); + let type_str = template_type.as_str(); + let project = values + .get("project") + .filter(|p| !p.is_empty()) + .cloned() + .unwrap_or_else(|| "global".to_string()); + + let filename = format!("{timestamp}-{type_str}-{project}-new-ticket.md"); + let filepath = self.queue_path.join(&filename); + + let template = template_type.template_content(); + let content = render_template(template, values)?; + fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; + fs::write(&filepath, &content).context("Failed to write ticket file")?; + + Ok(filepath) +} +``` + +Refactor `create_ticket_with_values` to call `create_ticket_headless` and then `open_in_editor` (DRY). Run existing tests to confirm no regression. + +- [ ] **Step 2: Add MCP fn `create_ticket` in `tickets.rs`** + +```rust +pub async fn create_ticket(args: Value, state: &ApiState) -> Result { + use crate::queue::creator::TicketCreator; + use crate::templates::TemplateType; + use std::collections::HashMap; + + let template_str = args.get("template").and_then(|v| v.as_str()).ok_or("Missing required arg: template")?; + let template_type = TemplateType::from_str(template_str).map_err(|e| e.to_string())?; + let mut values: HashMap = HashMap::new(); + if let Some(obj) = args.get("values").and_then(|v| v.as_object()) { + for (k, v) in obj { + if let Some(s) = v.as_str() { + values.insert(k.clone(), s.to_string()); + } + } + } + + let config = (*state.config).clone(); + let path = tokio::task::spawn_blocking(move || -> Result { + let creator = TicketCreator::new(&config); + creator.create_ticket_headless(template_type, &values).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(json!({ "path": path.to_string_lossy(), "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or("") })) +} +``` + +Verify `TemplateType::from_str` exists. If not, use the project's actual enum-parsing convention. + +- [ ] **Step 3: Register `operator_create_ticket` in `tools.rs`** + +```rust + McpToolDefinition { + name: "operator_create_ticket".to_string(), + description: "Create a new ticket from a template (FEAT, FIX, INV, SPIKE, etc.) and write it to the queue. Returns the filename. Gated by [mcp].expose_ticket_write_tools.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template": { "type": "string", "description": "Template type (FEAT, FIX, INV, SPIKE, ...)" }, + "values": { "type": "object", "description": "Handlebars values for the template (project, summary, etc.)" } + }, + "required": ["template"] + }), + }, +``` + +```rust + "operator_create_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::create_ticket(args, state).await + } +``` + +- [ ] **Step 4: Add test** + +Temp tickets dir, call `create_ticket` with `template = "FEAT", values = { "summary": "test", "project": "demo" }`, assert a `.md` file lands in `queue/` with a name containing `demo`. + +- [ ] **Step 5: Update tool-count assertions to 12** + +- [ ] **Step 6: Verify** + +Run: `cargo test mcp::` +Expected: PASS. + +- [ ] **Step 7: Stop for commit review** + +--- + +### Task 6: MCP resources capability — expose tickets as resources + +**Files:** +- Modify: `src/mcp/handler.rs` (add `resources/list` and `resources/read` handlers; the capability is already advertised after Task 1 Step 1) +- Create: `src/mcp/resources.rs` + +MCP clients can subscribe to resources to read context. Expose each ticket as a resource with URI `operator://tickets/{status}/{id}`. This is the highest-leverage capability for IDEs that want to surface tickets natively. + +- [ ] **Step 1: Add `resources/list` and `resources/read` handlers** + +After the `tools/call` arm in `handle_jsonrpc`: + +```rust + "resources/list" => { + let resources = crate::mcp::resources::list_resources(state).await + .unwrap_or_else(|_| vec![]); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "resources": resources })), + error: None, + } + } + "resources/read" => { + let uri = request.params.get("uri").and_then(|v| v.as_str()).unwrap_or(""); + match crate::mcp::resources::read_resource(uri, state).await { + Ok(contents) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "contents": [{ "uri": uri, "mimeType": "text/markdown", "text": contents }] })), + error: None, + }, + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { code: -32000, message: e }), + }, + } + } +``` + +- [ ] **Step 2: Implement `src/mcp/resources.rs` via `Queue`** + +```rust +//! MCP resources — exposes tickets as URI-addressable resources. + +use serde_json::{json, Value}; + +use crate::queue::Queue; +use crate::rest::state::ApiState; + +pub async fn list_resources(state: &ApiState) -> Result, String> { + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let mut all = Vec::new(); + for (status, list) in [ + ("queue", queue.list_queue()), + ("in-progress", queue.list_in_progress()), + ("completed", queue.list_completed()), + ] { + for t in list.map_err(|e| e.to_string())? { + all.push(json!({ + "uri": format!("operator://tickets/{status}/{}", t.id), + "name": t.filename, + "mimeType": "text/markdown", + "description": t.summary, + })); + } + } + Ok(all) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn read_resource(uri: &str, state: &ApiState) -> Result { + let prefix = "operator://tickets/"; + let rest = uri.strip_prefix(prefix).ok_or_else(|| format!("Unknown URI scheme: {uri}"))?; + let (status, id) = rest.split_once('/').ok_or_else(|| format!("Malformed URI: {uri}"))?; + + let config = (*state.config).clone(); + let status = status.to_string(); + let id = id.to_string(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + let ticket = list.into_iter().find(|t| t.id == id).ok_or_else(|| format!("Ticket {id} not found"))?; + std::fs::read_to_string(&ticket.filepath).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + ApiState::new(Config::default(), temp.into_path()) + } + + #[tokio::test] + async fn test_list_resources_empty() { + let state = test_state(); + let resources = list_resources(&state).await.unwrap(); + assert!(resources.is_empty()); + } + + #[tokio::test] + async fn test_read_resource_unknown_scheme() { + let state = test_state(); + let err = read_resource("file:///tmp/x", &state).await.unwrap_err(); + assert!(err.contains("Unknown URI scheme")); + } + + #[tokio::test] + async fn test_read_resource_malformed() { + let state = test_state(); + let err = read_resource("operator://tickets/queue", &state).await.unwrap_err(); + assert!(err.contains("Malformed URI")); + } +} +``` + +- [ ] **Step 3: Verify** + +Run: `cargo test mcp::` +Expected: All PASS. + +- [ ] **Step 4: Stop for commit review** + +--- + +### Task 6.5: Extend `McpDescriptorResponse` with stdio command (vscode-extension Phase 2 enabler) + +**Files:** +- Modify: `src/mcp/descriptor.rs` + +The existing descriptor at `src/mcp/descriptor.rs:14-30` is consumed by `vscode-extension/src/mcp-connect.ts` to register operator as an SSE MCP server. Extending it with an optional `stdio` field is purely additive (gated by `skip_serializing_if`) and unlocks the Phase 2 work where the extension can choose to spawn `operator mcp` instead of (or alongside) the SSE transport. + +- [ ] **Step 1: Add `StdioCommand` and the optional field** + +Edit `src/mcp/descriptor.rs`: + +```rust +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StdioCommand { + /// Absolute path to the operator binary (the same binary serving this descriptor) + pub command: String, + /// Args to pass: typically ["mcp"] + pub args: Vec, + /// Working directory the client should set when spawning. Defaults to the + /// operator process's current working directory. + pub cwd: String, +} + +// Add to McpDescriptorResponse: + /// Stdio transport entrypoint. Present when [mcp].stdio_advertised = true. + /// Clients may spawn this as a subprocess instead of using transport_url. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdio: Option, +``` + +- [ ] **Step 2: Inject `State` into the handler and populate `stdio`** + +Change the handler signature from `descriptor(Host(host): Host)` to `descriptor(State(state): State, Host(host): Host)` and populate: + +```rust +pub async fn descriptor( + State(state): State, + Host(host): Host, +) -> Json { + let base = format!("http://{host}"); + + let stdio = if state.config.mcp.stdio_advertised { + let command = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_else(|| "operator".to_string()); + let cwd = std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + .unwrap_or_default(); + Some(StdioCommand { + command, + args: vec!["mcp".to_string()], + cwd, + }) + } else { + None + }; + + Json(McpDescriptorResponse { + server_name: "operator".to_string(), + server_id: "operator-mcp".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + transport_url: format!("{base}/api/v1/mcp/sse"), + label: "Operator MCP Server".to_string(), + openapi_url: Some(format!("{base}/api-docs/openapi.json")), + stdio, + }) +} +``` + +The route registration in `src/rest/mod.rs` already passes `ApiState` (other handlers use it), but verify the descriptor's route line and add the `State` extractor if it currently uses a different shape. + +- [ ] **Step 3: Update existing descriptor tests + add stdio coverage** + +Update both `test_descriptor_response` and `test_descriptor_custom_port` to construct a test `ApiState` and pass it. Add two new tests: +- `test_descriptor_stdio_present_when_advertised` — config with `stdio_advertised = true` → `resp.stdio.is_some()` and `resp.stdio.unwrap().args == vec!["mcp"]`. +- `test_descriptor_stdio_absent_when_disabled` — config with `stdio_advertised = false` → `resp.stdio.is_none()`. + +(Both tests need Task 7's `McpConfig` to exist. Either land Task 7 first, or temporarily inline the field default behind a feature flag. **Preferred order:** Task 7 before Task 6.5 — see ordering note below.) + +- [ ] **Step 4: Regenerate TypeScript bindings** + +Run: `cargo test` — `ts-rs` regenerates `bindings/` (or wherever the project configures `#[ts(export)]` output) including the new `StdioCommand` type. + +- [ ] **Step 5: Verify** + +Run: `cargo test mcp::descriptor` +Expected: PASS, including the two new stdio tests. + +Run: `cargo clippy -- -D warnings` +Expected: clean. + +- [ ] **Step 6: Stop for commit review** + +> **Ordering note:** This task reads `config.mcp.stdio_advertised`, which is defined in Task 7. If you're executing strictly in numeric order, swap: do Task 7 first, then return to Task 6.5. Tasks 1-6 are independent of `McpConfig`. + +--- + +### Task 7: Add `[mcp]` config section + +**Files:** +- Modify: `src/config.rs` (Config struct around lines 28-74; new `McpConfig` struct alongside `RestApiConfig` and `RelayConfig`) + +- [ ] **Step 1: Add the `McpConfig` struct** + +Add in `src/config.rs`, near other sub-structs like `RestApiConfig` (`src/config.rs:263-291`) and `RelayConfig`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export)] +pub struct McpConfig { + /// Whether to mount MCP HTTP/SSE endpoints on the REST API server. + /// Toggling requires an API restart (no hot-swap of the axum router). + #[serde(default = "default_true")] + pub http_enabled: bool, + /// Whether the descriptor endpoint advertises the `operator mcp` stdio + /// command. Set to false on multi-tenant/remote deployments where clients + /// shouldn't spawn local subprocesses. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + /// create) over MCP. Defaults to `false` because any MCP client can call them. + #[serde(default)] + pub expose_ticket_write_tools: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + http_enabled: true, + stdio_advertised: true, + expose_ticket_write_tools: false, + } + } +} + +fn default_true() -> bool { true } +``` + +The `JsonSchema + TS` derive pair matches the rest of the codebase (`src/config.rs`'s other structs). The `TS` derive triggers TypeScript binding regeneration consumed by `vscode-extension/scripts/copy-types.js`. + +- [ ] **Step 2: Add the field to `Config`** + +Add to the `Config` struct (alongside `relay: RelayConfig`): + +```rust + #[serde(default)] + pub mcp: McpConfig, +``` + +Update `Config::default()` to include `mcp: McpConfig::default()`. + +- [ ] **Step 3: Wire `http_enabled` into the router build** + +In `src/rest/mod.rs` (around lines 175-181, where MCP routes are currently mounted unconditionally), wrap the MCP route registrations: + +```rust +if state.config.mcp.http_enabled { + router = router + .route("/api/v1/mcp/descriptor", get(descriptor::descriptor)) + .route("/api/v1/mcp/sse", get(transport::sse_handler)) + .route("/api/v1/mcp/message", post(transport::message_handler)); +} +``` + +(Exact shape depends on the existing router-building pattern. Verify by reading `src/rest/mod.rs:175-181`.) + +- [ ] **Step 4: Verify the gate from Task 5 now compiles** + +Run: `cargo test mcp::` +Expected: PASS, including the disabled-write-tools gate test from Task 5. + +- [ ] **Step 5: Regen docs and TS bindings** + +``` +cargo run -- docs --only config # regenerates docs/configuration/index.md with [mcp] section +cargo test # ts-rs regenerates bindings +``` + +Then refresh the vscode-extension's copy (Phase 2 will need this): + +``` +cd vscode-extension && npm run copy-types +``` + +If `copy-types` isn't yet a script in `package.json`, fall back to the manual path the project uses (`scripts/copy-types.js`). + +Verify the generated `docs/configuration/index.md` now describes `[mcp]`. + +- [ ] **Step 6: Stop for commit review** + +--- + +### Task 8: Client config snippet generator + +**Files:** +- Create: `src/mcp/client_configs.rs` + +Operator users adopting MCP need to be told "paste this into your client config." Generate snippets at runtime so they always carry the correct absolute path to the operator binary and the project's working directory. + +- [ ] **Step 0: Verify modern client config shapes** + +Before writing snippets, confirm the current expected shape for each: +- Run: `head -100 vscode-extension/src/mcp-connect.ts` — verify what the extension currently writes to workspace `mcp.servers` (or the modern `.vscode/mcp.json` `servers` shape). The snippet for VS Code must match what the extension expects. +- Cursor: `~/.cursor/mcp.json` uses the `mcpServers` shape (same as Claude Code's `claude.json`). +- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` uses `mcpServers`. +- Zed: `settings.json` under `context_servers`. + +If any shape has drifted, update the snippet in Step 1 before testing. + +- [ ] **Step 1: Implement `client_configs.rs`** + +```rust +//! Generates copy-paste MCP client configuration snippets pointing at this operator binary. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +pub fn current_exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +fn mcp_servers_shape(cwd: &Path) -> Value { + // Used by Claude Code (~/.claude.json), Claude Desktop, and Cursor (~/.cursor/mcp.json). + json!({ + "mcpServers": { + "operator": { + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +pub fn claude_code_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } +pub fn claude_desktop_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } + +/// Cursor's `~/.cursor/mcp.json` uses the same `mcpServers` shape as Claude Code. +pub fn cursor_snippet(cwd: &Path) -> Value { mcp_servers_shape(cwd) } + +/// VS Code (1.94+) per-workspace `.vscode/mcp.json` uses a `servers` block with explicit `type`. +pub fn vscode_snippet(cwd: &Path) -> Value { + json!({ + "servers": { + "operator": { + "type": "stdio", + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +/// Zed config under `context_servers` in user settings. +pub fn zed_snippet(cwd: &Path) -> Value { + json!({ + "context_servers": { + "operator": { + "command": { "path": current_exe().to_string_lossy(), "args": ["mcp"], "env": {} }, + "settings": { "cwd": cwd.to_string_lossy() } + } + } + }) +} + +/// Dispatch by client name. Returns `None` for unknown clients. +pub fn snippet_for(client: &str, cwd: &Path) -> Option { + match client { + "claude-code" => Some(claude_code_snippet(cwd)), + "claude-desktop" => Some(claude_desktop_snippet(cwd)), + "cursor" => Some(cursor_snippet(cwd)), + "vscode" => Some(vscode_snippet(cwd)), + "zed" => Some(zed_snippet(cwd)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claude_code_snippet_shape() { + let cfg = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["mcpServers"]["operator"]["args"][0], "mcp"); + assert_eq!(cfg["mcpServers"]["operator"]["cwd"], "/work"); + } + + #[test] + fn test_cursor_snippet_matches_claude_code() { + let cursor = cursor_snippet(&PathBuf::from("/work")); + let claude = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cursor, claude); + } + + #[test] + fn test_vscode_snippet_uses_servers_with_type() { + let cfg = vscode_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["servers"]["operator"]["type"], "stdio"); + assert_eq!(cfg["servers"]["operator"]["args"][0], "mcp"); + } + + #[test] + fn test_zed_snippet_uses_context_servers() { + let cfg = zed_snippet(&PathBuf::from("/work")); + assert!(cfg["context_servers"]["operator"]["command"]["path"].is_string()); + } + + #[test] + fn test_snippet_for_unknown_client_is_none() { + assert!(snippet_for("notepad++", &PathBuf::from("/w")).is_none()); + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test mcp::client_configs` +Expected: PASS. + +- [ ] **Step 3: Stop for commit review** + +--- + +### Task 9: Integrate MCP into `StatusSnapshot` and `ConnectionsSection` + +**Files:** +- Modify: `src/ui/status_panel.rs:401-431` (StatusSnapshot fields), `:143-174` (StatusAction) +- Modify: `src/ui/sections/connections_section.rs:70-138` (children) +- Modify: `src/ui/dashboard.rs:203-317` (snapshot construction) +- Modify: `src/app/status_actions.rs` (action handlers) + +Mirror the existing `Operator API` row pattern exactly. No new clipboard dependency — `WriteAndOpenMcpClientConfig` writes the snippet to a file and dispatches the existing `EditFile(path)` action. + +- [ ] **Step 1: Add `McpHttpStatus` enum** + +In `src/rest/server.rs` (alongside `RestApiStatus`) or a new `src/mcp/status.rs` if you prefer to keep MCP types together: + +```rust +#[derive(Debug, Clone, PartialEq)] +pub enum McpHttpStatus { + /// MCP HTTP routes mounted on the REST API server on the given port. + Mounted { port: u16 }, + /// MCP HTTP routes disabled via [mcp].http_enabled = false. + NotMounted, +} +``` + +- [ ] **Step 2: Add MCP fields to `StatusSnapshot`** + +In `src/ui/status_panel.rs` around line 425, before the closing `}`: + +```rust + /// MCP HTTP transport status (mounted on the API server, or disabled by config). + pub mcp_http_status: McpHttpStatus, + /// Whether the descriptor advertises the stdio entrypoint. + pub mcp_stdio_advertised: bool, + /// Currently active MCP SSE sessions on the HTTP transport. + pub mcp_active_sessions: usize, +``` + +- [ ] **Step 3: Add new `StatusAction` variants** + +In `src/ui/status_panel.rs:143-174`, add before `None`: + +```rust + /// Toggle [mcp].http_enabled (requires API restart to take effect). + ToggleMcpHttp, + /// Generate a client config snippet, write it to /operator/mcp/.json, + /// and open it in $EDITOR. `client` is one of: "claude-code", "claude-desktop", "cursor", "vscode", "zed". + WriteAndOpenMcpClientConfig { client: String }, + /// Open the operator MCP docs page in the default browser. + OpenMcpDocs, +``` + +- [ ] **Step 4: Add the "MCP" row in `ConnectionsSection::children`** + +In `src/ui/sections/connections_section.rs` after the "Operator API" row push (around line 117), insert: + +```rust + rows.push(TreeRow { + section_id: SectionId::Connections, + depth: 1, + label: "MCP".into(), + description: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised, snapshot.mcp_active_sessions) { + (McpHttpStatus::Mounted { port }, true, n) if n > 0 => format!(":{port} + stdio · {n} sessions"), + (McpHttpStatus::Mounted { port }, true, _) => format!(":{port} + stdio"), + (McpHttpStatus::Mounted { port }, false, _) => format!(":{port} (HTTP only)"), + (McpHttpStatus::NotMounted, true, _) => "stdio only".into(), + (McpHttpStatus::NotMounted, false, _) => "Disabled".into(), + }, + icon: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised) { + (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Plug, + _ => StatusIcon::Cross, + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::WriteAndOpenMcpClientConfig { client: "claude-code".to_string() }, + back: StatusAction::None, + special: StatusAction::ToggleMcpHttp, + special_meta: Some(ActionMeta { title: "HTTP", tooltip: "Toggle the MCP HTTP transport (restart required)" }), + refresh: StatusAction::OpenMcpDocs, + refresh_meta: Some(ActionMeta { title: "Docs", tooltip: "Open MCP setup docs in browser" }), + }, + health: SectionHealth::Gray, + }); +``` + +`McpHttpStatus` and `StatusIcon::Plug` need imports added at the top of the file. + +- [ ] **Step 5: Populate the snapshot in `dashboard.rs`** + +In `src/ui/dashboard.rs:203-317`'s `build_status_snapshot`, populate the three new fields: + +```rust + mcp_http_status: if self.config.mcp.http_enabled { + match &self.rest_api_status { + RestApiStatus::Running { port } => McpHttpStatus::Mounted { port: *port }, + _ => McpHttpStatus::NotMounted, + } + } else { + McpHttpStatus::NotMounted + }, + mcp_stdio_advertised: self.config.mcp.stdio_advertised, + mcp_active_sessions: self.api_state.as_ref() + .map(|s| s.mcp_sessions.try_lock().map(|m| m.len()).unwrap_or(0)) + .unwrap_or(0), +``` + +Verify the `Dashboard` struct's field that holds `ApiState` (might not be `api_state`; grep for `ApiState` in `src/ui/dashboard.rs`). If the dashboard doesn't currently hold an `ApiState` reference, route the session count through the `RestApiServer` lifecycle handle (which already holds the state) or default to `0` until the API is running. + +- [ ] **Step 6: Wire the action handlers in `src/app/status_actions.rs`** + +Add three new match arms in the existing dispatcher (around `src/app/status_actions.rs:66`): + +```rust +StatusAction::ToggleMcpHttp => { + // Flip config.mcp.http_enabled in the running Config and surface a notice. + // No hot-swap: tell the user to restart the API. + self.config.mcp.http_enabled = !self.config.mcp.http_enabled; + self.dashboard.set_status(if self.config.mcp.http_enabled { + "MCP HTTP enabled — restart the API to mount routes" + } else { + "MCP HTTP disabled — restart the API to unmount routes" + }); +} + +StatusAction::WriteAndOpenMcpClientConfig { client } => { + use crate::mcp::client_configs; + let cwd = std::env::current_dir().unwrap_or_default(); + let Some(snippet) = client_configs::snippet_for(&client, &cwd) else { + self.dashboard.set_status(&format!("Unknown MCP client: {client}")); + return; + }; + let dir = self.config.tickets_path().join("operator/mcp"); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dashboard.set_status(&format!("Failed to create {}: {e}", dir.display())); + return; + } + let path = dir.join(format!("{client}.json")); + let body = serde_json::to_string_pretty(&snippet).unwrap_or_default(); + if let Err(e) = std::fs::write(&path, body) { + self.dashboard.set_status(&format!("Failed to write {}: {e}", path.display())); + return; + } + // Reuse the existing EditFile dispatcher. + self.dispatch(StatusAction::EditFile(path.to_string_lossy().into_owned())); +} + +StatusAction::OpenMcpDocs => { + // Use the existing open_in_browser helper. + if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") { + self.dashboard.set_status(&format!("Failed to open docs: {e}")); + } +} +``` + +Confirm the docs URL before merging (TODO marker; pick the actual operator docs URL). + +- [ ] **Step 7: Update all test snapshots** + +Search test files for `StatusSnapshot {` (`rg "StatusSnapshot \{" --type rust`) and add the three new fields with defaults: + +```rust + mcp_http_status: McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, +``` + +- [ ] **Step 8: Add a test for the new MCP row** + +Append to `src/ui/sections/connections_section.rs::tests`: + +```rust + #[test] + fn test_connections_mcp_row_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP"); + assert!(mcp_row.is_some(), "MCP row should always be present"); + } + + #[test] + fn test_connections_mcp_row_description_disabled() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = false; + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + assert_eq!(mcp_row.description, "Disabled"); + } +``` + +- [ ] **Step 9: Verify** + +Run: `cargo fmt && cargo clippy -- -D warnings && cargo test` +Expected: All PASS, no warnings. + +- [ ] **Step 10: Stop for commit review** + +--- + +### Task 10: End-to-end integration test + +**Files:** +- Create: `tests/mcp_stdio_integration.rs` + +- [ ] **Step 1: Write the test** + +```rust +//! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip +//! a real JSON-RPC handshake. + +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn test_operator_mcp_stdio_initialize_and_list_tools() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator mcp"); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout).lines(); + + stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n").await.unwrap(); + stdin.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let line1 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap(); + let resp1: serde_json::Value = serde_json::from_str(&line1).unwrap(); + assert_eq!(resp1["id"], 1); + assert_eq!(resp1["result"]["serverInfo"]["name"], "operator"); + + let line2 = tokio::time::timeout(std::time::Duration::from_secs(5), reader.next_line()).await.unwrap().unwrap().unwrap(); + let resp2: serde_json::Value = serde_json::from_str(&line2).unwrap(); + assert_eq!(resp2["id"], 2); + // 8 read + 4 write tools = 12 (or whichever count Task 5.5 left it at) + assert!(resp2["result"]["tools"].as_array().unwrap().len() >= 8); + + drop(stdin); + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; +} +``` + +- [ ] **Step 2: Run** + +Run: `cargo test --test mcp_stdio_integration -- --nocapture` +Expected: PASS (a few seconds to build the binary the first time). + +- [ ] **Step 3: Final verification** + +``` +cargo fmt +cargo clippy -- -D warnings +cargo test +cargo run -- docs --only config # confirm [mcp] appears in regenerated docs +cd vscode-extension && npm run copy-types && cd .. # confirm new TS types regenerated +``` + +Expected: green across the board, generated docs and TS bindings updated. + +- [ ] **Step 4: Stop for user to commit** + +--- + +## Integration Handoff (sets up Phase 2 + Phase 3) + +This plan does **not** modify vscode-extension or write a Cursor integration. It sets the stage so the next two plans can be written and executed independently. + +**Phase 2 — vscode-extension refinement (separate follow-up plan):** +- The existing `vscode-extension/src/mcp-connect.ts:34-97` already consumes `/api/v1/mcp/descriptor`. After Task 6.5, the response carries an optional `stdio: StdioCommand` field. +- Phase 2 will modify `mcp-connect.ts` to: (a) detect the new field, (b) offer a workspace setting `operator.mcpTransport: "sse" | "stdio" | "auto"`, (c) when stdio is chosen/auto-selected, register operator via VS Code's modern MCP API as a stdio server using `descriptor.stdio.command` + `descriptor.stdio.args` + `descriptor.stdio.cwd`. Fallback remains SSE. +- The TypeScript binding for `StdioCommand` will be available via the existing `scripts/copy-types.js` flow once Task 7 Step 5 runs. + +**Phase 3 — Cursor integration (separate follow-up plan):** +- Cursor has no extension; it consumes `~/.cursor/mcp.json` directly. Task 8's `cursor_snippet()` already produces the right shape. +- Phase 3 will add either: (a) a `operator mcp install --client cursor` CLI subcommand that writes the snippet to `~/.cursor/mcp.json` (merging with existing servers), or (b) a docs page rendering the snippet with the current binary path, or (c) both. The dashboard's `WriteAndOpenMcpClientConfig { client: "cursor" }` action (Task 9) already covers the local-workspace path. +- Phase 3 should also document the JetBrains/Claude Desktop install flow using the same `snippet_for(client, cwd)` dispatch since those clients use the same `mcpServers` shape. + +--- + +## Self-Review + +**Spec coverage:** +- Stdio transport — Tasks 2, 3 +- Expanded tool surface (read + 4 write tools) — Tasks 4, 5, 5.5 +- Resources capability — Task 6 +- Descriptor stdio handoff for vscode-extension — Task 6.5 +- Config section — Task 7 +- Client config snippets — Task 8 +- Status integration (toggle + write-and-open snippet + docs link) — Task 9 +- End-to-end test — Task 10 +- Phase 2/3 handoff — Integration Handoff section + +**Open assumptions verified in Pre-Flight:** +1. ✓ `Queue` API at `src/queue/mod.rs:134-158` (sync methods). +2. ✓ `McpDescriptorResponse` exists at `src/mcp/descriptor.rs:14`. +3. ✓ `mcp_sessions: Arc>` — `.await` correct. +4. ⚠ Docs URL for `OpenMcpDocs` — placeholder used (`https://operator.untra.io/mcp/`); confirm before merging. +5. ⚠ VS Code MCP shape — verify against current extension behaviour in Task 8 Step 0. +6. ⚠ Dashboard's holding of `ApiState` — Task 9 Step 5 grep verifies; fallback to 0 sessions if not available. + +**Tradeoffs locked in:** +- HTTP and stdio MCP share the handler core. HTTP behavior is unchanged unless `config.mcp.http_enabled = false` (then routes are not mounted at startup; toggle requires restart). +- Ticket write tools are off by default. Users opt in via `[mcp].expose_ticket_write_tools = true`. +- The descriptor extension is additive (`Option`, `skip_serializing_if`) so existing vscode-extension code keeps working until Phase 2 chooses to use the new field. +- Snippet delivery is "write to file + open in editor," reusing `EditFile` — no clipboard dependency. +- Stdio resource subscription is `listChanged: false` — clients re-list rather than subscribe. Simpler; revisit if a real client demands push. diff --git a/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md new file mode 100644 index 00000000..235944ab --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md @@ -0,0 +1,426 @@ +# Operator Licensing Platform — Template Bootstrap Plan + +## Context + +Operator is a Rust TUI that the author (Sam / `untra`) wants to license and sell. Beyond Operator itself, the author intends to build other billable software products under the `untra` umbrella. The licensing/billing infrastructure must therefore be **reusable across future untra products**, not bespoke to Operator. + +Two concerns were brainstormed in this session: + +- **X. Operator's licensing & billing system** — sub-projects A (entitlement model) through F (admin tool). Roadmapped here; detailed designs deferred to follow-up sessions. +- **Y. Untra platform template & deployment skeleton** — the reusable cloud + DNS + auth + storefront skeleton that every untra product plugs into. **This is the focus of this session.** Y was promoted from a child of X to a top-level peer once the templating goal was made explicit. + +Goal of this session: produce a plan for creating six template repositories at `../templates/` — one per archetype. Each template gets a `README.md` and a `HANDOFF.md` and nothing else. A top-level `../templates/README.md` indexes the six archetypes for anyone landing in the directory. The templates are then fleshed out in independent follow-up Claude sessions, one per template, using the handoff briefs. + +Cost-deferral is a hard requirement: **no paid commitments should be necessary to complete Phase 0–1**. The first paid commitment is the root-domain registration (~$12/yr), happening at Phase 2. + +--- + +## Reading of the request + +This plan rests on one interpretation of the user's template list (`iac api app auth admin license`). Surfacing it loudly so it can be corrected at review time: + +- **`api`** — generic product-side backend. Cloud Run service in the public per-project monorepo. Exposes (1) an **unauthenticated** `/version` endpoint and (2) **authenticated** endpoints (verified against the entitlement JWT signed by `license`) that report user details and serve product business logic. Receives the **LemonSqueezy purchase webhook** and, on a successful sale, calls `license` over a signed internal request to mint a license record. +- **`license`** — privileged entitlement microservice. Lives in the **private** sister repo (`operator-private` for Operator; `-private` for other untra products). Holds the Ed25519 signing key in Secret Manager. Only `auth` and `api` may call it. Verifies a posted license key, returns/issues a signed entitlement JWT (booleans + integer limits, ~14-day TTL). Maintains the revocation list. +- **`auth`** — identity orchestrator at `auth.`. Wraps Firebase Auth (sign-in UI). After the user signs in, `auth` accepts a license key, calls `license` to verify it, and packages the resulting entitlements into a JWT (signed by `license`) returned to the client. Acts as the trust bridge between Firebase identity and license entitlements. + +If this reading is wrong, sections below collapse. Push back at spec review. + +--- + +## Roadmap + +### X. Operator licensing sub-projects (decomposition only) + +| # | Component | Lives in | Status | +|---|---|---|---| +| A | Entitlement model & license-key format | shared schema crate / proto | **Deferred** — detailed spec in a follow-up session. Eight decisions already locked (see "Entitlement model — locked decisions" below). | +| B | License verification service | `operator-private`, generated from `templates/license` | Built via Y. Detailed implementation in a follow-up session. | +| C | Operator client integration | this repo (`operator/`) | Future ticket. Reads entitlement JWT, exposes flags via OpenFeature provider. | +| D | Storefront + billing | LemonSqueezy hosted; no template. Adapters in `templates/app` + `templates/api`. | Built via Y. | +| E | Customer account site | `templates/app` instance | Built via Y. | +| F | Admin tool | `templates/admin` instance | Built via Y. | + +### Y. Untra platform template (this session) + +Six archetype template repos at `../templates/`: + +``` +templates/ +├── iac/ # Terraform/OpenTofu modules: Cloudflare, GCP, Firebase, Neon, IAP, Secret Manager +├── api/ # Generic product backend (Rust + Axum + Cloud Run) +├── app/ # Customer-facing web app (TypeScript SPA, sign-in via Firebase, license mgmt UI) +├── auth/ # Identity orchestrator (auth.): Firebase Auth UI + license-exchange endpoint +├── admin/ # Admin console (IAP-gated): plan editor, license mgmt, revocation +└── license/ # Privileged entitlement service (vendors into *-private repos only) +``` + +### Entitlement model — locked decisions (referenced by `license`) + +These were agreed earlier in the session. They become the "Entitlement Model — frozen" section in `license/README.md`. Detailed token schema/crypto is part of the deferred A-spec. + +1. **Vintage model:** Adobe-style year-versioned major releases (`standard-2025`, `enterprise-2026`). Each vintage is a distinct SKU. +2. **Access duration:** Perpetual one-time buy per vintage. No subscription expiry. +3. **Feature types:** Booleans + integer limits (e.g. `acp_enabled=true`, `max_projects=20`). +4. **Verification model:** Hybrid — short opaque license key + server-issued signed entitlement JWT cached for ~14 days. +5. **License scope:** Single user, soft cap of 3 machines per license. +6. **Account model:** One account owns many licenses over time. +7. **OpenFeature shape:** Custom OpenFeature provider in client (Rust SDK) reads flags from cached entitlement JWT. +8. **Revocation:** Revocation list + TTL-driven propagation. Already-cached tokens expire within ~14 days of revocation. + +--- + +## Service trust model + +``` + Firebase ID token + user ──signin──▶ auth. ──┐ + │ (verify license key + Firebase identity) + ▼ + license. + (Ed25519 sign entitlement JWT) + │ + client ◀───── entitlement JWT ───┘ + │ + ▼ Bearer JWT + api. ◀── verifies JWT against license public key (embedded) + │ + └── LemonSqueezy webhook ──▶ signed internal call ──▶ license (mint license) +``` + +**Trust boundary:** `license`'s signing key never leaves the private GCP project. `auth` and `api` only hold `license`'s public key. Public-monorepo CI cannot touch `license`'s secrets. + +--- + +## Architectural decisions (named, not buried) + +### D1. Two-repo structure per product (public + private) + +Every untra product produces **two** GitHub repos at provisioning time: + +- `/` — public monorepo, vendors `iac` + `api` + `app` + `auth` + `admin`. +- `-private/` — private monorepo, vendors `license` and a separate slice of `iac` (the private-side state, signing-key Secret Manager, separate Neon project). + +The two repos correspond to two separate GCP projects with no cross-project IAM. The only runtime coupling is the signed internal HTTPS call from `api` (public) to `license` (private). + +### D2. LemonSqueezy webhook lands in `api`, not `license` + +Webhooks have public, unauthenticated ingress by design. `api` already has a public surface and a Neon DB for user records — it's the right place to receive them. `api` then makes a **signed internal request** (HMAC over webhook payload + nonce) to `license` to mint the actual license. `license` never receives unauthenticated external traffic; the only external endpoints on `license` are token-signing endpoints called by `auth`. + +### D3. The first untra product (Operator) eats its own dog food + +Operator is both: +- a **consumer** of the platform (it has license keys, calls `auth` to refresh entitlements, gates features via OpenFeature) +- the **bootstrap operator** for future products (per its CLAUDE.md, "self-starting work multiplexor") + +Therefore Operator's licensing integration (sub-project C) and the platform templates (Y) must be designed so Operator can later orchestrate Phase 2 deployments for *new* untra products. This session does not implement that orchestration; it only avoids painting it into a corner. + +--- + +## Cost-deferral phases + +- **Phase 0** *(this session, $0)*: six templates exist at `../templates//`, each `git init`'d, each containing only `README.md` and `HANDOFF.md`. +- **Phase 1** *(follow-up Claude sessions, $0)*: each template gets fleshed out by a fresh Claude session using its HANDOFF.md. Output: working code, Dockerfiles, IaC modules. Still local; no cloud accounts needed. +- **Phase 2** *(first deployment, ~$12/yr)*: register Operator's root domain. Set up Cloudflare zone (free), GCP project (uses $300 trial credit), Firebase Auth (free tier), Neon Postgres (free tier), Google Secret Manager (free tier). Deploy all services to Cloud Run with `min_instances=0`. Cost ceiling: ~$12/yr for the year, possibly $0 within trial credit. +- **Phase 3** *(first sale)*: LemonSqueezy onboarding (~30 min, no business entity required). First transaction triggers the first revenue and the first 5%+50¢ fee. No upfront commitment. + +--- + +## What this session WILL produce (post-ExitPlanMode) + +**Top-level index file:** +1. Write `../templates/README.md` — a one-page index explaining the six archetype repos, the platform stack, and how they fit together. Section outline below. + +**Per archetype** (`iac`, `api`, `app`, `auth`, `admin`, `license`): +1. Create directory `../templates//`. +2. Run `git init` inside it. +3. Write `README.md` per the outline below. +4. Write `HANDOFF.md` per the outline below. +5. **Do not commit.** Per user instruction (memory: `feedback_no_commits.md`), the user handles all commits. + +**Promotion step:** +1. After the templates exist, copy this plan file to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` so it survives outside `~/.claude/plans/`. (User confirmed promotion at plan approval.) + +That is the entirety of the action. **No source files, no Dockerfiles, no Terraform.** Those are Phase 1, in separate sessions. + +--- + +## Top-level `../templates/README.md` outline + +A short index file at the root of the templates directory. Anyone who `cd`s into `../templates/` should understand the platform in under a minute. Sections in order: + +``` +# untra platform templates + +## What this directory is +One paragraph: these are archetype templates for untra's billable-SaaS platform. +Each subdirectory is its own git repo; they get vendored into per-product +monorepos at Phase 2. + +## The six archetypes +A table: archetype name | one-line role | vendors into (public/private). + +## Platform stack (defaults) +The cost-deferral stack table from the master spec, abbreviated: +domain (Cloudflare), compute (Cloud Run min=0), identity (Firebase Auth), +data (Neon Postgres), storefront (LemonSqueezy MoR), CI (GitHub Actions), +IaC (OpenTofu). Cost-at-zero-traffic ceiling: ~$12/yr (domain only). + +## Trust model +The auth → license → JWT → api diagram, in ASCII. + +## How a new product is provisioned +Cross-reference templates/iac/README.md "Quickstart" section for the manual +checklist. + +## Status +"Phase 0: READMEs and handoff briefs only. See each subdirectory's HANDOFF.md +to start implementation in a fresh Claude session." + +## Master spec +Pointer to operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md +``` + +--- + +## README.md outline (per template) + +A common shape, plus per-archetype detail. Each `README.md` should contain these sections in this order: + +``` +# — untra platform template + +## Purpose +One paragraph describing what an instance of this template does in a deployed untra product. + +## Role in the platform +A short list of which other archetypes this template talks to and how. + +## Tech stack +Language, framework, deployment target. From the locked Y stack. + +## Repository layout +The directory shape this template prescribes. + +## Quickstart (instantiation) +How a Phase 2 operator clones this template into a new product monorepo. + +## Configuration +Parameters that must be set per product (e.g. `{root_domain}`, `{gcp_project_id}`). + +## Cost profile +Free-tier story; what triggers paid usage. + +## Status +"Phase 0: README and handoff brief only. See HANDOFF.md to start implementation." + +## Links +Pointer to master spec, the platform stack table, related archetypes. +``` + +### Per-archetype README key facts + +**`templates/iac/README.md`:** +- Purpose: Terraform/OpenTofu modules that provision a single untra product's cloud (public + private sides). +- Modules to enumerate: `cloudflare_zone`, `gcp_project`, `cloud_run_service`, `firebase_auth`, `neon_project`, `secret_manager`, `iap_admin`, `github_oidc_wif`. +- Two top-level compositions: `iac/public/` and `iac/private/`, run against separate GCP projects. +- Backend state: GCS bucket per product, configured via `terraform init -backend-config`. + +**`templates/api/README.md`:** +- Purpose: generic product backend. Rust + Axum + Cloud Run. +- Endpoints (initial): `GET /version` (unauth), `GET /me` (entitlement-JWT-auth, returns user + active license summary), `POST /webhooks/lemonsqueezy` (HMAC-verified). +- Verifies entitlement JWT against `license` public key, embedded at build time. +- Talks to: Neon Postgres (user table, license cache table); calls `license` over signed HMAC internal request. + +**`templates/app/README.md`:** +- Purpose: customer-facing web app at `app.`. TypeScript + React + Vite + Firebase JS SDK. +- Routes (initial): `/` (landing/download), `/signin` (redirects to `auth.`), `/account` (signed-in: shows licenses, machines), `/licenses/:id` (manage machines for a license). +- Calls `auth.` for sign-in flow; calls `api.` for authenticated data. +- Static-friendly: deployable to Cloud Storage + Cloud CDN or Cloudflare Pages. + +**`templates/auth/README.md`:** +- Purpose: identity orchestrator at `auth.`. Cloud Run service + small static UI. +- Flow: user signs in via Firebase (email/password, magic-link, OAuth) → static UI captures Firebase ID token → `auth` calls `license.` to verify entitlement → returns entitlement JWT to client. Also handles license-key claim (first-time activation) and machine registration (machine-fingerprint binding within the 3-machine cap). +- Endpoints: `POST /claim` (Firebase token + license key + machine fingerprint), `POST /refresh` (Firebase token + machine fingerprint). + +**`templates/admin/README.md`:** +- Purpose: admin console at `admin.`, gated by GCP IAP (no Firebase Auth — admin is internal). +- Routes (initial): `/plans` (define plans + feature bundles via OpenFeature schema), `/licenses` (search, view, revoke), `/users` (view accounts), `/billing` (LemonSqueezy passthrough links). +- Calls `api` for read data, calls `license` for write actions (mint license manually, revoke). + +**`templates/license/README.md`:** +- Purpose: privileged entitlement service. Rust + Axum + Cloud Run. Lives in private sister repo only. +- **Embed the eight locked entitlement decisions verbatim** (see "Entitlement model — locked decisions" above) as a "Frozen entitlement model" section. +- Endpoints: `POST /internal/verify` (called by `auth`, HMAC-authed, returns entitlement JWT), `POST /internal/mint` (called by `api`, HMAC-authed, creates a license record), `POST /internal/revoke` (called by `admin`, HMAC-authed). All endpoints are internal-only (Cloud Run with IAM-based ingress restriction). +- Crypto: Ed25519 signing key in Secret Manager; public key available at a public, unauth, cacheable `GET /.well-known/license-public-key` endpoint (used by client-side OpenFeature provider and by `api` for JWT verification). +- Detailed token schema and key-rotation policy: **deferred to A-spec follow-up.** + +--- + +## HANDOFF.md outline (per template) + +Fixed structure across all six templates so the briefs are interchangeable. Each `HANDOFF.md` should contain these sections in this order: + +``` +# Handoff brief — + +> Read this file before starting implementation. You are a fresh Claude session +> with no prior context. The README.md in this same directory has the role and +> stack. This file tells you what "done" looks like for the first milestone. + +## Pointer to master spec +Path: ~/.claude/plans/this-project-operator-is-rustling-tome.md +(or wherever the user has moved it after approval — check first). + +## Role (one paragraph) +Restate the role from README. Confirms shared interpretation. + +## Acceptance criteria (testable) +Concrete, runnable checks. Examples: +- "Produces a Cloud Run service that responds HTTP 200 to /healthz" +- "Returns HTTP 401 to /me when no Authorization header is present" +- "Terraform plan against a clean GCP project produces zero errors" + +## Public interface +- HTTP endpoints / UI routes / Terraform variables / library exports. +- Schemas where they exist (link to A-spec for entitlement JWT schema). + +## Dependencies +- Which other archetypes this calls (by name). +- Which other archetypes call this (by name). +- External services (Firebase, Neon, Cloudflare, LemonSqueezy). + +## Non-goals +Explicit list of things NOT to build in this session. Examples for `api`: +- Do not implement license signing — that's `license`'s job. +- Do not build the admin endpoints — those live in `admin`. +- Do not add OpenTelemetry exporters yet; add `tracing` only. + +## First milestone (smallest deployable slice) +A specific, minimal end-to-end slice that proves the template works. +Example for `api`: "GET /version returns {\"version\":\"0.1.0\"} as JSON, +deployed to Cloud Run min=0, served at api.." + +## Out-of-scope flags for later milestones +List of features to leave as `// TODO(milestone-2)` comments so the next +session knows what comes next. +``` + +### Per-archetype HANDOFF key facts + +**`templates/iac/HANDOFF.md`:** +- First milestone: a `terraform plan` for a hypothetical product `example-product` against a fresh GCP project produces a valid plan (no apply required this milestone). +- Non-goals: do not write a GitHub Actions workflow that runs `terraform apply` yet; do not provision DNS records for the private domain (private side is its own composition). + +**`templates/api/HANDOFF.md`:** +- First milestone: `GET /version` returns the current version as JSON; service builds into a Cloud Run image via the included Dockerfile; `curl localhost:8080/version` works locally. +- Non-goals: do not implement webhook signature verification yet (stub it); do not implement `/me` yet; do not connect to Neon yet (stub the DB layer). + +**`templates/app/HANDOFF.md`:** +- First milestone: a static site that renders a landing page with a "Download Operator" button and a "Sign In" link to `auth./signin`; `npm run build` produces a deployable `dist/`. +- Non-goals: no `/account` page yet; no API integration; no Firebase wiring in JS yet (just a link). + +**`templates/auth/HANDOFF.md`:** +- First milestone: a Cloud Run service exposing a static `/signin` page (Firebase UI or hand-rolled email-link form) that successfully signs a user in and shows their Firebase UID; `POST /claim` is stubbed and returns `501 Not Implemented`. +- Non-goals: do not call `license` yet (stub the call); do not implement machine-fingerprint logic yet; do not implement `/refresh`. + +**`templates/admin/HANDOFF.md`:** +- First milestone: a Cloud Run service with IAP enforcement that renders a "Hello, {user.email}" page sourced from `X-Goog-Authenticated-User-Email`. +- Non-goals: no plan editor yet; no license search; no revocation UI; no API integration. + +**`templates/license/HANDOFF.md`:** +- First milestone: a Cloud Run service with a `/healthz` endpoint and a `/.well-known/license-public-key` endpoint that returns a hardcoded Ed25519 public key (real key generation deferred to A-spec). +- Non-goals: do not implement `/internal/verify`, `/internal/mint`, or `/internal/revoke` yet; do not implement the revocation list; do not implement HMAC validation of internal callers yet (return `501` with a `TODO` comment); do not freeze the entitlement JWT schema (waits on A-spec follow-up). + +--- + +## Critical files to be created + +``` +../templates/README.md (top-level index, not in any git repo) +../templates/iac/README.md +../templates/iac/HANDOFF.md +../templates/iac/.git/ (git init only) +../templates/api/README.md +../templates/api/HANDOFF.md +../templates/api/.git/ +../templates/app/README.md +../templates/app/HANDOFF.md +../templates/app/.git/ +../templates/auth/README.md +../templates/auth/HANDOFF.md +../templates/auth/.git/ +../templates/admin/README.md +../templates/admin/HANDOFF.md +../templates/admin/.git/ +../templates/license/README.md +../templates/license/HANDOFF.md +../templates/license/.git/ +``` + +Plus the promotion copy: + +``` +operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md +``` + +Total: 13 files written, 6 directories `git init`'d, 1 spec file promoted. **Zero commits.** + +--- + +## Verification + +After implementation: + +```bash +# 0. Top-level index exists. +test -f ../templates/README.md && echo "top README OK" || echo "TOP README MISSING" + +# 1. All directories exist and are git repos. +for t in iac api app auth admin license; do + test -d ../templates/$t/.git && echo "$t: git OK" || echo "$t: GIT MISSING" +done + +# 2. Both files exist per template. +for t in iac api app auth admin license; do + test -f ../templates/$t/README.md && test -f ../templates/$t/HANDOFF.md \ + && echo "$t: files OK" || echo "$t: files MISSING" +done + +# 3. No stray files inside template repos. +find ../templates -mindepth 2 -maxdepth 2 -type f \ + ! -name README.md ! -name HANDOFF.md ! -path '*/.git/*' \ + | grep -v "^$" && echo "stray files present" || echo "no stray files" + +# 4. No commits yet (user commits manually). +for t in iac api app auth admin license; do + ( cd ../templates/$t && test -z "$(git log 2>/dev/null)" \ + && echo "$t: no commits OK" || echo "$t: HAS COMMITS" ) +done + +# 5. Promoted spec exists. +test -f operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md \ + && echo "promoted spec OK" || echo "PROMOTED SPEC MISSING" +``` + +After Phase 1 (separate sessions, out of scope here): each template implements its first-milestone acceptance criteria. + +--- + +## Non-goals of this plan (explicit) + +- No code beyond `README.md` and `HANDOFF.md`. +- No commits anywhere. +- No registration of any cloud account, domain, or LemonSqueezy account. +- No detailed entitlement-JWT schema or key rotation policy (that's A-spec follow-up). +- No work in `operator/` itself in this session (Operator's licensing-client integration C is a future ticket). +- No GitHub Actions workflows, Dockerfiles, or Terraform code. +- No bootstrap script (user chose "manual checklist" — checklist content lives in `templates/iac/README.md` Quickstart section, not as separate code). + +--- + +## Follow-ups queued after this plan executes + +1. **A-spec brainstorm session** — flesh out the entitlement-JWT schema, key format, key rotation, machine-fingerprint algorithm. Produces the detailed `license` data model. +2. **Per-template implementation sessions (6 of them)** — each starts a fresh Claude in the relevant `../templates//` directory and works from HANDOFF.md. +3. **Operator integration (C)** — separate ticket in `operator/` to add the OpenFeature provider, license-key UI, refresh loop. Depends on A-spec being done. +4. **Promote this plan** — confirmed at approval. Copy to `operator/docs/superpowers/specs/2026-05-20-licensing-platform-and-templates-design.md` as part of this session's deliverable (user commits manually). diff --git a/docs/backstage/taxonomy.md b/docs/taxonomy/index.md similarity index 93% rename from docs/backstage/taxonomy.md rename to docs/taxonomy/index.md index d1b8e897..b521b5d3 100644 --- a/docs/backstage/taxonomy.md +++ b/docs/taxonomy/index.md @@ -3,25 +3,25 @@ title: "Project Taxonomy" layout: doc --- - + # Project Taxonomy This document defines the **25 project Kinds** organized into **5 tiers**. -Each Kind represents a category of project that can be cataloged in Backstage. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files. +Each Kind represents a category of project that can be classified by Operator. The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files. ## Version - **Version**: `1.0.0` -- **Description**: Operator project taxonomy for Backstage catalog +- **Description**: Operator project taxonomy for project classification ## Quick Reference All 24 Kinds at a glance: -| ID | Key | Name | Tier | Backstage Type | +| ID | Key | Name | Tier | Catalog Type | | --- | --- | --- | --- | --- | | 1 | `infrastructure` | Infrastructure (IaC) | foundation | `resource` | | 2 | `identity-access` | Identity & Access (IAM) | foundation | `resource` | @@ -67,7 +67,7 @@ Cloud resources and network (Terraform/CDK) - **Key**: `infrastructure` - **Stakeholder**: Platform/DevOps - **Primary Output**: Cloud Environment -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `*.tf` @@ -88,7 +88,7 @@ Service accounts, secrets, and RBAC policies - **Key**: `identity-access` - **Stakeholder**: SDET/SecOps - **Primary Output**: Permissions/Tokens -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `iam-*.yaml` @@ -108,7 +108,7 @@ Global feature flags and environment manifests - **Key**: `config-policy` - **Stakeholder**: Platform/DevOps - **Primary Output**: Runtime Behavior -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `config/*.yaml` @@ -128,7 +128,7 @@ Orchestration for projects and root standards - **Key**: `monorepo-meta` - **Stakeholder**: Architect/Lead - **Primary Output**: Project Standards -- **Backstage Type**: `system` +- **Catalog Type**: `system` **Detection** File Patterns: - `nx.json` @@ -161,7 +161,7 @@ Reusable UI components and brand tokens - **Key**: `design-system` - **Stakeholder**: Product/UX - **Primary Output**: Component Libraries -- **Backstage Type**: `library` +- **Catalog Type**: `library` **Detection** File Patterns: - `tokens/*.json` @@ -180,7 +180,7 @@ Reusable internal logic packages (Shared Utils) - **Key**: `software-library` - **Stakeholder**: Engineering - **Primary Output**: Versioned Packages -- **Backstage Type**: `library` +- **Catalog Type**: `library` **Detection** File Patterns: - `lib/*` @@ -199,7 +199,7 @@ API contracts and generated client libraries - **Key**: `proto-sdk` - **Stakeholder**: Engineering - **Primary Output**: Contract Libraries -- **Backstage Type**: `api` +- **Catalog Type**: `api` **Detection** File Patterns: - `*.proto` @@ -222,7 +222,7 @@ Scaffolding templates for bootstrapping repos - **Key**: `blueprint` - **Stakeholder**: Architect/Lead - **Primary Output**: Project Templates -- **Backstage Type**: `template` +- **Catalog Type**: `template` **Detection** File Patterns: - `template.yaml` @@ -241,7 +241,7 @@ Custom scanners, audit scripts, and honeytokens - **Key**: `security-tooling` - **Stakeholder**: SDET/SecOps - **Primary Output**: Security Reports -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `security/*` @@ -262,7 +262,7 @@ Evidence, snapshots, and regulatory reports - **Key**: `compliance-audit` - **Stakeholder**: SDET/SecOps - **Primary Output**: Compliance Proofs -- **Backstage Type**: `documentation` +- **Catalog Type**: `documentation` **Detection** File Patterns: - `compliance/*` @@ -295,7 +295,7 @@ Training scripts and model weight artifacts - **Key**: `ml-model` - **Stakeholder**: Data/ML - **Primary Output**: Model Artifacts -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `model/*` @@ -318,7 +318,7 @@ Data transformation logic and SQL models - **Key**: `data-etl` - **Stakeholder**: Data/ML - **Primary Output**: Clean Datasets -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `dbt_project.yml` @@ -338,7 +338,7 @@ Backend business logic and domain units - **Key**: `microservice` - **Stakeholder**: Engineering - **Primary Output**: Running Binaries -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `src/main.rs` @@ -359,7 +359,7 @@ Entry points that route and protect traffic - **Key**: `api-gateway` - **Stakeholder**: Engineering - **Primary Output**: Network Endpoints -- **Backstage Type**: `api` +- **Catalog Type**: `api` **Detection** File Patterns: - `gateway/*` @@ -380,7 +380,7 @@ Web or mobile apps for end-user interaction - **Key**: `ui-frontend` - **Stakeholder**: Engineering - **Primary Output**: Web/Mobile Assets -- **Backstage Type**: `website` +- **Catalog Type**: `website` **Detection** File Patterns: - `src/App.tsx` @@ -402,7 +402,7 @@ Private apps for internal business operations - **Key**: `internal-tool` - **Stakeholder**: Engineering - **Primary Output**: Operational Apps -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `admin/*` @@ -431,7 +431,7 @@ CI/CD actions and custom build logic - **Key**: `build-tool` - **Stakeholder**: Platform/DevOps - **Primary Output**: Automated Pipelines -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `.github/workflows/*` @@ -452,7 +452,7 @@ Integration tests and smoke test runners - **Key**: `e2e-test` - **Stakeholder**: SDET/SecOps - **Primary Output**: Quality Reports -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `e2e/*` @@ -473,7 +473,7 @@ Documentation, tutorials, and references - **Key**: `docs-site` - **Stakeholder**: Product/UX - **Primary Output**: Static Support Sites -- **Backstage Type**: `website` +- **Catalog Type**: `website` **Detection** File Patterns: - `docs/*` @@ -494,7 +494,7 @@ Incident response and on-call runbooks - **Key**: `playbook` - **Stakeholder**: Platform/DevOps - **Primary Output**: Operational Guides -- **Backstage Type**: `documentation` +- **Catalog Type**: `documentation` **Detection** File Patterns: - `playbooks/*` @@ -512,7 +512,7 @@ Productivity scripts and developer utilities - **Key**: `cli-devtool` - **Stakeholder**: Platform/DevOps - **Primary Output**: Developer UX Tools -- **Backstage Type**: `tool` +- **Catalog Type**: `tool` **Detection** File Patterns: - `cli/*` @@ -541,7 +541,7 @@ Best-practice implementation examples - **Key**: `reference-example` - **Stakeholder**: Architect/Lead - **Primary Output**: Educational Code -- **Backstage Type**: `documentation` +- **Catalog Type**: `documentation` **Detection** File Patterns: - `examples/*` @@ -558,7 +558,7 @@ Proof-of-concepts and R&D "spikes" - **Key**: `experiment-sandbox` - **Stakeholder**: Engineering - **Primary Output**: Discardable Code -- **Backstage Type**: `service` +- **Catalog Type**: `service` **Detection** File Patterns: - `experiments/*` @@ -576,7 +576,7 @@ Legacy code and forks of 3rd party repos - **Key**: `archival-fork` - **Stakeholder**: SDET/SecOps - **Primary Output**: Historical/Vendor Code -- **Backstage Type**: `library` +- **Catalog Type**: `library` **Detection** File Patterns: - `vendor/*` @@ -594,7 +594,7 @@ Repositories containing test data, fixtures, seed data, and mock datasets - **Key**: `test-data-fixtures` - **Stakeholder**: SDET/SecOps - **Primary Output**: Test Data Assets -- **Backstage Type**: `resource` +- **Catalog Type**: `resource` **Detection** File Patterns: - `fixtures/*` @@ -901,11 +901,11 @@ Patterns use glob syntax: - `*.seed.sql` - `db/seeds/*` -## Backstage Type Mapping +## Catalog Type Mapping -Each Kind maps to a Backstage catalog type: +Each Kind maps to a catalog type: -| Backstage Type | Kinds | +| Catalog Type | Kinds | | --- | --- | | `api` | `proto-sdk`, `api-gateway` | | `documentation` | `compliance-audit`, `playbook`, `reference-example` | diff --git a/icons/coder.svg b/icons/coder.svg new file mode 100644 index 00000000..a0acff3a --- /dev/null +++ b/icons/coder.svg @@ -0,0 +1 @@ +Coder \ No newline at end of file diff --git a/icons/zedindustries.svg b/icons/zedindustries.svg new file mode 100644 index 00000000..02327fd1 --- /dev/null +++ b/icons/zedindustries.svg @@ -0,0 +1 @@ +Zed Industries \ No newline at end of file diff --git a/opr8r/Cargo.lock b/opr8r/Cargo.lock index afb58bbb..e6b687bb 100644 --- a/opr8r/Cargo.lock +++ b/opr8r/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -19,15 +19,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -95,9 +95,9 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -107,9 +107,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.52" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "shlex", @@ -129,9 +129,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.54" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -139,9 +139,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -151,9 +151,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -163,15 +163,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "dirs" @@ -223,26 +223,25 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "foldhash" @@ -270,35 +269,35 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-task", "pin-project-lite", - "pin-utils", + "slab", ] [[package]] @@ -352,9 +351,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -364,9 +363,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -403,9 +402,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -416,7 +415,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -424,15 +422,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -441,14 +438,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -465,12 +461,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -478,9 +475,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -491,9 +488,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -505,15 +502,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -525,15 +522,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -563,9 +560,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -578,7 +575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -614,19 +611,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -636,16 +623,18 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -662,9 +651,9 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ "bitflags 2.11.1", "libc", @@ -678,9 +667,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" @@ -688,29 +677,26 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru-slab" @@ -720,15 +706,15 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -766,9 +752,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -793,7 +779,7 @@ dependencies = [ [[package]] name = "opr8r" -version = "0.1.31" +version = "0.2.0" dependencies = [ "clap", "operator-relay", @@ -819,27 +805,15 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "plain" -version = "0.2.3" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -865,9 +839,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -886,7 +860,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -894,9 +868,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -907,7 +881,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -929,9 +903,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -950,9 +924,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -970,22 +944,13 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1b3bc831f92381018fd9c6350b917c7b21f1eed35a65a51900e0e55a3d7afa" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -1051,15 +1016,15 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.11.1", "errno", @@ -1070,9 +1035,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -1084,9 +1049,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1111,9 +1076,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -1162,9 +1127,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1203,9 +1168,9 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -1215,12 +1180,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1243,9 +1208,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1274,12 +1239,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1296,11 +1261,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -1316,9 +1281,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1327,9 +1292,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1337,9 +1302,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -1352,9 +1317,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -1368,9 +1333,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1404,20 +1369,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -1471,9 +1436,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-xid" @@ -1549,11 +1514,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.46.0", + "wit-bindgen 0.57.1", ] [[package]] @@ -1567,9 +1532,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -1580,22 +1545,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1603,9 +1565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -1616,9 +1578,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -1659,9 +1621,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -1679,9 +1641,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -1923,12 +1885,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1938,6 +1894,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -2019,15 +1981,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2036,9 +1998,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2048,18 +2010,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2068,18 +2030,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2095,9 +2057,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2106,9 +2068,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2117,9 +2079,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -2128,6 +2090,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.13" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index 8ef3ad55..e096a9b4 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opr8r" -version = "0.1.31" +version = "0.2.0" edition = "2021" description = "Minimal CLI wrapper for LLM commands in multi-step ticket workflows" license = "MIT" diff --git a/plugins/plugin-issuetypes/src/api/OperatorApi.ts b/plugins/plugin-issuetypes/src/api/OperatorApi.ts deleted file mode 100644 index 0f229a2c..00000000 --- a/plugins/plugin-issuetypes/src/api/OperatorApi.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Operator API interface definition. - */ -import { createApiRef } from '@backstage/core-plugin-api'; -import type { - IssueTypeSummary, - IssueTypeResponse, - CollectionResponse, - StepResponse, - StatusResponse, - CreateIssueTypeRequest, - UpdateIssueTypeRequest, - UpdateStepRequest, -} from './types'; - -/** Interface for the Operator API */ -export interface OperatorApi { - // Health & Status - getStatus(): Promise; - - // Issue Types - listIssueTypes(): Promise; - getIssueType(key: string): Promise; - createIssueType(request: CreateIssueTypeRequest): Promise; - updateIssueType( - key: string, - request: UpdateIssueTypeRequest, - ): Promise; - deleteIssueType(key: string): Promise; - - // Steps - getSteps(issueTypeKey: string): Promise; - getStep(issueTypeKey: string, stepName: string): Promise; - updateStep( - issueTypeKey: string, - stepName: string, - request: UpdateStepRequest, - ): Promise; - - // Collections - listCollections(): Promise; - getActiveCollection(): Promise; - getCollection(name: string): Promise; - activateCollection(name: string): Promise; -} - -/** API ref for dependency injection */ -export const operatorApiRef = createApiRef({ - id: 'plugin.operator.api', -}); diff --git a/plugins/plugin-issuetypes/src/api/OperatorApiClient.ts b/plugins/plugin-issuetypes/src/api/OperatorApiClient.ts deleted file mode 100644 index 4756192d..00000000 --- a/plugins/plugin-issuetypes/src/api/OperatorApiClient.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Operator API client implementation. - * Communicates with the Operator REST API via Backstage proxy. - */ -import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; -import type { OperatorApi } from './OperatorApi'; -import type { - IssueTypeSummary, - IssueTypeResponse, - CollectionResponse, - StepResponse, - StatusResponse, - CreateIssueTypeRequest, - UpdateIssueTypeRequest, - UpdateStepRequest, - ErrorResponse, -} from './types'; - -/** Options for creating the Operator API client */ -export interface OperatorApiClientOptions { - discoveryApi: DiscoveryApi; - fetchApi: FetchApi; -} - -/** API client error with status code */ -export class OperatorApiError extends Error { - constructor( - message: string, - public readonly status: number, - public readonly errorCode?: string, - ) { - super(message); - this.name = 'OperatorApiError'; - } -} - -/** Implementation of the Operator API */ -export class OperatorApiClient implements OperatorApi { - private readonly discoveryApi: DiscoveryApi; - private readonly fetchApi: FetchApi; - - constructor(options: OperatorApiClientOptions) { - this.discoveryApi = options.discoveryApi; - this.fetchApi = options.fetchApi; - } - - /** Get the base URL for the Operator API via proxy */ - private async getBaseUrl(): Promise { - const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); - return `${proxyUrl}/operator`; - } - - /** Make a request to the Operator API */ - private async request( - path: string, - options?: RequestInit, - ): Promise { - const baseUrl = await this.getBaseUrl(); - const url = `${baseUrl}${path}`; - - const response = await this.fetchApi.fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); - - if (!response.ok) { - let errorMessage = `API error: ${response.status} ${response.statusText}`; - let errorCode: string | undefined; - - try { - const errorBody: ErrorResponse = await response.json(); - errorMessage = errorBody.message; - errorCode = errorBody.error; - } catch { - // Use default error message if parsing fails - } - - throw new OperatorApiError(errorMessage, response.status, errorCode); - } - - // Handle empty responses (e.g., DELETE) - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return response.json(); - } - - return undefined as T; - } - - // Health & Status - - async getStatus(): Promise { - return this.request('/api/v1/status'); - } - - // Issue Types - - async listIssueTypes(): Promise { - return this.request('/api/v1/issuetypes'); - } - - async getIssueType(key: string): Promise { - return this.request(`/api/v1/issuetypes/${encodeURIComponent(key)}`); - } - - async createIssueType( - request: CreateIssueTypeRequest, - ): Promise { - return this.request('/api/v1/issuetypes', { - method: 'POST', - body: JSON.stringify(request), - }); - } - - async updateIssueType( - key: string, - request: UpdateIssueTypeRequest, - ): Promise { - return this.request(`/api/v1/issuetypes/${encodeURIComponent(key)}`, { - method: 'PUT', - body: JSON.stringify(request), - }); - } - - async deleteIssueType(key: string): Promise { - await this.request(`/api/v1/issuetypes/${encodeURIComponent(key)}`, { - method: 'DELETE', - }); - } - - // Steps - - async getSteps(issueTypeKey: string): Promise { - return this.request( - `/api/v1/issuetypes/${encodeURIComponent(issueTypeKey)}/steps`, - ); - } - - async getStep( - issueTypeKey: string, - stepName: string, - ): Promise { - return this.request( - `/api/v1/issuetypes/${encodeURIComponent(issueTypeKey)}/steps/${encodeURIComponent(stepName)}`, - ); - } - - async updateStep( - issueTypeKey: string, - stepName: string, - request: UpdateStepRequest, - ): Promise { - return this.request( - `/api/v1/issuetypes/${encodeURIComponent(issueTypeKey)}/steps/${encodeURIComponent(stepName)}`, - { - method: 'PUT', - body: JSON.stringify(request), - }, - ); - } - - // Collections - - async listCollections(): Promise { - return this.request('/api/v1/collections'); - } - - async getActiveCollection(): Promise { - return this.request('/api/v1/collections/active'); - } - - async getCollection(name: string): Promise { - return this.request(`/api/v1/collections/${encodeURIComponent(name)}`); - } - - async activateCollection(name: string): Promise { - return this.request( - `/api/v1/collections/${encodeURIComponent(name)}/activate`, - { - method: 'PUT', - }, - ); - } -} diff --git a/plugins/plugin-issuetypes/src/api/index.ts b/plugins/plugin-issuetypes/src/api/index.ts deleted file mode 100644 index 8b1d5eb5..00000000 --- a/plugins/plugin-issuetypes/src/api/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * API module exports. - */ - -// API ref and interface -export { operatorApiRef } from './OperatorApi'; -export type { OperatorApi } from './OperatorApi'; - -// API client implementation -export { OperatorApiClient, OperatorApiError } from './OperatorApiClient'; -export type { OperatorApiClientOptions } from './OperatorApiClient'; - -// Types -export * from './types'; diff --git a/plugins/plugin-issuetypes/src/api/types.ts b/plugins/plugin-issuetypes/src/api/types.ts deleted file mode 100644 index 8332f1f8..00000000 --- a/plugins/plugin-issuetypes/src/api/types.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * TypeScript types for the Operator REST API. - * These mirror the Rust DTOs in src/rest/dto.rs - */ - -/** Execution mode for issue types */ -export type ExecutionMode = 'autonomous' | 'paired'; - -/** Permission mode for steps */ -export type PermissionMode = 'default' | 'plan' | 'acceptEdits' | 'delegate'; - -/** Field types for issue type fields */ -export type FieldType = 'string' | 'enum' | 'bool' | 'date' | 'text'; - -/** Step output types */ -export type StepOutput = - | 'plan' - | 'code' - | 'test' - | 'pr' - | 'ticket' - | 'review' - | 'report' - | 'documentation'; - -/** Summary response for listing issue types */ -export interface IssueTypeSummary { - key: string; - name: string; - description: string; - mode: ExecutionMode; - glyph: string; - source: string; - step_count: number; -} - -/** Full response for a single issue type */ -export interface IssueTypeResponse { - key: string; - name: string; - description: string; - mode: ExecutionMode; - glyph: string; - color?: string; - project_required: boolean; - source: string; - fields: FieldResponse[]; - steps: StepResponse[]; -} - -/** Response for a field within an issue type */ -export interface FieldResponse { - name: string; - description: string; - field_type: FieldType; - required: boolean; - default?: string; - options: string[]; - placeholder?: string; - max_length?: number; - user_editable: boolean; -} - -/** Response for a step within an issue type */ -export interface StepResponse { - name: string; - display_name?: string; - prompt: string; - outputs: StepOutput[]; - allowed_tools: string[]; - requires_review: boolean; - next_step?: string; - permission_mode: PermissionMode; -} - -/** Response for a collection of issue types */ -export interface CollectionResponse { - name: string; - description: string; - types: string[]; - is_active: boolean; -} - -/** Request to create a new issue type */ -export interface CreateIssueTypeRequest { - key: string; - name: string; - description: string; - mode?: ExecutionMode; - glyph: string; - color?: string; - project_required?: boolean; - fields?: CreateFieldRequest[]; - steps: CreateStepRequest[]; -} - -/** Request to update an existing issue type */ -export interface UpdateIssueTypeRequest { - name?: string; - description?: string; - mode?: ExecutionMode; - glyph?: string; - color?: string; - project_required?: boolean; - fields?: CreateFieldRequest[]; - steps?: CreateStepRequest[]; -} - -/** Request to create a field */ -export interface CreateFieldRequest { - name: string; - description: string; - field_type?: FieldType; - required?: boolean; - default?: string; - options?: string[]; - placeholder?: string; - max_length?: number; - user_editable?: boolean; -} - -/** Request to create a step */ -export interface CreateStepRequest { - name: string; - display_name?: string; - prompt: string; - outputs?: StepOutput[]; - allowed_tools?: string[]; - requires_review?: boolean; - next_step?: string; - permission_mode?: PermissionMode; -} - -/** Request to update a step */ -export interface UpdateStepRequest { - display_name?: string; - prompt?: string; - outputs?: StepOutput[]; - allowed_tools?: string[]; - requires_review?: boolean; - next_step?: string; - permission_mode?: PermissionMode; -} - -/** Health check response */ -export interface HealthResponse { - status: string; - version: string; -} - -/** Status response with registry info */ -export interface StatusResponse { - status: string; - version: string; - issuetype_count: number; - collection_count: number; - active_collection: string; -} - -/** Error response from the API */ -export interface ErrorResponse { - error: string; - message: string; -} diff --git a/plugins/plugin-issuetypes/src/components/CollectionsPage.tsx b/plugins/plugin-issuetypes/src/components/CollectionsPage.tsx deleted file mode 100644 index b4312023..00000000 --- a/plugins/plugin-issuetypes/src/components/CollectionsPage.tsx +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Collections management page component. - */ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Content, - ContentHeader, - Header, - HeaderLabel, - Page, - Progress, - SupportButton, -} from '@backstage/core-components'; -import { - Box, - Button, - Card, - CardActions, - CardContent, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Grid, - Typography, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; -import CheckCircleIcon from '@material-ui/icons/CheckCircle'; -import { Alert } from '@material-ui/lab'; -import { useCollections, useActivateCollection } from '../hooks/useCollections'; -import type { CollectionResponse } from '../api/types'; - -const useStyles = makeStyles(theme => ({ - card: { - height: '100%', - display: 'flex', - flexDirection: 'column', - }, - cardContent: { - flexGrow: 1, - }, - activeCard: { - borderColor: theme.palette.success.main, - borderWidth: 2, - }, - activeBadge: { - backgroundColor: theme.palette.success.main, - color: theme.palette.success.contrastText, - }, - typeChip: { - margin: theme.spacing(0.5), - }, - typesContainer: { - marginTop: theme.spacing(2), - }, -})); - -/** Collection card component */ -function CollectionCard({ - collection, - onActivate, - activating, -}: { - collection: CollectionResponse; - onActivate: () => void; - activating: boolean; -}) { - const classes = useStyles(); - - return ( - - - - {collection.name} - {collection.is_active && ( - } - label="Active" - size="small" - className={classes.activeBadge} - /> - )} - - - - {collection.description} - - - - - Issue Types ({collection.types.length}): - - - {collection.types.map(type => ( - - ))} - - - - - - {!collection.is_active && ( - - )} - - - ); -} - -/** Main collections page component */ -export function CollectionsPage() { - const navigate = useNavigate(); - const { collections, loading, error, retry } = useCollections(); - const { activateCollection, activating } = useActivateCollection(); - const [confirmDialog, setConfirmDialog] = useState(null); - const [activatingName, setActivatingName] = useState(null); - - if (loading) { - return ; - } - - const handleActivate = async (name: string) => { - setActivatingName(name); - try { - await activateCollection(name); - retry(); - } finally { - setActivatingName(null); - setConfirmDialog(null); - } - }; - - const activeCollection = collections?.find(c => c.is_active); - - return ( - -
- {activeCollection && ( - - )} -
- - - - - Collections group issue types together. Only one collection can be - active at a time. - - - - {error && ( - Retry} - style={{ marginBottom: 16 }} - > - Failed to load collections: {error.message} - - )} - - - {collections?.map(collection => ( - - setConfirmDialog(collection.name)} - activating={activating && activatingName === collection.name} - /> - - ))} - {collections?.length === 0 && ( - - - No collections found. - - - )} - - - {/* Activation confirmation dialog */} - setConfirmDialog(null)} - > - Activate Collection - - - Are you sure you want to activate the collection "{confirmDialog}"? - This will deactivate the currently active collection. - - - - - - - - -
- ); -} diff --git a/plugins/plugin-issuetypes/src/components/IssueTypeDetailPage.tsx b/plugins/plugin-issuetypes/src/components/IssueTypeDetailPage.tsx deleted file mode 100644 index 0bb79d50..00000000 --- a/plugins/plugin-issuetypes/src/components/IssueTypeDetailPage.tsx +++ /dev/null @@ -1,401 +0,0 @@ -/** - * Issue Type detail page component. - */ -import React, { useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { - Content, - ContentHeader, - Header, - HeaderLabel, - Page, - Progress, -} from '@backstage/core-components'; -import { - Box, - Button, - Card, - CardContent, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Divider, - Grid, - List, - ListItem, - ListItemText, - Paper, - Typography, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; -import DeleteIcon from '@material-ui/icons/Delete'; -import EditIcon from '@material-ui/icons/Edit'; -import { Alert } from '@material-ui/lab'; -import { useIssueType, useDeleteIssueType } from '../hooks/useIssueTypes'; -import type { StepResponse, FieldResponse } from '../api/types'; - -const useStyles = makeStyles(theme => ({ - header: { - marginBottom: theme.spacing(2), - }, - glyph: { - fontSize: '4rem', - fontWeight: 'bold', - color: theme.palette.primary.main, - }, - section: { - marginTop: theme.spacing(3), - }, - sectionTitle: { - marginBottom: theme.spacing(2), - }, - stepCard: { - marginBottom: theme.spacing(2), - }, - stepNumber: { - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - borderRadius: '50%', - width: 32, - height: 32, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - marginRight: theme.spacing(2), - }, - prompt: { - backgroundColor: theme.palette.background.default, - padding: theme.spacing(1), - borderRadius: theme.shape.borderRadius, - fontFamily: 'monospace', - fontSize: '0.875rem', - maxHeight: 200, - overflow: 'auto', - }, - chip: { - margin: theme.spacing(0.5), - }, - metadataRow: { - display: 'flex', - alignItems: 'center', - marginBottom: theme.spacing(1), - }, - metadataLabel: { - fontWeight: 'bold', - marginRight: theme.spacing(1), - minWidth: 120, - }, - actions: { - marginTop: theme.spacing(2), - }, -})); - -/** Step card component */ -function StepCard({ step, index }: { step: StepResponse; index: number }) { - const classes = useStyles(); - - return ( - - - - {index + 1} - - - {step.display_name || step.name} - - - {step.name} - - - - Prompt: - - {step.prompt} - - - - - - {step.requires_review && ( - - )} - {step.next_step && ( - - )} - - - {step.outputs.length > 0 && ( - - Outputs: - {step.outputs.map(output => ( - - ))} - - )} - - {step.allowed_tools.length > 0 && step.allowed_tools[0] !== '*' && ( - - Allowed Tools: - {step.allowed_tools.map(tool => ( - - ))} - - )} - - - - - ); -} - -/** Field list component */ -function FieldsList({ fields }: { fields: FieldResponse[] }) { - if (fields.length === 0) { - return ( - - No custom fields defined. - - ); - } - - return ( - - {fields.map(field => ( - - - {field.name} - - {field.required && ( - - )} - - } - secondary={field.description} - /> - - ))} - - ); -} - -/** Main detail page component */ -export function IssueTypeDetailPage() { - const classes = useStyles(); - const navigate = useNavigate(); - const { key } = useParams<{ key: string }>(); - const { issueType, loading, error, retry } = useIssueType(key || ''); - const { deleteIssueType, deleting } = useDeleteIssueType(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - - if (loading) { - return ; - } - - if (error) { - return ( - -
- - Retry}> - Failed to load issue type: {error.message} - - - - ); - } - - if (!issueType) { - return ( - -
- - Issue type not found. - - - ); - } - - const isBuiltin = issueType.source === 'builtin'; - - const handleDelete = async () => { - await deleteIssueType(issueType.key); - navigate('..'); - }; - - return ( - -
- - -
- - - - {!isBuiltin && ( - <> - - - - )} - - - - - - - - - {issueType.glyph} - - - -
- Key: - {issueType.key} -
-
- Name: - {issueType.name} -
-
- Mode: - -
-
- - Project Required: - - - {issueType.project_required ? 'Yes' : 'No'} - -
- {issueType.color && ( -
- Color: - -
- )} - - - - {issueType.description} -
-
-
- - - - - Steps ({issueType.steps.length}) - - {issueType.steps.map((step, index) => ( - - ))} - - - - - Fields ({issueType.fields.length}) - - - - - - - - -
- - {/* Delete confirmation dialog */} - setDeleteDialogOpen(false)}> - Delete Issue Type - - - Are you sure you want to delete the issue type "{issueType.name}" ( - {issueType.key})? This action cannot be undone. - - - - - - - -
-
- ); -} diff --git a/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx b/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx deleted file mode 100644 index 0ffcc9ed..00000000 --- a/plugins/plugin-issuetypes/src/components/IssueTypeFormPage.tsx +++ /dev/null @@ -1,459 +0,0 @@ -/** - * Issue Type create/edit form page component. - */ -import React, { useState, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { - Content, - ContentHeader, - Header, - Page, - Progress, -} from '@backstage/core-components'; -import { - Box, - Button, - Card, - CardContent, - FormControl, - FormControlLabel, - Grid, - IconButton, - InputLabel, - MenuItem, - Select, - Switch, - TextField, - Typography, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import AddIcon from '@material-ui/icons/Add'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; -import DeleteIcon from '@material-ui/icons/Delete'; -import { Alert } from '@material-ui/lab'; -import { - useIssueType, - useCreateIssueType, - useUpdateIssueType, -} from '../hooks/useIssueTypes'; -import type { - CreateIssueTypeRequest, - CreateStepRequest, - ExecutionMode, - PermissionMode, -} from '../api/types'; - -const useStyles = makeStyles(theme => ({ - form: { - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - }, - stepCard: { - marginBottom: theme.spacing(2), - position: 'relative', - }, - stepHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: theme.spacing(1), - }, - actions: { - display: 'flex', - gap: theme.spacing(2), - marginTop: theme.spacing(3), - }, -})); - -interface StepFormData { - name: string; - display_name: string; - prompt: string; - permission_mode: PermissionMode; - requires_review: boolean; - next_step: string; -} - -const defaultStep: StepFormData = { - name: '', - display_name: '', - prompt: '', - permission_mode: 'default', - requires_review: false, - next_step: '', -}; - -/** Step editor component */ -function StepEditor({ - step, - index, - onChange, - onDelete, -}: { - step: StepFormData; - index: number; - onChange: (step: StepFormData) => void; - onDelete: () => void; -}) { - const classes = useStyles(); - - return ( - - - - Step {index + 1} - - - - - - - - onChange({ ...step, name: e.target.value })} - fullWidth - required - helperText="Internal identifier (e.g., 'plan', 'implement')" - /> - - - onChange({ ...step, display_name: e.target.value })} - fullWidth - helperText="Human-readable name (optional)" - /> - - - onChange({ ...step, prompt: e.target.value })} - fullWidth - required - multiline - rows={4} - helperText="Instructions for the agent" - /> - - - - Permission Mode - - - - - onChange({ ...step, next_step: e.target.value })} - fullWidth - helperText="Name of next step (optional)" - /> - - - - onChange({ ...step, requires_review: e.target.checked }) - } - /> - } - label="Requires Review" - /> - - - - - ); -} - -/** Main form page component */ -export function IssueTypeFormPage() { - const classes = useStyles(); - const navigate = useNavigate(); - const { key } = useParams<{ key: string }>(); - const isEditMode = Boolean(key && key !== 'new'); - - const { issueType, loading: loadingType } = useIssueType( - isEditMode ? key! : '', - ); - const { createIssueType, creating, error: createError } = useCreateIssueType(); - const { updateIssueType, updating, error: updateError } = useUpdateIssueType(); - - // Form state - const [formKey, setFormKey] = useState(''); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [glyph, setGlyph] = useState(''); - const [mode, setMode] = useState('autonomous'); - const [projectRequired, setProjectRequired] = useState(true); - const [color, setColor] = useState(''); - const [steps, setSteps] = useState([{ ...defaultStep }]); - - // Populate form when editing - useEffect(() => { - if (isEditMode && issueType) { - setFormKey(issueType.key); - setName(issueType.name); - setDescription(issueType.description); - setGlyph(issueType.glyph); - setMode(issueType.mode); - setProjectRequired(issueType.project_required); - setColor(issueType.color || ''); - setSteps( - issueType.steps.map(s => ({ - name: s.name, - display_name: s.display_name || '', - prompt: s.prompt, - permission_mode: s.permission_mode, - requires_review: s.requires_review, - next_step: s.next_step || '', - })), - ); - } - }, [isEditMode, issueType]); - - if (isEditMode && loadingType) { - return ; - } - - const error = createError || updateError; - const saving = creating || updating; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - const stepsData: CreateStepRequest[] = steps.map(s => ({ - name: s.name, - display_name: s.display_name || undefined, - prompt: s.prompt, - permission_mode: s.permission_mode, - requires_review: s.requires_review, - next_step: s.next_step || undefined, - })); - - const request: CreateIssueTypeRequest = { - key: formKey.toUpperCase(), - name, - description, - glyph, - mode, - project_required: projectRequired, - color: color || undefined, - steps: stepsData, - }; - - try { - if (isEditMode) { - await updateIssueType(key!, { - name, - description, - glyph, - mode, - project_required: projectRequired, - color: color || undefined, - steps: stepsData, - }); - } else { - await createIssueType(request); - } - navigate('..'); - } catch { - // Error is handled by the hook - } - }; - - const handleStepChange = (index: number, step: StepFormData) => { - const newSteps = [...steps]; - newSteps[index] = step; - setSteps(newSteps); - }; - - const handleAddStep = () => { - setSteps([...steps, { ...defaultStep }]); - }; - - const handleDeleteStep = (index: number) => { - if (steps.length > 1) { - setSteps(steps.filter((_, i) => i !== index)); - } - }; - - return ( - -
- - - - - - {error && ( - - {error.message} - - )} - -
- - - - Basic Information - - - - setFormKey(e.target.value.toUpperCase())} - fullWidth - required - disabled={isEditMode} - helperText="Unique identifier (e.g., FEAT, FIX)" - inputProps={{ maxLength: 10 }} - /> - - - setName(e.target.value)} - fullWidth - required - helperText="Display name" - /> - - - setGlyph(e.target.value)} - fullWidth - required - helperText="Icon character" - inputProps={{ maxLength: 2 }} - /> - - - setColor(e.target.value)} - fullWidth - helperText="Hex color (optional)" - placeholder="#3B82F6" - /> - - - setDescription(e.target.value)} - fullWidth - required - multiline - rows={2} - /> - - - - Execution Mode - - - - - setBranchPrefix(e.target.value)} - fullWidth - helperText="Git branch prefix" - placeholder={formKey.toLowerCase() || 'task'} - /> - - - setProjectRequired(e.target.checked)} - /> - } - label="Project Required" - /> - - - - - - - - Steps - - - - {steps.map((step, index) => ( - handleStepChange(index, s)} - onDelete={() => handleDeleteStep(index)} - /> - ))} - - - - - - -
-
- - ); -} diff --git a/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx b/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx deleted file mode 100644 index 8557a184..00000000 --- a/plugins/plugin-issuetypes/src/components/IssueTypesPage.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Issue Types list page component. - */ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Content, - ContentHeader, - Header, - HeaderLabel, - Page, - Progress, - SupportButton, -} from '@backstage/core-components'; -import { - Box, - Button, - Card, - CardActionArea, - CardContent, - Chip, - FormControl, - Grid, - InputLabel, - MenuItem, - Select, - Typography, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -import AddIcon from '@material-ui/icons/Add'; -import { Alert } from '@material-ui/lab'; -import { useIssueTypes } from '../hooks/useIssueTypes'; -import type { IssueTypeSummary } from '../api/types'; - -const useStyles = makeStyles(theme => ({ - card: { - height: '100%', - display: 'flex', - flexDirection: 'column', - }, - cardContent: { - flexGrow: 1, - }, - glyph: { - fontSize: '2rem', - fontWeight: 'bold', - color: theme.palette.primary.main, - marginBottom: theme.spacing(1), - }, - modeBadge: { - marginTop: theme.spacing(1), - }, - autonomousBadge: { - backgroundColor: theme.palette.success.main, - color: theme.palette.success.contrastText, - }, - pairedBadge: { - backgroundColor: theme.palette.warning.main, - color: theme.palette.warning.contrastText, - }, - builtinChip: { - marginLeft: theme.spacing(1), - fontSize: '0.7rem', - }, - filterBar: { - marginBottom: theme.spacing(2), - }, - stepCount: { - color: theme.palette.text.secondary, - marginTop: theme.spacing(1), - }, -})); - -/** Card component for a single issue type */ -function IssueTypeCard({ issueType }: { issueType: IssueTypeSummary }) { - const classes = useStyles(); - const navigate = useNavigate(); - - const isBuiltin = issueType.source === 'builtin'; - const isAutonomous = issueType.mode === 'autonomous'; - - return ( - - navigate(issueType.key)}> - - {issueType.glyph} - - {issueType.key} - {isBuiltin && ( - - )} - - - {issueType.name} - - - {issueType.description.length > 100 - ? `${issueType.description.substring(0, 100)}...` - : issueType.description} - - - - - - {issueType.step_count} step{issueType.step_count !== 1 ? 's' : ''} - - - - - ); -} - -/** Main issue types page component */ -export function IssueTypesPage() { - const classes = useStyles(); - const navigate = useNavigate(); - const { issueTypes, loading, error, retry } = useIssueTypes(); - const [sourceFilter, setSourceFilter] = useState('all'); - - if (loading) { - return ; - } - - const filteredTypes = issueTypes?.filter(t => { - if (sourceFilter === 'all') return true; - if (sourceFilter === 'builtin') return t.source === 'builtin'; - if (sourceFilter === 'user') return t.source !== 'builtin'; - return true; - }); - - return ( - -
- -
- - - - - Issue types define the workflow steps for different ticket types. - - - - {error && ( - Retry}> - Failed to load issue types: {error.message} - - )} - - - - Source - - - - - - {filteredTypes?.map(issueType => ( - - - - ))} - {filteredTypes?.length === 0 && ( - - - No issue types found. - - - )} - - -
- ); -} diff --git a/plugins/plugin-issuetypes/src/components/index.ts b/plugins/plugin-issuetypes/src/components/index.ts deleted file mode 100644 index ab1061de..00000000 --- a/plugins/plugin-issuetypes/src/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Component exports. - */ -export { IssueTypesPage } from './IssueTypesPage'; -export { IssueTypeDetailPage } from './IssueTypeDetailPage'; -export { IssueTypeFormPage } from './IssueTypeFormPage'; -export { CollectionsPage } from './CollectionsPage'; diff --git a/plugins/plugin-issuetypes/src/hooks/index.ts b/plugins/plugin-issuetypes/src/hooks/index.ts deleted file mode 100644 index f34629f3..00000000 --- a/plugins/plugin-issuetypes/src/hooks/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Hooks module exports. - */ -export { - useIssueTypes, - useIssueType, - useCreateIssueType, - useUpdateIssueType, - useDeleteIssueType, -} from './useIssueTypes'; - -export { - useCollections, - useActiveCollection, - useActivateCollection, -} from './useCollections'; diff --git a/plugins/plugin-issuetypes/src/hooks/useCollections.ts b/plugins/plugin-issuetypes/src/hooks/useCollections.ts deleted file mode 100644 index 30443104..00000000 --- a/plugins/plugin-issuetypes/src/hooks/useCollections.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Hooks for fetching and managing collections. - */ -import { useApi } from '@backstage/core-plugin-api'; -import { useAsync, useAsyncFn } from 'react-use'; -import { operatorApiRef } from '../api'; -import type { CollectionResponse } from '../api/types'; - -/** Hook to fetch the list of all collections */ -export function useCollections() { - const api = useApi(operatorApiRef); - - const state = useAsync(async () => { - return api.listCollections(); - }, [api]); - - return { - collections: state.value as CollectionResponse[] | undefined, - loading: state.loading, - error: state.error, - retry: () => state.retry?.(), - }; -} - -/** Hook to fetch the active collection */ -export function useActiveCollection() { - const api = useApi(operatorApiRef); - - const state = useAsync(async () => { - try { - return await api.getActiveCollection(); - } catch (error) { - // 404 means no active collection, which is valid - if ((error as { status?: number }).status === 404) { - return null; - } - throw error; - } - }, [api]); - - return { - activeCollection: state.value as CollectionResponse | null | undefined, - loading: state.loading, - error: state.error, - retry: () => state.retry?.(), - }; -} - -/** Hook to activate a collection */ -export function useActivateCollection() { - const api = useApi(operatorApiRef); - - const [state, activateCollection] = useAsyncFn( - async (name: string) => { - return api.activateCollection(name); - }, - [api], - ); - - return { - activateCollection, - activating: state.loading, - error: state.error, - activatedCollection: state.value, - }; -} diff --git a/plugins/plugin-issuetypes/src/hooks/useIssueTypes.ts b/plugins/plugin-issuetypes/src/hooks/useIssueTypes.ts deleted file mode 100644 index 102b8bfb..00000000 --- a/plugins/plugin-issuetypes/src/hooks/useIssueTypes.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Hooks for fetching and managing issue types. - */ -import { useApi } from '@backstage/core-plugin-api'; -import { useAsync, useAsyncFn } from 'react-use'; -import { operatorApiRef } from '../api'; -import type { - IssueTypeSummary, - IssueTypeResponse, - CreateIssueTypeRequest, - UpdateIssueTypeRequest, -} from '../api/types'; - -/** Hook to fetch the list of all issue types */ -export function useIssueTypes() { - const api = useApi(operatorApiRef); - - const state = useAsync(async () => { - return api.listIssueTypes(); - }, [api]); - - return { - issueTypes: state.value as IssueTypeSummary[] | undefined, - loading: state.loading, - error: state.error, - retry: () => state.retry?.(), - }; -} - -/** Hook to fetch a single issue type by key */ -export function useIssueType(key: string) { - const api = useApi(operatorApiRef); - - const state = useAsync(async () => { - return api.getIssueType(key); - }, [api, key]); - - return { - issueType: state.value as IssueTypeResponse | undefined, - loading: state.loading, - error: state.error, - retry: () => state.retry?.(), - }; -} - -/** Hook to create a new issue type */ -export function useCreateIssueType() { - const api = useApi(operatorApiRef); - - const [state, createIssueType] = useAsyncFn( - async (request: CreateIssueTypeRequest) => { - return api.createIssueType(request); - }, - [api], - ); - - return { - createIssueType, - creating: state.loading, - error: state.error, - createdIssueType: state.value, - }; -} - -/** Hook to update an existing issue type */ -export function useUpdateIssueType() { - const api = useApi(operatorApiRef); - - const [state, updateIssueType] = useAsyncFn( - async (key: string, request: UpdateIssueTypeRequest) => { - return api.updateIssueType(key, request); - }, - [api], - ); - - return { - updateIssueType, - updating: state.loading, - error: state.error, - updatedIssueType: state.value, - }; -} - -/** Hook to delete an issue type */ -export function useDeleteIssueType() { - const api = useApi(operatorApiRef); - - const [state, deleteIssueType] = useAsyncFn( - async (key: string) => { - await api.deleteIssueType(key); - return key; - }, - [api], - ); - - return { - deleteIssueType, - deleting: state.loading, - error: state.error, - deletedKey: state.value, - }; -} diff --git a/plugins/plugin-issuetypes/src/index.ts b/plugins/plugin-issuetypes/src/index.ts deleted file mode 100644 index 827f7ae8..00000000 --- a/plugins/plugin-issuetypes/src/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @operator/plugin-issuetypes - * - * Backstage plugin for managing Operator issue types and collections. - */ - -// Plugin and extensions -export { - issueTypesPlugin, - IssueTypesPage, - IssueTypeDetailPage, - IssueTypeFormPage, - CollectionsPage, -} from './plugin'; - -// API -export { operatorApiRef } from './api'; -export type { OperatorApi } from './api'; - -// Types -export type { - IssueTypeSummary, - IssueTypeResponse, - StepResponse, - FieldResponse, - CollectionResponse, - CreateIssueTypeRequest, - UpdateIssueTypeRequest, - CreateStepRequest, - CreateFieldRequest, -} from './api/types'; - -// Routes -export { - rootRouteRef, - detailRouteRef, - createRouteRef, - editRouteRef, - collectionsRouteRef, -} from './routes'; diff --git a/plugins/plugin-issuetypes/src/plugin.ts b/plugins/plugin-issuetypes/src/plugin.ts deleted file mode 100644 index a8620c8a..00000000 --- a/plugins/plugin-issuetypes/src/plugin.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Plugin definition for the issuetypes plugin. - */ -import { - createPlugin, - createRoutableExtension, - createApiFactory, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; - -import { - rootRouteRef, - detailRouteRef, - createRouteRef, - editRouteRef, - collectionsRouteRef, -} from './routes'; -import { operatorApiRef, OperatorApiClient } from './api'; - -/** The issuetypes plugin instance */ -export const issueTypesPlugin = createPlugin({ - id: 'issuetypes', - apis: [ - createApiFactory({ - api: operatorApiRef, - deps: { - discoveryApi: discoveryApiRef, - fetchApi: fetchApiRef, - }, - factory: ({ discoveryApi, fetchApi }) => - new OperatorApiClient({ discoveryApi, fetchApi }), - }), - ], - routes: { - root: rootRouteRef, - detail: detailRouteRef, - create: createRouteRef, - edit: editRouteRef, - collections: collectionsRouteRef, - }, -}); - -/** Issue types list page extension */ -export const IssueTypesPage = issueTypesPlugin.provide( - createRoutableExtension({ - name: 'IssueTypesPage', - component: () => - import('./components/IssueTypesPage').then(m => m.IssueTypesPage), - mountPoint: rootRouteRef, - }), -); - -/** Issue type detail page extension */ -export const IssueTypeDetailPage = issueTypesPlugin.provide( - createRoutableExtension({ - name: 'IssueTypeDetailPage', - component: () => - import('./components/IssueTypeDetailPage').then( - m => m.IssueTypeDetailPage, - ), - mountPoint: detailRouteRef, - }), -); - -/** Issue type form page extension (create/edit) */ -export const IssueTypeFormPage = issueTypesPlugin.provide( - createRoutableExtension({ - name: 'IssueTypeFormPage', - component: () => - import('./components/IssueTypeFormPage').then(m => m.IssueTypeFormPage), - mountPoint: createRouteRef, - }), -); - -/** Collections management page extension */ -export const CollectionsPage = issueTypesPlugin.provide( - createRoutableExtension({ - name: 'CollectionsPage', - component: () => - import('./components/CollectionsPage').then(m => m.CollectionsPage), - mountPoint: collectionsRouteRef, - }), -); diff --git a/plugins/plugin-issuetypes/src/routes.ts b/plugins/plugin-issuetypes/src/routes.ts deleted file mode 100644 index d8f3f0e2..00000000 --- a/plugins/plugin-issuetypes/src/routes.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Route references for the issuetypes plugin. - */ -import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; - -/** Root route for the issue types list page */ -export const rootRouteRef = createRouteRef({ - id: 'issuetypes', -}); - -/** Route for viewing a single issue type */ -export const detailRouteRef = createSubRouteRef({ - id: 'issuetypes-detail', - parent: rootRouteRef, - path: '/:key', -}); - -/** Route for creating a new issue type */ -export const createRouteRef = createSubRouteRef({ - id: 'issuetypes-create', - parent: rootRouteRef, - path: '/new', -}); - -/** Route for editing an existing issue type */ -export const editRouteRef = createSubRouteRef({ - id: 'issuetypes-edit', - parent: rootRouteRef, - path: '/:key/edit', -}); - -/** Route for managing collections */ -export const collectionsRouteRef = createSubRouteRef({ - id: 'issuetypes-collections', - parent: rootRouteRef, - path: '/collections', -}); diff --git a/plugins/plugin-issuetypes/tsconfig.json b/plugins/plugin-issuetypes/tsconfig.json deleted file mode 100644 index e9129e25..00000000 --- a/plugins/plugin-issuetypes/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "@backstage/cli/config/tsconfig.json", - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true, - "jsx": "react-jsx", - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - } -} diff --git a/scripts/cicdprep.sh b/scripts/cicdprep.sh new file mode 100755 index 00000000..c9731f9d --- /dev/null +++ b/scripts/cicdprep.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +# --- Colors & helpers --- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +RUN_ALL=false +CONTINUE_ON_FAIL=false +FAILURES=() +PASSES=() +SKIPPED=() + +usage() { + echo "Usage: $(basename "$0") [OPTIONS]" + echo "" + echo "Run CI/CD checks locally before creating a PR." + echo "Auto-detects changed files and runs only relevant workflow checks." + echo "" + echo "Options:" + echo " --all Run all checks regardless of changed files" + echo " --continue Don't stop on first failure; run everything and report" + echo " -h, --help Show this help" +} + +for arg in "$@"; do + case "$arg" in + --all) RUN_ALL=true ;; + --continue) CONTINUE_ON_FAIL=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $arg"; usage; exit 1 ;; + esac +done + +section() { + echo "" + echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════${RESET}" + echo -e "${CYAN}${BOLD} $1${RESET}" + echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════${RESET}" +} + +step() { + echo -e "\n${BOLD}▸ $1${RESET}" +} + +pass() { + echo -e " ${GREEN}✓ $1${RESET}" + PASSES+=("$1") +} + +fail() { + echo -e " ${RED}✗ $1${RESET}" + FAILURES+=("$1") + if [ "$CONTINUE_ON_FAIL" = false ]; then + echo -e "\n${RED}${BOLD}FAILED: $1${RESET}" + echo -e "${RED}Use --continue to run all checks despite failures.${RESET}" + exit 1 + fi +} + +skip() { + echo -e " ${YELLOW}⊘ $1 (skipped — no changes)${RESET}" + SKIPPED+=("$1") +} + +require_tool() { + local tool="$1" + local context="$2" + if ! command -v "$tool" &>/dev/null; then + echo -e "${RED}Missing required tool: ${BOLD}$tool${RESET}${RED} (needed for $context)${RESET}" + echo "Install it and re-run." + exit 1 + fi +} + +run_step() { + local label="$1" + shift + step "$label" + if "$@"; then + pass "$label" + else + fail "$label" + fi +} + +# Verify a bun project's lockfile is in sync with its package.json, exactly the +# way CI does (`bun install --frozen-lockfile`). This is the check that catches +# the "lockfile had changes, but lockfile is frozen" CI failure locally — it +# happens when package.json is edited but bun.lock isn't regenerated/committed. +check_bun_lockfile() { + local dir="$1" + step "Lockfile sync: $dir" + if [ ! -f "$dir/bun.lock" ]; then + skip "Lockfile sync: $dir (no bun.lock)" + return + fi + if (cd "$dir" && bun install --frozen-lockfile) >/dev/null 2>&1; then + pass "Lockfile sync: $dir" + else + echo -e " ${YELLOW}lockfile out of sync — run: ${BOLD}(cd $dir && bun install)${RESET}${YELLOW} and commit bun.lock${RESET}" + fail "Lockfile sync: $dir" + fi +} + +# --- Detect changed files --- + +section "Detecting changes" + +MAIN_BRANCH="main" +if ! git rev-parse --verify "$MAIN_BRANCH" &>/dev/null; then + MAIN_BRANCH="origin/main" +fi + +MERGE_BASE=$(git merge-base "$MAIN_BRANCH" HEAD 2>/dev/null || echo "") + +if [ -z "$MERGE_BASE" ]; then + echo -e "${YELLOW}Could not find merge base with $MAIN_BRANCH — running all checks.${RESET}" + RUN_ALL=true + CHANGED_FILES="" +else + CHANGED_FILES=$(git diff --name-only "$MERGE_BASE"...HEAD 2>/dev/null || "") + UNSTAGED=$(git diff --name-only 2>/dev/null || "") + STAGED=$(git diff --name-only --cached 2>/dev/null || "") + CHANGED_FILES=$(echo -e "${CHANGED_FILES}\n${UNSTAGED}\n${STAGED}" | sort -u | grep -v '^$' || true) +fi + +if [ "$RUN_ALL" = true ]; then + echo -e "${YELLOW}Running ALL checks (--all or no merge base).${RESET}" +else + FILE_COUNT=$(echo "$CHANGED_FILES" | grep -c '.' || echo 0) + echo -e "Found ${BOLD}$FILE_COUNT${RESET} changed file(s) vs $MAIN_BRANCH." + if [ "$FILE_COUNT" -eq 0 ]; then + echo -e "${GREEN}No changes detected. Nothing to check.${RESET}" + exit 0 + fi +fi + +has_changes() { + local pattern="$1" + if [ "$RUN_ALL" = true ]; then + return 0 + fi + echo "$CHANGED_FILES" | grep -qE "$pattern" +} + +# build.yaml triggers on everything EXCEPT docs-only or version-only changes +needs_operator() { + if [ "$RUN_ALL" = true ]; then return 0; fi + local non_ignored + non_ignored=$(echo "$CHANGED_FILES" | grep -vE '^(docs/|\.github/workflows/docs\.yml$|VERSION$)' || true) + [ -n "$non_ignored" ] +} + +needs_opr8r() { has_changes '^opr8r/'; } +needs_vscode() { has_changes '^(vscode-extension/|icons/)'; } +needs_zed() { has_changes '^zed-extension/'; } +needs_docs() { has_changes '^(docs/|src/docs_gen/|src/taxonomy/taxonomy\.toml|src/templates/.*\.json)'; } + +# A bun project needs a lockfile check whenever its package.json or bun.lock +# changed. Root project lives at the repo root; others under their dir. +needs_bun_root() { has_changes '^(package\.json|bun\.lock)$'; } +needs_bun_ui() { has_changes '^ui/(package\.json|bun\.lock)$'; } +needs_bun_backstage() { has_changes '^backstage-server/(.*/)?(package\.json|bun\.lock)$'; } + +# --- 0. Bun lockfiles --- +# +# Run this first, cheaply, across every bun project so a stale lockfile fails +# loudly and early instead of midway through a UI build. Mirrors CI's +# `bun install --frozen-lockfile`, and additionally covers the root and +# backstage-server lockfiles that CI does not currently enforce. + +if needs_bun_root || needs_bun_ui || needs_bun_backstage; then + section "Bun lockfiles" + require_tool bun "bun lockfile sync" + + if needs_bun_root; then check_bun_lockfile "."; else skip "Lockfile sync: . (no changes)"; fi + if needs_bun_ui; then check_bun_lockfile "ui"; else skip "Lockfile sync: ui (no changes)"; fi + if needs_bun_backstage; then check_bun_lockfile "backstage-server"; else skip "Lockfile sync: backstage-server (no changes)"; fi +else + skip "Bun lockfiles" +fi + +# --- 1. Operator (main crate) --- + +if needs_operator; then + section "Operator (main crate)" + require_tool cargo "operator" + require_tool bun "operator UI build" + require_tool cargo-deny "operator dependency audit" + + step "UI build" + ( + cd ui + bun install --frozen-lockfile + bun run build + DIST_SIZE=$(du -sk dist/ | awk '{print $1 * 1024}') + echo " UI dist size: ${DIST_SIZE}B ($(echo "scale=1; $DIST_SIZE/1048576" | bc)MB)" + if [ "$DIST_SIZE" -gt 5242880 ]; then + echo "UI dist exceeds 5MB budget (${DIST_SIZE}B)" >&2 + exit 1 + fi + ) && pass "UI build + size check" || fail "UI build + size check" + + run_step "cargo fmt" cargo fmt -- --check + run_step "cargo clippy" cargo clippy --locked --all-targets --all-features -- -D warnings + run_step "cargo test" cargo test --locked --all-features + run_step "cargo deny" cargo deny --manifest-path Cargo.toml check +else + skip "Operator (main crate)" +fi + +# --- 2. opr8r --- + +if needs_opr8r; then + section "opr8r" + require_tool cargo "opr8r" + require_tool cargo-deny "opr8r dependency audit" + + run_step "opr8r fmt" bash -c "cd opr8r && cargo fmt -- --check" + run_step "opr8r clippy" bash -c "cd opr8r && cargo clippy --locked --all-targets --all-features -- -D warnings" + run_step "opr8r test" bash -c "cd opr8r && cargo test --locked --all-features" + run_step "opr8r cargo deny" cargo deny --manifest-path opr8r/Cargo.toml check +else + skip "opr8r" +fi + +# --- 3. vscode-extension --- + +if needs_vscode; then + section "vscode-extension" + require_tool node "vscode-extension" + require_tool npm "vscode-extension" + + step "Install dependencies" + (cd vscode-extension && npm ci) && pass "vscode install" || fail "vscode install" + + run_step "vscode copy-types" bash -c "cd vscode-extension && npm run copy-types" + run_step "vscode generate:icons" bash -c "cd vscode-extension && mkdir -p images/icons/dist && npm run generate:icons" + run_step "vscode lint" bash -c "cd vscode-extension && npm run lint" + run_step "vscode compile" bash -c "cd vscode-extension && npm run compile" + run_step "vscode compile:webview" bash -c "cd vscode-extension && npm run compile:webview" +else + skip "vscode-extension" +fi + +# --- 4. zed-extension --- + +if needs_zed; then + section "zed-extension" + require_tool cargo "zed-extension" + require_tool cargo-deny "zed-extension dependency audit" + + if ! rustup target list --installed 2>/dev/null | grep -q wasm32-wasip1; then + echo -e "${YELLOW}Installing wasm32-wasip1 target...${RESET}" + rustup target add wasm32-wasip1 + fi + + run_step "zed fmt" bash -c "cd zed-extension && cargo fmt -- --check" + run_step "zed clippy" bash -c "cd zed-extension && cargo clippy --locked --target wasm32-wasip1 -- -D warnings" + run_step "zed build" bash -c "cd zed-extension && cargo build --locked --release --target wasm32-wasip1" + run_step "zed cargo deny" cargo deny --manifest-path zed-extension/Cargo.toml check +else + skip "zed-extension" +fi + +# --- 5. docs --- + +if needs_docs; then + section "docs" + require_tool cargo "docs generation" + require_tool bundle "docs Jekyll build" + + run_step "docs generate" cargo run --locked -- docs + step "Jekyll build" + (cd docs && bundle install && bundle exec jekyll build) && pass "Jekyll build" || fail "Jekyll build" +else + skip "docs" +fi + +# --- Summary --- + +section "Summary" + +if [ ${#PASSES[@]} -gt 0 ]; then + echo -e "\n${GREEN}${BOLD}Passed (${#PASSES[@]}):${RESET}" + for p in "${PASSES[@]}"; do + echo -e " ${GREEN}✓${RESET} $p" + done +fi + +if [ ${#SKIPPED[@]} -gt 0 ]; then + echo -e "\n${YELLOW}${BOLD}Skipped (${#SKIPPED[@]}):${RESET}" + for s in "${SKIPPED[@]}"; do + echo -e " ${YELLOW}⊘${RESET} $s" + done +fi + +if [ ${#FAILURES[@]} -gt 0 ]; then + echo -e "\n${RED}${BOLD}Failed (${#FAILURES[@]}):${RESET}" + for f in "${FAILURES[@]}"; do + echo -e " ${RED}✗${RESET} $f" + done + echo "" + echo -e "${RED}${BOLD}CI would fail. Fix the above issues before creating a PR.${RESET}" + exit 1 +fi + +echo "" +echo -e "${GREEN}${BOLD}All checks passed. Ready to create a PR.${RESET}" diff --git a/scripts/operator-statusline.sh b/scripts/operator-statusline.sh new file mode 100755 index 00000000..caaf65b2 --- /dev/null +++ b/scripts/operator-statusline.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Operator status line for Claude Code sessions. +# Receives session JSON on stdin; reads OPERATOR_* env vars. +# Outputs two lines: Line 1 = cwd + git + UI link, Line 2 = operator context. + +set -o pipefail + +# ANSI color codes +RESET='\033[0m' +BOLD='\033[1m' +BLUE_BG='\033[44m' +GREEN_BG='\033[42m' +YELLOW_BG='\033[43m' +CYAN_BG='\033[46m' +MAGENTA_BG='\033[45m' +BLACK_FG='\033[30m' +WHITE_FG='\033[97m' + +# Parse stdin JSON (Claude Code pipes session context) +if command -v jq >/dev/null 2>&1; then + INPUT=$(cat) + CWD=$(echo "$INPUT" | jq -r '.cwd // .workspace.current_dir // empty' 2>/dev/null) + MODEL=$(echo "$INPUT" | jq -r '.model.display_name // .model // empty' 2>/dev/null) + CTX_USED=$(echo "$INPUT" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) +else + # Consume stdin even without jq + cat >/dev/null + CWD="" + MODEL="" + CTX_USED="" +fi + +# Fallbacks +CWD="${CWD:-$(pwd)}" + +# Shorten home directory to ~ +home="$HOME" +SHORT_CWD="${CWD/#$home/\~}" + +# Git info (only if cwd is valid) +GIT_BRANCH="" +GIT_DIRTY="" +if [ -d "$CWD" ]; then + GIT_BRANCH=$(git -C "$CWD" --no-optional-locks symbolic-ref --short HEAD 2>/dev/null) + if [ -n "$GIT_BRANCH" ]; then + GIT_STATUS=$(git -C "$CWD" --no-optional-locks status --porcelain 2>/dev/null) + if [ -n "$GIT_STATUS" ]; then + GIT_DIRTY=" ✚" + fi + fi +fi + +# --- Line 1: cwd | git branch | View in UI --- +LINE1="" + +# Directory segment +LINE1="${LINE1}$(printf "${BLUE_BG}${BLACK_FG}${BOLD} %s ${RESET}" "$SHORT_CWD")" + +# Git segment +if [ -n "$GIT_BRANCH" ]; then + if [ -n "$GIT_DIRTY" ]; then + LINE1="${LINE1}$(printf "${YELLOW_BG}${BLACK_FG}${BOLD} ± %s%s ${RESET}" "$GIT_BRANCH" "$GIT_DIRTY")" + else + LINE1="${LINE1}$(printf "${GREEN_BG}${BLACK_FG}${BOLD} ± %s ${RESET}" "$GIT_BRANCH")" + fi +fi + +# View in UI link (OSC 8 hyperlink if OPERATOR_UI_URL is set) +if [ -n "$OPERATOR_UI_URL" ]; then + LINE1="${LINE1}$(printf " \033]8;;%s\033\\${BOLD}View in UI${RESET}\033]8;;\033\\" "$OPERATOR_UI_URL")" +fi + +# --- Line 2: [OPR8R] ticket | project | model | ctx:% --- +LINE2="" + +# Operator badge +LINE2="${LINE2}$(printf "${MAGENTA_BG}${WHITE_FG}${BOLD} OPR8R ${RESET}")" + +# Ticket ID +if [ -n "$OPERATOR_TICKET_ID" ]; then + LINE2="${LINE2}$(printf " %s" "$OPERATOR_TICKET_ID")" +fi + +# Project +if [ -n "$OPERATOR_PROJECT" ]; then + LINE2="${LINE2}$(printf " | %s" "$OPERATOR_PROJECT")" +fi + +# Model +if [ -n "$MODEL" ]; then + LINE2="${LINE2}$(printf " ${CYAN_BG}${BLACK_FG} %s ${RESET}" "$MODEL")" +fi + +# Context usage +if [ -n "$CTX_USED" ]; then + CTX_INT=$(printf "%.0f" "$CTX_USED" 2>/dev/null || echo "$CTX_USED") + LINE2="${LINE2}$(printf " ${WHITE_FG}ctx:%s%%${RESET}" "$CTX_INT")" +fi + +printf "%b\n%b\n" "$LINE1" "$LINE2" diff --git a/shared/types.ts b/shared/types.ts index 7bf36da8..5c8a29d2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -34,7 +34,7 @@ default_branch: string | null, */ ai_context_path: string | null, /** - * Backstage taxonomy kind (tier 1-5) + * Project taxonomy kind (tier 1-5) */ kind: string | null, /** @@ -272,7 +272,7 @@ projects: Array, agents: AgentsConfig, notifications: NotificationsConfi /** * Session wrapper configuration (tmux, vscode, or cmux) */ -sessions: SessionsConfig, llm_tools: LlmToolsConfig, backstage: BackstageConfig, rest_api: RestApiConfig, git: GitConfig, +sessions: SessionsConfig, llm_tools: LlmToolsConfig, rest_api: RestApiConfig, git: GitConfig, /** * Kanban provider configuration for syncing issues from Jira, Linear, etc. */ @@ -289,9 +289,26 @@ delegators: Array, * User-declared model servers (ollama, lmstudio, any OpenAI-compat host). * Implicit builtin servers exist for each `llm_tool`'s vendor API and do not need declaration. */ -model_servers: Array, }; +model_servers: Array, +/** + * Relay MCP injection configuration + */ +relay: RelayConfig, +/** + * Model Context Protocol (MCP) server configuration + */ +mcp: McpConfig, +/** + * Agent Client Protocol (ACP) agent configuration + */ +acp: AcpConfig, }; -export type AgentsConfig = { max_parallel: number, cores_reserved: number, health_check_interval: bigint, +export type AgentsConfig = { max_parallel: number, cores_reserved: number, +/** + * Maximum concurrent agents per project/repo (default: 1). + * Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + */ +max_agents_per_repo: number, health_check_interval: bigint, /** * Timeout in seconds for each agent generation (default: 300 = 5 min) */ @@ -383,85 +400,6 @@ export type TmuxConfig = { */ config_generated: boolean, }; -export type BackstageConfig = { -/** - * Whether Backstage integration is enabled - */ -enabled: boolean, -/** - * Whether to show Backstage in the Connections status section - */ -display: boolean, -/** - * Port for the Backstage server - */ -port: number, -/** - * Auto-start Backstage server when TUI launches - */ -auto_start: boolean, -/** - * Subdirectory within `state_path` for Backstage installation - */ -subpath: string, -/** - * Subdirectory within backstage path for branding customization - */ -branding_subpath: string, -/** - * Base URL for downloading backstage-server binary - */ -release_url: string, -/** - * Optional local path to backstage-server binary - * If set, this is used instead of downloading from `release_url` - */ -local_binary_path: string | null, -/** - * Branding and theming configuration - */ -branding: BrandingConfig, }; - -export type BrandingConfig = { -/** - * App title shown in header - */ -app_title: string, -/** - * Organization name - */ -org_name: string, -/** - * Path to logo SVG (relative to branding path) - */ -logo_path: string | null, -/** - * Theme colors (uses Operator defaults if not set) - */ -colors: ThemeColors, }; - -export type ThemeColors = { -/** - * Primary/accent color (default: salmon #cc6c55) - */ -primary: string, -/** - * Secondary color (default: dark teal #114145) - */ -secondary: string, -/** - * Accent/highlight color (default: cream #f4dbb7) - */ -accent: string, -/** - * Warning/error color (default: coral #d46048) - */ -warning: string, -/** - * Muted text color (default: darker salmon #8a4a3a) - */ -muted: string, }; - export type RestApiConfig = { /** * Whether the REST API is enabled @@ -663,7 +601,11 @@ 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, }; export type CollectionPreset = "simple" | "dev_kanban" | "devops_kanban" | "custom"; @@ -843,6 +785,95 @@ export type HealthResponse = { status: string, version: string, }; export type StatusResponse = { status: string, version: string, issuetype_count: number, collection_count: number, active_collection: string, }; +export type SectionDto = { +/** + * Stable section id (e.g. "config", "connections", "kanban"). + */ +id: string, label: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, description: string, +/** + * Section ids that must be Green before this section is usable. + */ +prerequisites: Array, +/** + * Whether all prerequisites are met. Sections are always returned (the web + * UI styles unmet ones as locked) rather than hidden by progressive disclosure. + */ +met: boolean, children: Array, }; + +export type SectionRowDto = { +/** + * Stable, section-scoped row id. Clients use it as a tree key and to route + * row-specific commands without matching on the (mutable) display label. + * Dynamic rows carry their entity key (issue-type key, project name); + * static rows carry a fixed slug (e.g. "git-token"). + */ +id: string, +/** + * Nesting depth within the section (1 = direct child, 2 = grandchild). + * Lets clients rebuild the tree (e.g. LLM tools → model aliases). + */ +depth: number, label: string, description: string, +/** + * Icon hint (e.g. "check", "warning", "tool", "folder"). + */ +icon: string, +/** + * Health: "green" | "yellow" | "red" | "gray". + */ +health: string, +/** + * Browser-openable actions for this row (links shown in the web UI). + */ +actions: Array, }; + +export type KanbanProviderCatalogEntry = { +/** + * Stable lowercase slug ("jira" | "linear" | "github"). + */ +slug: string, +/** + * Human-readable name (e.g. "Jira Cloud", "GitHub Projects"). + */ +display_name: string, +/** + * One-line connect description shown next to the provider. + */ +description: string, +/** + * Credential/token page opened when the user chooses to configure it. + */ +setup_url: string, +/** + * VS Code codicon hint (rendered as `$(icon)` in the picker). + */ +icon: string, +/** + * Whether at least one instance of this provider is already configured. + */ +configured: boolean, }; + +export type WorkflowExportResponse = { +/** + * The ticket the workflow was generated from. + */ +ticket_id: string, +/** + * The issue type key that supplied the step structure. + */ +issuetype_key: string, +/** + * Suggested filename for saving the workflow (`.workflow.js`). + */ +suggested_filename: string, +/** + * The generated `.js` workflow source. + */ +contents: string, }; + export type SkillEntry = { /** * Tool this skill belongs to (e.g., "claude", "codex") @@ -973,7 +1004,11 @@ 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, }; export type LlmTask = { /** diff --git a/src/acp/agent.rs b/src/acp/agent.rs new file mode 100644 index 00000000..061ea956 --- /dev/null +++ b/src/acp/agent.rs @@ -0,0 +1,300 @@ +//! Operator's ACP agent over stdio. +//! +//! Wires the [`agent_client_protocol::Agent`] role builder up to a [`Stdio`] +//! transport. Editors that speak ACP launch `operator acp` as a subprocess +//! and exchange line-delimited JSON-RPC with this loop. + +use std::process::Stdio as ProcStdio; +use std::sync::Arc; + +use agent_client_protocol::schema::{ + AgentCapabilities, CancelNotification, ContentBlock, Implementation, InitializeRequest, + InitializeResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SessionId, SessionNotification, StopReason, +}; +use agent_client_protocol::{Agent, Client, ConnectionTo, Dispatch, Stdio}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::oneshot; + +use crate::acp::session::SessionRegistry; +use crate::acp::translator; +use crate::config::{Config, Delegator}; + +/// Build the `InitializeResponse` operator advertises. +/// +/// Echoes the client's protocol version (the ACP convention — the agent +/// accepts the protocol version requested unless it cannot satisfy it), +/// advertises default agent capabilities, and attaches `agentInfo` so +/// editors can identify operator in their UI. +pub fn build_initialize_response(request: &InitializeRequest) -> InitializeResponse { + InitializeResponse::new(request.protocol_version) + .agent_capabilities(AgentCapabilities::default()) + .agent_info(Implementation::new("operator", env!("CARGO_PKG_VERSION")).title("Operator")) +} + +/// Run operator as an ACP agent over stdin/stdout until the client +/// disconnects. +/// +/// Returns the protocol's `Result` so the binary entrypoint can surface +/// transport errors. Logs go to stderr via `tracing`; stdout is reserved +/// for line-delimited JSON-RPC (see `src/logging.rs` — global subscriber +/// writes to stderr). +pub async fn run_stdio(config: Config) -> agent_client_protocol::Result<()> { + let registry = Arc::new(SessionRegistry::new()); + let config = Arc::new(config); + + let new_session_registry = Arc::clone(®istry); + let new_session_config = Arc::clone(&config); + let prompt_registry = Arc::clone(®istry); + let prompt_config = Arc::clone(&config); + let cancel_registry = Arc::clone(®istry); + + Agent + .builder() + .name("operator") + .on_receive_request( + async move |request: InitializeRequest, responder, _connection| { + responder.respond(build_initialize_response(&request)) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: NewSessionRequest, responder, _connection| { + match new_session_registry.create_or_attach(&new_session_config, &request.cwd) { + Ok(session_id) => { + tracing::info!(?session_id, cwd = %request.cwd.display(), "ACP session opened"); + responder.respond(NewSessionResponse::new(session_id)) + } + Err(err) => responder.respond_with_error( + agent_client_protocol::util::internal_error(format!( + "session/new failed: {err}" + )), + ), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: PromptRequest, responder, connection| { + let reg = Arc::clone(&prompt_registry); + let cfg = Arc::clone(&prompt_config); + let cx = connection.clone(); + connection.spawn(async move { + let response = handle_prompt(®, &cfg, request, &cx).await; + match response { + Ok(resp) => responder.respond(resp)?, + Err(message) => responder.respond_with_error( + agent_client_protocol::util::internal_error(message), + )?, + } + Ok(()) + })?; + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |notif: CancelNotification, _connection| { + tracing::info!(session_id = ?notif.session_id, "ACP cancel received"); + if let Some(tx) = cancel_registry.take_cancel_sender(¬if.session_id) { + let _ = tx.send(()); + tracing::info!(session_id = ?notif.session_id, "ACP cancel signal sent to delegator"); + } + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + let method = message.method().to_string(); + message.respond_with_error( + agent_client_protocol::util::internal_error(format!( + "ACP method not implemented: {method}" + )), + cx, + ) + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_to(Stdio::new()) + .await +} + +/// Concatenate the text content blocks of a `PromptRequest`. +/// +/// v1 supports text only; other `ContentBlock` variants (image, audio, +/// resource) are skipped. The result is one newline-joined string suitable +/// for piping to a CLI delegator's prompt file. +fn flatten_prompt(blocks: &[ContentBlock]) -> String { + blocks + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(t.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Pick the delegator to use for an ACP prompt: prefer `[acp].default_delegator` +/// by name, then fall back to `agents::delegator_resolution::resolve_default_delegator`. +fn resolve_acp_delegator(config: &Config) -> Option<&Delegator> { + if let Some(name) = config.acp.default_delegator.as_deref() { + if let Some(d) = config.delegators.iter().find(|d| d.name == name) { + return Some(d); + } + tracing::warn!( + requested = name, + "acp.default_delegator name not found; falling back to default resolver" + ); + } + crate::agents::delegator_resolution::resolve_default_delegator(config) +} + +/// Run the prompt → delegator → stream-back-to-editor pipeline. +/// +/// Returns the prompt response on success, or an `Err(String)` message that +/// the caller will wrap in `internal_error`. +async fn handle_prompt( + registry: &SessionRegistry, + config: &Config, + request: PromptRequest, + connection: &ConnectionTo, +) -> Result { + let session_id = request.session_id.clone(); + let session = registry + .get(&session_id) + .ok_or_else(|| format!("unknown ACP session: {}", session_id.0))?; + + let prompt_text = flatten_prompt(&request.prompt); + let delegator = resolve_acp_delegator(config) + .cloned() + .ok_or_else(|| "no delegator configured for ACP prompts".to_string())?; + + let session_id_str = session_id.0.to_string(); + let prompt_file = + crate::agents::launcher::prompt::write_prompt_file(config, &session_id_str, &prompt_text) + .map_err(|e| format!("write_prompt_file: {e}"))?; + + let mut command_string = + crate::agents::launcher::llm_command::build_llm_command_with_permissions_for_tool( + config, + &delegator.llm_tool, + &delegator.model, + &session_id_str, + &prompt_file, + None, + None, + Some(false), + ) + .map_err(|e| format!("build_llm_command: {e}"))?; + + if delegator.llm_tool == "claude" { + command_string.push_str(" --output-format stream-json"); + } + + tracing::info!( + ?session_id, + delegator = %delegator.name, + cwd = %session.working_directory.display(), + "ACP prompt: spawning delegator" + ); + + let (cancel_tx, cancel_rx) = oneshot::channel(); + registry.register_cancel_sender(&session_id, cancel_tx); + + let stop_reason = stream_delegator( + &command_string, + &session.working_directory, + &session_id, + connection, + cancel_rx, + ) + .await + .map_err(|e| format!("delegator subprocess: {e}"))?; + + registry.take_cancel_sender(&session_id); + + Ok(PromptResponse::new(stop_reason)) +} + +/// Spawn the delegator via `bash -lc ` in `cwd`, stream stdout +/// line-by-line as ACP `AgentMessageChunk` notifications, and return the +/// final `StopReason` based on exit status. If `cancel_rx` fires, the +/// child process is killed and `StopReason::Cancelled` is returned. +async fn stream_delegator( + command_string: &str, + cwd: &std::path::Path, + session_id: &SessionId, + connection: &ConnectionTo, + mut cancel_rx: oneshot::Receiver<()>, +) -> std::io::Result { + let mut child = Command::new("bash") + .arg("-lc") + .arg(command_string) + .current_dir(cwd) + .stdin(ProcStdio::null()) + .stdout(ProcStdio::piped()) + .stderr(ProcStdio::piped()) + .spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| std::io::Error::other("failed to capture delegator stdout"))?; + + let mut lines = BufReader::new(stdout).lines(); + loop { + tokio::select! { + line_result = lines.next_line() => { + match line_result? { + Some(line) => { + if let Some(update) = translator::line_to_update(&line) { + let notif = SessionNotification::new(session_id.clone(), update); + if let Err(e) = connection.send_notification(notif) { + tracing::warn!(error = %e, "ACP send_notification failed"); + break; + } + } + } + None => break, + } + } + _ = &mut cancel_rx => { + tracing::info!(?session_id, "ACP cancel: killing delegator subprocess"); + child.kill().await.ok(); + return Ok(StopReason::Cancelled); + } + } + } + + let status = child.wait().await?; + Ok(if status.success() { + StopReason::EndTurn + } else { + StopReason::Refusal + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::ProtocolVersion; + + #[test] + fn test_initialize_response_echoes_protocol_version() { + let request = InitializeRequest::new(ProtocolVersion::V1); + let response = build_initialize_response(&request); + assert_eq!(response.protocol_version, ProtocolVersion::V1); + } + + #[test] + fn test_initialize_response_advertises_agent_info() { + let request = InitializeRequest::new(ProtocolVersion::V1); + let response = build_initialize_response(&request); + let info = response.agent_info.expect("agent_info must be populated"); + assert_eq!(info.name, "operator"); + assert_eq!(info.version, env!("CARGO_PKG_VERSION")); + } +} diff --git a/src/acp/client_configs.rs b/src/acp/client_configs.rs new file mode 100644 index 00000000..53a82164 --- /dev/null +++ b/src/acp/client_configs.rs @@ -0,0 +1,123 @@ +//! Generates copy-paste ACP agent registrations pointing at this operator +//! binary. +//! +//! Each `*_snippet()` returns either a `serde_json::Value` (Zed, `JetBrains`) +//! or a plain `String` (Emacs elisp, Kiro TOML), shaped the way the target +//! editor expects it. The dashboard writes one of these to +//! `/operator/acp/.{json,el,toml}` and opens it in the +//! user's editor; the user pastes the contents into their actual editor +//! configuration. + +use serde_json::{json, Value}; +use std::path::PathBuf; + +/// Path to the currently-running operator binary. Falls back to bare +/// `"operator"` if `current_exe` is unavailable (e.g. in some test contexts). +pub fn current_exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +fn exe_string() -> String { + current_exe().to_string_lossy().into_owned() +} + +/// Zed `~/.config/zed/settings.json` — `agent_servers` block. +pub fn zed_snippet() -> Value { + json!({ + "agent_servers": { + "operator": { + "command": exe_string(), + "args": ["acp"], + "env": {} + } + } + }) +} + +/// `JetBrains` ACP Agent Registry JSON. Imported via the IDE's ACP plugin. +pub fn jetbrains_snippet() -> Value { + json!({ + "name": "operator", + "displayName": "Operator (Kanban Orchestrator)", + "command": exe_string(), + "args": ["acp"] + }) +} + +/// Emacs `agent-shell` — elisp form to add to your init file. +pub fn emacs_snippet() -> String { + format!( + "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))", + exe_string() + ) +} + +/// Kiro `~/.kiro/agents.toml` entry. +pub fn kiro_snippet() -> String { + format!( + "[[agents]]\nname = \"operator\"\ncommand = \"{}\"\nargs = [\"acp\"]\n", + exe_string() + ) +} + +/// Dispatch by editor name. Returns `None` for unknown editors. JSON-shaped +/// editors (Zed, `JetBrains`) return their snippet directly; text-format +/// editors (Emacs, Kiro) are wrapped as `Value::String` so callers can treat +/// the result uniformly. +pub fn snippet_for(editor: &str) -> Option { + match editor { + "zed" => Some(zed_snippet()), + "jetbrains" => Some(jetbrains_snippet()), + "emacs" => Some(Value::String(emacs_snippet())), + "kiro" => Some(Value::String(kiro_snippet())), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zed_snippet_shape() { + let cfg = zed_snippet(); + assert_eq!(cfg["agent_servers"]["operator"]["args"][0], "acp"); + assert!(cfg["agent_servers"]["operator"]["command"].is_string()); + } + + #[test] + fn test_jetbrains_snippet_has_name_and_command() { + let cfg = jetbrains_snippet(); + assert_eq!(cfg["name"], "operator"); + assert_eq!(cfg["args"][0], "acp"); + } + + #[test] + fn test_emacs_snippet_is_valid_elisp_form() { + let snippet = emacs_snippet(); + assert!(snippet.starts_with("(add-to-list 'agent-shell-acp-agents")); + assert!(snippet.contains("operator")); + assert!(snippet.contains(":args (\"acp\")")); + } + + #[test] + fn test_kiro_snippet_is_toml_array_entry() { + let snippet = kiro_snippet(); + assert!(snippet.starts_with("[[agents]]")); + assert!(snippet.contains("name = \"operator\"")); + assert!(snippet.contains("args = [\"acp\"]")); + } + + #[test] + fn test_snippet_for_unknown_editor_is_none() { + assert!(snippet_for("notepad++").is_none()); + } + + #[test] + fn test_snippet_for_dispatches_all_editors() { + assert!(snippet_for("zed").is_some()); + assert!(snippet_for("jetbrains").is_some()); + assert!(snippet_for("emacs").is_some()); + assert!(snippet_for("kiro").is_some()); + } +} diff --git a/src/acp/mod.rs b/src/acp/mod.rs new file mode 100644 index 00000000..cfffdc99 --- /dev/null +++ b/src/acp/mod.rs @@ -0,0 +1,23 @@ +//! Agent Client Protocol (ACP) integration for Operator. +//! +//! Operator runs as an ACP agent that editors (Zed, `JetBrains`, Emacs, +//! Kiro, `OpenCode`, marimo, Eclipse) launch as a stdio subprocess. Each +//! ACP session maps to one operator ticket (created or attached when +//! the editor's cwd matches an in-progress ticket). +//! +//! Phase A (this file's current scope): only `initialize` is handled. +//! Sessions, prompts, and editor config snippets land in Phase B. +//! +//! See: + +pub mod agent; +pub mod client_configs; +pub mod server; +pub mod session; +pub mod translator; + +pub use agent::run_stdio; +pub use server::{AcpAgentServer, AcpAgentStatus}; +// SessionRegistry and AcpSession are intentionally not re-exported at the +// `acp::*` root: they're internal to the agent runtime. Callers that need +// them can use `acp::session::*`. diff --git a/src/acp/server.rs b/src/acp/server.rs new file mode 100644 index 00000000..f5f991ff --- /dev/null +++ b/src/acp/server.rs @@ -0,0 +1,93 @@ +//! ACP agent status/count handle for the dashboard. +//! +//! Unlike [`crate::rest::server::RestApiServer`], this is **not** a listener +//! lifecycle — editor-spawned `operator acp` runs in a separate stdio +//! subprocess that the TUI never hosts. [`AcpAgentServer`] just records +//! whether ACP is advertised in the dashboard and how many sessions are +//! currently active (always `0` in v1, since out-of-process ACP runs don't +//! report back to the TUI). +//! +//! When a shared file/socket bridge is added later, `active_sessions` can be +//! populated from there without changing this handle's shape. + +use std::sync::{Arc, Mutex}; + +use crate::config::Config; + +/// Coarse status reported to the dashboard. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AcpAgentStatus { + /// `[acp].stdio_advertised = false` — operator is intentionally not + /// advertising itself as an ACP agent. + Disabled, + /// Advertised. No active sessions are visible to the TUI (the editor + /// runs `operator acp` out-of-process in v1, so the count is always 0 + /// here). The dashboard can still surface "ready" and offer config + /// snippets. + Advertised { active_sessions: usize }, +} + +impl AcpAgentStatus { + pub fn is_advertised(&self) -> bool { + matches!(self, AcpAgentStatus::Advertised { .. }) + } + + pub fn active_sessions(&self) -> usize { + match self { + AcpAgentStatus::Advertised { active_sessions } => *active_sessions, + AcpAgentStatus::Disabled => 0, + } + } +} + +use serde::{Deserialize, Serialize}; + +/// Status handle wired into `App` and read by the dashboard. Shape mirrors +/// the lock-protected pattern of [`crate::rest::server::RestApiServer`] so +/// future TUI-launched listeners can slot in without changing call sites. +#[derive(Debug, Clone)] +pub struct AcpAgentServer { + status: Arc>, +} + +impl AcpAgentServer { + /// Construct from a config snapshot. Honors `config.acp.stdio_advertised`. + pub fn from_config(config: &Config) -> Self { + let status = if config.acp.stdio_advertised { + AcpAgentStatus::Advertised { active_sessions: 0 } + } else { + AcpAgentStatus::Disabled + }; + Self { + status: Arc::new(Mutex::new(status)), + } + } + + /// Current status (cloned out of the mutex). + pub fn status(&self) -> AcpAgentStatus { + self.status.lock().unwrap().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_config_advertised_default() { + let config = Config::default(); + let server = AcpAgentServer::from_config(&config); + assert!(server.status().is_advertised()); + assert_eq!(server.status().active_sessions(), 0); + } + + #[test] + fn test_from_config_disabled_when_flag_off() { + let mut config = Config::default(); + config.acp.stdio_advertised = false; + let server = AcpAgentServer::from_config(&config); + assert!(!server.status().is_advertised()); + assert_eq!(server.status(), AcpAgentStatus::Disabled); + } +} diff --git a/src/acp/session.rs b/src/acp/session.rs new file mode 100644 index 00000000..a567871f --- /dev/null +++ b/src/acp/session.rs @@ -0,0 +1,347 @@ +//! ACP session registry — maps `SessionId` to operator tickets. +//! +//! When an editor calls `session/new`, [`SessionRegistry::create_or_attach`] +//! either attaches to an existing in-progress ACP ticket (if exactly one +//! matches the editor's cwd) or writes a fresh `ACP-{short}.md` into +//! `.tickets/in-progress/` and registers the session against it. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use agent_client_protocol::schema::SessionId; +use anyhow::{anyhow, Context, Result}; +use tokio::sync::oneshot; + +use crate::config::Config; + +/// One live ACP session: an editor-spawned conversation backed by an +/// `ACP-*.md` ticket in the in-progress directory. +#[derive(Debug, Clone)] +pub struct AcpSession { + /// Reserved for future ticket-completion bookkeeping (mark the ACP + /// ticket done when the editor disconnects). + #[allow(dead_code)] + pub session_id: SessionId, + /// Reserved for future ticket-update logic. + #[allow(dead_code)] + pub ticket_path: PathBuf, + pub working_directory: PathBuf, +} + +/// Thread-safe registry of live ACP sessions. Handler closures share this +/// across `Agent.builder()` registrations via `Arc`. +#[derive(Debug, Default, Clone)] +pub struct SessionRegistry { + sessions: Arc>>, + cancel_senders: Arc>>>, +} + +impl SessionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Create or attach an ACP session for `cwd`. + /// + /// Behavior: + /// 1. Reject if the registry already holds `config.acp.max_concurrent_sessions`. + /// 2. Canonicalize `cwd` (falling back to the literal path on error). + /// 3. If exactly one `ACP-*.md` ticket in `in-progress/` has matching + /// frontmatter `cwd`, attach to it (do not write a new ticket). + /// 4. Otherwise write a fresh `ACP-{session-short}.md` to `in-progress/`. + pub fn create_or_attach(&self, config: &Config, cwd: &Path) -> Result { + { + let active = self.sessions.lock().unwrap().len(); + if active >= config.acp.max_concurrent_sessions { + return Err(anyhow!( + "ACP session limit reached: {active}/{}", + config.acp.max_concurrent_sessions + )); + } + } + + let canonical_cwd = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let in_progress = config.tickets_path().join("in-progress"); + let session_id = SessionId::from(uuid::Uuid::new_v4().to_string()); + + let ticket_path = match find_matching_acp_ticket(&in_progress, &canonical_cwd) { + Some(path) => path, + None => write_new_acp_ticket(&in_progress, &session_id, &canonical_cwd)?, + }; + + let session = AcpSession { + session_id: session_id.clone(), + ticket_path, + working_directory: canonical_cwd, + }; + self.sessions + .lock() + .unwrap() + .insert(session_id.clone(), session); + Ok(session_id) + } + + /// Number of live ACP sessions. Used by `AcpAgentServer::active_sessions` + /// once the registry is shared with the dashboard. + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.sessions.lock().unwrap().len() + } + + /// Reserved for the same future as `len`. + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.sessions.lock().unwrap().is_empty() + } + + /// Return a clone of the session matching `id`, if any. + pub fn get(&self, id: &SessionId) -> Option { + self.sessions.lock().unwrap().get(id).cloned() + } + + /// Store a cancel sender for an in-flight prompt. The cancel notification + /// handler calls [`take_cancel_sender`] to fire it. + pub fn register_cancel_sender(&self, id: &SessionId, tx: oneshot::Sender<()>) { + self.cancel_senders.lock().unwrap().insert(id.clone(), tx); + } + + /// Remove and return the cancel sender for `id`, if one is registered. + /// Returns `None` if the prompt already completed (sender was cleaned up) + /// or if no prompt is in flight for this session. + pub fn take_cancel_sender(&self, id: &SessionId) -> Option> { + self.cancel_senders.lock().unwrap().remove(id) + } +} + +/// Scan `in_progress` for `ACP-*.md` files whose frontmatter `cwd` matches +/// `target`. Returns `Some(path)` iff exactly one matches; otherwise `None`. +fn find_matching_acp_ticket(in_progress: &Path, target: &Path) -> Option { + let entries = std::fs::read_dir(in_progress).ok()?; + let matches: Vec = entries + .filter_map(std::result::Result::ok) + .map(|e| e.path()) + .filter(|p| { + p.extension().and_then(|e| e.to_str()) == Some("md") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("ACP-")) + }) + .filter(|p| ticket_cwd_matches(p, target)) + .collect(); + if matches.len() == 1 { + Some(matches.into_iter().next().unwrap()) + } else { + None + } +} + +/// True iff the file at `path` has YAML frontmatter with a `cwd` field that +/// equals `target` after path comparison. +fn ticket_cwd_matches(path: &Path, target: &Path) -> bool { + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return false; + } + let after_open = &trimmed[3..]; + let Some(end_idx) = after_open.find("\n---") else { + return false; + }; + let yaml_str = after_open[..end_idx].trim(); + let Ok(fm) = serde_yaml::from_str::>(yaml_str) else { + return false; + }; + fm.get("cwd") + .and_then(serde_yaml::Value::as_str) + .is_some_and(|s| Path::new(s) == target) +} + +fn write_new_acp_ticket(in_progress: &Path, session_id: &SessionId, cwd: &Path) -> Result { + std::fs::create_dir_all(in_progress) + .with_context(|| format!("create in-progress dir {}", in_progress.display()))?; + let short = session_short(session_id); + let filename = format!("ACP-{short}.md"); + let path = in_progress.join(filename); + let now = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let cwd_str = cwd.display().to_string(); + let project = cwd.file_name().and_then(|n| n.to_str()).unwrap_or("global"); + let body = format!( + "---\nid: ACP-{short}\nstatus: in-progress\nkind: acp\ncreated: {now}\nproject: {project}\ncwd: {cwd_str}\n---\n\n# ACP session from {cwd_str}\n" + ); + std::fs::write(&path, body).with_context(|| format!("write ACP ticket {}", path.display()))?; + Ok(path) +} + +/// First 8 hex chars of the session UUID — short enough for a filename, long +/// enough that random collisions inside one in-progress dir are negligible. +fn session_short(session_id: &SessionId) -> String { + session_id + .0 + .chars() + .filter(char::is_ascii_hexdigit) + .take(8) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Once; + + static INIT: Once = Once::new(); + + fn test_config(tickets_dir: &Path, max_sessions: usize) -> Config { + INIT.call_once(|| {}); + let mut config = Config::default(); + config.paths.tickets = tickets_dir.to_string_lossy().into_owned(); + config.acp.max_concurrent_sessions = max_sessions; + config + } + + fn write_acp_ticket(in_progress: &Path, name: &str, cwd: &Path) { + std::fs::create_dir_all(in_progress).unwrap(); + let body = format!( + "---\nid: ACP-{name}\nstatus: in-progress\nkind: acp\ncreated: 2026-05-17\nproject: test\ncwd: {}\n---\n\n# pre-existing\n", + cwd.display() + ); + std::fs::write(in_progress.join(format!("ACP-{name}.md")), body).unwrap(); + } + + #[test] + fn test_attaches_when_one_acp_ticket_matches_cwd() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canon_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let in_progress = tickets.path().join("in-progress"); + write_acp_ticket(&in_progress, "abcd1234", &canon_cwd); + + let config = test_config(tickets.path(), 4); + let registry = SessionRegistry::new(); + let session_id = registry.create_or_attach(&config, cwd.path()).unwrap(); + + let session = registry + .sessions + .lock() + .unwrap() + .get(&session_id) + .cloned() + .expect("session must be registered"); + assert_eq!(session.ticket_path, in_progress.join("ACP-abcd1234.md")); + assert_eq!(session.working_directory, canon_cwd); + // No new file written: only the pre-seeded one exists + let count = std::fs::read_dir(&in_progress).unwrap().count(); + assert_eq!(count, 1); + } + + #[test] + fn test_creates_new_ticket_when_no_match() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let in_progress = tickets.path().join("in-progress"); + + let config = test_config(tickets.path(), 4); + let registry = SessionRegistry::new(); + let session_id = registry.create_or_attach(&config, cwd.path()).unwrap(); + + let path = registry + .sessions + .lock() + .unwrap() + .get(&session_id) + .unwrap() + .ticket_path + .clone(); + assert!(path.exists()); + assert!(path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("ACP-")); + assert_eq!(std::fs::read_dir(&in_progress).unwrap().count(), 1); + } + + #[test] + fn test_creates_new_when_multiple_acp_tickets_match() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canon_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let in_progress = tickets.path().join("in-progress"); + write_acp_ticket(&in_progress, "aaaaaaaa", &canon_cwd); + write_acp_ticket(&in_progress, "bbbbbbbb", &canon_cwd); + + let config = test_config(tickets.path(), 4); + let registry = SessionRegistry::new(); + let session_id = registry.create_or_attach(&config, cwd.path()).unwrap(); + + let path = registry + .sessions + .lock() + .unwrap() + .get(&session_id) + .unwrap() + .ticket_path + .clone(); + // Should be a brand-new file, not one of the two pre-seeded ones + assert!(path.exists()); + assert_ne!(path, in_progress.join("ACP-aaaaaaaa.md")); + assert_ne!(path, in_progress.join("ACP-bbbbbbbb.md")); + assert_eq!(std::fs::read_dir(&in_progress).unwrap().count(), 3); + } + + #[test] + fn test_rejects_when_max_concurrent_sessions_reached() { + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let config = test_config(tickets.path(), 1); + let registry = SessionRegistry::new(); + registry.create_or_attach(&config, cwd.path()).unwrap(); + + let err = registry + .create_or_attach(&config, cwd.path()) + .expect_err("second session must be rejected"); + assert!(err.to_string().contains("session limit")); + } + + #[test] + fn test_session_short_is_8_hex_chars() { + let id = SessionId::from("deadbeef-cafe-1234-5678-90abcdef0000".to_string()); + let short = session_short(&id); + assert_eq!(short.len(), 8); + assert!(short.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_register_and_take_cancel_sender() { + let registry = SessionRegistry::new(); + let id = SessionId::from("test-session-1".to_string()); + let (tx, _rx) = oneshot::channel(); + registry.register_cancel_sender(&id, tx); + assert!( + registry.take_cancel_sender(&id).is_some(), + "first take should return the sender" + ); + } + + #[test] + fn test_double_take_cancel_sender_returns_none() { + let registry = SessionRegistry::new(); + let id = SessionId::from("test-session-2".to_string()); + let (tx, _rx) = oneshot::channel(); + registry.register_cancel_sender(&id, tx); + registry.take_cancel_sender(&id); + assert!( + registry.take_cancel_sender(&id).is_none(), + "second take should return None" + ); + } + + #[test] + fn test_take_cancel_sender_unknown_session_returns_none() { + let registry = SessionRegistry::new(); + let id = SessionId::from("nonexistent".to_string()); + assert!(registry.take_cancel_sender(&id).is_none()); + } +} diff --git a/src/acp/translator.rs b/src/acp/translator.rs new file mode 100644 index 00000000..928f7c8d --- /dev/null +++ b/src/acp/translator.rs @@ -0,0 +1,183 @@ +//! Translate delegator subprocess output into ACP `SessionUpdate` +//! notifications. +//! +//! Two parsing modes: structured JSON (Claude Code's `--output-format +//! stream-json`) and plain text (fallback). `line_to_update` tries JSON +//! first, falls back to plain text on parse failure. + +use agent_client_protocol::schema::{ContentBlock, ContentChunk, SessionUpdate, TextContent}; + +/// Map a single line of delegator stdout to an optional ACP `SessionUpdate`. +/// +/// Tries structured JSON parse first (for delegators like Claude Code with +/// `--output-format stream-json`). Falls back to plain text wrapping. +/// Empty / whitespace-only lines return `None`. +pub fn line_to_update(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.starts_with('{') { + match try_stream_json(trimmed) { + StreamJsonResult::Update(update) => return Some(*update), + StreamJsonResult::Skip => return None, + StreamJsonResult::NotStreamJson => {} + } + } + plain_text_update(trimmed) +} + +enum StreamJsonResult { + Update(Box), + Skip, + NotStreamJson, +} + +/// Wrap a non-empty text line as an `AgentMessageChunk`. +fn plain_text_update(trimmed: &str) -> Option { + let text = TextContent::new(format!("{trimmed}\n")); + let chunk = ContentChunk::new(ContentBlock::Text(text)); + Some(SessionUpdate::AgentMessageChunk(chunk)) +} + +/// Try to parse a line as Claude Code `--output-format stream-json`. +/// +/// Returns `Update` for displayable events, `Skip` for internal events +/// (`tool_use`, `system`), and `NotStreamJson` if it doesn't look like +/// stream-json (so the caller can fall back to plain text). +fn try_stream_json(line: &str) -> StreamJsonResult { + let Ok(obj) = serde_json::from_str::(line) else { + return StreamJsonResult::NotStreamJson; + }; + let Some(event_type) = obj.get("type").and_then(|v| v.as_str()) else { + return StreamJsonResult::NotStreamJson; + }; + + match event_type { + "assistant" => { + let Some(content) = obj + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return StreamJsonResult::Skip; + }; + let mut texts = Vec::new(); + for block in content { + if block.get("type").and_then(|v| v.as_str()) == Some("text") { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + texts.push(t.to_string()); + } + } + } + if texts.is_empty() { + return StreamJsonResult::Skip; + } + let joined = texts.join("\n"); + let text = TextContent::new(format!("{joined}\n")); + let chunk = ContentChunk::new(ContentBlock::Text(text)); + StreamJsonResult::Update(Box::new(SessionUpdate::AgentMessageChunk(chunk))) + } + "result" => { + if let Some(result_text) = obj.get("result").and_then(|v| v.as_str()) { + if !result_text.is_empty() { + let text = TextContent::new(format!("{result_text}\n")); + let chunk = ContentChunk::new(ContentBlock::Text(text)); + return StreamJsonResult::Update(Box::new(SessionUpdate::AgentMessageChunk( + chunk, + ))); + } + } + StreamJsonResult::Skip + } + "tool_use" | "tool_result" | "system" => StreamJsonResult::Skip, + _ => StreamJsonResult::NotStreamJson, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn extract_text(update: SessionUpdate) -> String { + let SessionUpdate::AgentMessageChunk(chunk) = update else { + panic!("expected AgentMessageChunk variant"); + }; + let ContentBlock::Text(text) = chunk.content else { + panic!("expected Text content block"); + }; + text.text + } + + #[test] + fn test_empty_line_yields_none() { + assert!(line_to_update("").is_none()); + assert!(line_to_update(" ").is_none()); + assert!(line_to_update("\t \n").is_none()); + } + + #[test] + fn test_text_line_yields_agent_message_chunk() { + let text = extract_text(line_to_update("hello world").unwrap()); + assert_eq!(text, "hello world\n"); + } + + #[test] + fn test_leading_and_trailing_whitespace_trimmed() { + let text = extract_text(line_to_update(" foo ").unwrap()); + assert_eq!(text, "foo\n"); + } + + #[test] + fn test_stream_json_assistant_text() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello from Claude"}]}}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, "Hello from Claude\n"); + } + + #[test] + fn test_stream_json_assistant_multiple_text_blocks() { + let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, "First\nSecond\n"); + } + + #[test] + fn test_stream_json_result_with_text() { + let line = r#"{"type":"result","result":"Task complete","cost_usd":0.05}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, "Task complete\n"); + } + + #[test] + fn test_stream_json_result_empty_yields_none() { + let line = r#"{"type":"result","result":"","cost_usd":0.01}"#; + assert!(line_to_update(line).is_none()); + } + + #[test] + fn test_stream_json_tool_use_skipped() { + let line = r#"{"type":"tool_use","name":"Read","input":{"path":"/tmp/foo"}}"#; + assert!(line_to_update(line).is_none()); + } + + #[test] + fn test_stream_json_system_event_skipped() { + let line = r#"{"type":"system","message":"thinking..."}"#; + assert!(line_to_update(line).is_none()); + } + + #[test] + fn test_malformed_json_falls_back_to_plain_text() { + let line = r#"{"broken json"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, r#"{"broken json"#.to_string() + "\n"); + } + + #[test] + fn test_json_without_type_field_falls_back_to_plain_text() { + let line = r#"{"key":"value"}"#; + let text = extract_text(line_to_update(line).unwrap()); + assert_eq!(text, r#"{"key":"value"}"#.to_string() + "\n"); + } +} diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index ceed4fa2..6b1aae54 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -321,6 +321,7 @@ mod tests { multi_model_config: None, multi_prompt_config: None, matrixed_config: None, + pipeline_config: None, } } diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 190c7d5a..94aefb78 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -47,18 +47,53 @@ pub(crate) fn resolve_model_server_for_delegator( } } -/// Convert a `Delegator` into an `LlmProvider`. +/// Convert a `Delegator` into an `LlmProvider`, resolving its `model_server` and +/// threading the server's env vars (base URL, API key, extra env) into +/// [`LlmProvider::env`] so they are exported when the agent spawns. /// -/// v1: populates `tool` and `model` only. The delegator's `model_server` is -/// resolved and env vars are expected to be injected into `LlmProvider.env` -/// at spawn time — currently a no-op. TODO(model-servers-v2): thread the -/// resolved `ModelServer` through to `LlmProvider.env` via a per-tool mapping. -pub(crate) fn delegator_to_provider(d: &Delegator) -> LlmProvider { - LlmProvider { +/// Errors if the delegator names a `model_server` that isn't declared. Builtins +/// and servers without a `base_url` contribute no env vars (the vendor-default +/// path is unchanged). +pub(crate) fn delegator_to_provider( + config: &Config, + d: &Delegator, +) -> Result { + let server = resolve_model_server_for_delegator(config, d)?; + let env = crate::api::providers::model_server::env_for_server(&server); + Ok(LlmProvider { tool: d.llm_tool.clone(), model: d.model.clone(), + env, ..Default::default() - } + }) +} + +/// Resolve env vars for an ad-hoc launch (`--llm-tool`/`--model`/`--model-server`). +/// +/// A named `model_server` is looked up among declared servers and the implicit +/// builtins; absent a name, the tool's implicit vendor default is used (which +/// contributes no env). Mirrors [`delegator_to_provider`] for the non-delegator path. +fn adhoc_model_server_env( + config: &Config, + tool: &str, + model_server: Option<&str>, +) -> Result, ResolutionError> { + let server = match model_server { + Some(name) => config + .model_servers + .iter() + .find(|s| s.name == name) + .cloned() + .or_else(|| { + ["claude", "codex", "gemini"] + .iter() + .map(|t| implicit_model_server_for_tool(t)) + .find(|s| s.name == name) + }) + .ok_or_else(|| ResolutionError::UnknownModelServer(name.to_string()))?, + None => implicit_model_server_for_tool(tool), + }; + Ok(crate::api::providers::model_server::env_for_server(&server)) } /// Apply a delegator's launch config to launch options @@ -86,7 +121,7 @@ pub(crate) fn apply_delegator_launch_config( /// 1. Single configured delegator -> use it /// 2. Delegator matching the user's preferred LLM tool -> use it /// 3. None -> caller falls back to first detected tool + first model alias -fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { +pub(crate) fn resolve_default_delegator(config: &Config) -> Option<&Delegator> { match config.delegators.len() { 0 => None, 1 => Some(&config.delegators[0]), @@ -124,6 +159,7 @@ pub fn resolve_launch_options( explicit_delegator: Option<&str>, explicit_provider: Option<&str>, explicit_model: Option<&str>, + explicit_model_server: Option<&str>, yolo_mode: bool, agent_context: Option<&AgentContext>, ) -> Result { @@ -140,7 +176,7 @@ pub fn resolve_launch_options( .find(|d| d.name == delegator_name) .ok_or_else(|| ResolutionError::UnknownDelegator(delegator_name.to_string()))?; - options.provider = Some(delegator_to_provider(delegator)); + options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); apply_delegator_launch_config(&mut options, &delegator.launch_config); return Ok(options); @@ -150,7 +186,7 @@ pub fn resolve_launch_options( if let Some(ctx) = agent_context { if let Some(ref step_agent) = ctx.step_agent { if let Some(delegator) = resolve_delegator_by_name(config, step_agent) { - options.provider = Some(delegator_to_provider(delegator)); + options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); apply_delegator_launch_config(&mut options, &delegator.launch_config); return Ok(options); @@ -161,7 +197,7 @@ pub fn resolve_launch_options( // 3. Issuetype-level agent if let Some(ref it_agent) = ctx.issuetype_agent { if let Some(delegator) = resolve_delegator_by_name(config, it_agent) { - options.provider = Some(delegator_to_provider(delegator)); + options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); apply_delegator_launch_config(&mut options, &delegator.launch_config); return Ok(options); @@ -182,9 +218,11 @@ pub fn resolve_launch_options( let model = explicit_model .map(std::string::ToString::to_string) .unwrap_or(p.model.clone()); + let env = adhoc_model_server_env(config, &p.tool, explicit_model_server)?; options.provider = Some(LlmProvider { tool: p.tool, model, + env, ..Default::default() }); } else { @@ -196,9 +234,11 @@ pub fn resolve_launch_options( if let Some(model) = explicit_model { if let Some(p) = config.llm_tools.providers.first().cloned() { + let env = adhoc_model_server_env(config, &p.tool, explicit_model_server)?; options.provider = Some(LlmProvider { tool: p.tool, model: model.to_string(), + env, ..Default::default() }); } @@ -208,7 +248,7 @@ pub fn resolve_launch_options( // 5. No explicit selection — resolve default delegator if let Some(delegator) = resolve_default_delegator(config) { - options.provider = Some(delegator_to_provider(delegator)); + options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); apply_delegator_launch_config(&mut options, &delegator.launch_config); return Ok(options); @@ -259,7 +299,7 @@ mod tests { #[test] fn test_resolve_default_no_delegators() { let config = Config::default(); - let options = resolve_launch_options(&config, None, None, None, false, None).unwrap(); + let options = resolve_launch_options(&config, None, None, None, None, false, None).unwrap(); assert!(options.provider.is_none()); assert!(!options.yolo_mode); } @@ -318,7 +358,7 @@ mod tests { .delegators .push(make_delegator("claude-opus", "claude", "opus")); - let options = resolve_launch_options(&config, None, None, None, false, None).unwrap(); + let options = resolve_launch_options(&config, None, None, None, None, false, None).unwrap(); let provider = options.provider.unwrap(); assert_eq!(provider.tool, "claude"); assert_eq!(provider.model, "opus"); @@ -336,7 +376,8 @@ mod tests { .push(make_delegator("gemini-pro", "gemini", "pro")); let options = - resolve_launch_options(&config, Some("gemini-pro"), None, None, false, None).unwrap(); + resolve_launch_options(&config, Some("gemini-pro"), None, None, None, false, None) + .unwrap(); let provider = options.provider.unwrap(); assert_eq!(provider.tool, "gemini"); assert_eq!(provider.model, "pro"); @@ -345,7 +386,8 @@ mod tests { #[test] fn test_resolve_unknown_delegator_errors() { let config = Config::default(); - let result = resolve_launch_options(&config, Some("nonexistent"), None, None, false, None); + let result = + resolve_launch_options(&config, Some("nonexistent"), None, None, None, false, None); assert!(result.is_err()); } @@ -364,7 +406,8 @@ mod tests { issuetype_agent: Some("claude-sonnet".to_string()), }; - let options = resolve_launch_options(&config, None, None, None, false, Some(&ctx)).unwrap(); + let options = + resolve_launch_options(&config, None, None, None, None, false, Some(&ctx)).unwrap(); let provider = options.provider.unwrap(); assert_eq!(provider.model, "opus"); } @@ -381,7 +424,8 @@ mod tests { issuetype_agent: Some("claude-opus".to_string()), }; - let options = resolve_launch_options(&config, None, None, None, false, Some(&ctx)).unwrap(); + let options = + resolve_launch_options(&config, None, None, None, None, false, Some(&ctx)).unwrap(); let provider = options.provider.unwrap(); assert_eq!(provider.model, "opus"); } @@ -398,7 +442,8 @@ mod tests { issuetype_agent: Some("claude-opus".to_string()), }; - let options = resolve_launch_options(&config, None, None, None, false, Some(&ctx)).unwrap(); + let options = + resolve_launch_options(&config, None, None, None, None, false, Some(&ctx)).unwrap(); let provider = options.provider.unwrap(); assert_eq!(provider.model, "opus"); } @@ -427,7 +472,7 @@ mod tests { }); let options = - resolve_launch_options(&config, Some("full"), None, None, false, None).unwrap(); + resolve_launch_options(&config, Some("full"), None, None, None, false, None).unwrap(); assert!(options.yolo_mode); assert!(options.docker_mode); assert_eq!(options.use_worktrees_override, Some(true)); @@ -440,7 +485,7 @@ mod tests { #[test] fn test_resolve_yolo_passthrough() { let config = Config::default(); - let options = resolve_launch_options(&config, None, None, None, true, None).unwrap(); + let options = resolve_launch_options(&config, None, None, None, None, true, None).unwrap(); assert!(options.yolo_mode); } } diff --git a/src/agents/generator.rs b/src/agents/generator.rs index 909d9d7f..f1996188 100644 --- a/src/agents/generator.rs +++ b/src/agents/generator.rs @@ -3,8 +3,8 @@ //! Agent and project ticket creators for operator-managed projects //! //! Creates TASK tickets for generating Claude Code agent files in a project's -//! `.claude/agents/` directory, and ASSESS tickets for Backstage catalog -//! assessment. These tickets can then be launched via the normal operator workflow. +//! `.claude/agents/` directory, and ASSESS tickets for project analysis. +//! These tickets can then be launched via the normal operator workflow. use anyhow::{Context, Result}; use chrono::Local; @@ -158,7 +158,7 @@ pub struct AssessTicketResult { pub project: String, } -/// Creates ASSESS tickets for Backstage catalog assessment +/// Creates ASSESS tickets for project assessment pub struct AssessTicketCreator; impl AssessTicketCreator { @@ -182,9 +182,13 @@ impl AssessTicketCreator { // Filename: YYYYMMDD-HHMM-ASSESS-project.md let filename = format!("{timestamp}-ASSESS-{project_name}.md"); - // Check if catalog-info.yaml already exists - let catalog_exists = project_path.join("catalog-info.yaml").exists(); - let action = if catalog_exists { "Update" } else { "Generate" }; + // Check if project has been previously assessed + let previously_assessed = project_path.join("catalog-info.yaml").exists(); + let action = if previously_assessed { + "Reassess" + } else { + "Assess" + }; // Build ticket content using the ASSESS template format let content = format!( @@ -196,7 +200,7 @@ status: queued created: {datetime} --- -# Assessment: {action} catalog-info.yaml for {project_name} +# Assessment: {action} {project_name} ## Project {project_name} diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index 5da25452..82456238 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -20,7 +20,7 @@ use super::llm_command::{ use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, - write_prompt_file, + write_prompt_file, OperatorEnvVars, }; use super::SESSION_PREFIX; @@ -78,6 +78,7 @@ pub fn launch_in_cmux_with_options( project_path: &str, initial_prompt: &str, options: &LaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check cmux is available and we're inside cmux cmux.check_available() @@ -164,11 +165,23 @@ pub fn launch_in_cmux_with_options( } if options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command( + config, + &llm_cmd, + project_path, + options.provider.as_ref().map(|p| &p.env), + )?; } // Write the command to a shell script file - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + options.provider.as_ref().map(|p| &p.env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { @@ -217,6 +230,7 @@ pub fn launch_in_cmux_with_relaunch_options( project_path: &str, initial_prompt: &str, options: &RelaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check cmux is available and we're inside cmux cmux.check_available() @@ -320,11 +334,23 @@ pub fn launch_in_cmux_with_relaunch_options( } if options.launch_options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command( + config, + &llm_cmd, + project_path, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; } // Write and send command - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 45a0eef6..961e302e 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -3,6 +3,9 @@ use std::fs; use std::path::PathBuf; +/// Embedded operator status line script, deployed per-session for Claude Code. +const STATUSLINE_SCRIPT: &str = include_str!("../../../scripts/operator-statusline.sh"); + /// TEMPORARILY DISABLED: JSON schema support causes command line length issues. /// Even when writing schemas to files (rather than inline), the --json-schema flag /// with large schema file paths can exceed OS command line limits. @@ -91,6 +94,7 @@ pub fn build_docker_command( config: &Config, inner_cmd: &str, project_path: &str, + operator_env: Option<&std::collections::HashMap>, ) -> Result { let docker_config = &config.launch.docker; @@ -123,6 +127,14 @@ pub fn build_docker_command( docker_args.push(arg.clone()); } + // Add operator environment variables (if provided) + if let Some(env) = operator_env { + for (key, value) in env { + docker_args.push("-e".to_string()); + docker_args.push(format!("{key}={value}")); + } + } + // Add the image docker_args.push(docker_config.image.clone()); @@ -219,6 +231,12 @@ fn generate_config_flags( cli_flags.push("--add-dir".to_string()); cli_flags.push(project_path.to_string()); + // Inject operator status line settings + if let Some(settings_path) = statusline_settings_flag(&session_dir) { + cli_flags.push("--settings".to_string()); + cli_flags.push(settings_path); + } + // Inject relay MCP server based on effective relay setting let hub_available = std::env::var("RELAY_HUB_SOCKET").is_ok(); if resolve_relay_injection(operator_relay, hub_available, config.relay.auto_inject_mcp) { @@ -228,6 +246,27 @@ fn generate_config_flags( } } + // Inject external MCP servers (skip reserved names and duplicates) + let mut seen_names = std::collections::HashSet::new(); + for server in &config.mcp.external_servers { + if !server.enabled { + continue; + } + if server.name == "relay" { + tracing::warn!("external MCP server name \"relay\" is reserved; skipping"); + continue; + } + if !seen_names.insert(&server.name) { + tracing::warn!(name = %server.name, "duplicate external MCP server name; skipping"); + continue; + } + if let Some(config_path) = external_mcp_config_flag(&session_dir, server, project_path) + { + cli_flags.push("--mcp-config".to_string()); + cli_flags.push(config_path); + } + } + // Add JSON schema flag for structured output (when enabled) // Write schema to a file to avoid shell escaping issues with inline JSON // Inline jsonSchema takes precedence over jsonSchemaFile @@ -279,6 +318,139 @@ fn generate_config_flags( } } +/// Write a per-session MCP server config JSON and return the file path. +/// +/// Produces `{ "mcpServers": { "": } }` at +/// `/-mcp.json`. +fn write_mcp_server_config( + session_dir: &std::path::Path, + server_name: &str, + entry: serde_json::Value, +) -> Option { + let filename = format!("{server_name}-mcp.json"); + let config_path = session_dir.join(&filename); + let config = serde_json::json!({ "mcpServers": { (server_name): entry } }); + let content = serde_json::to_string_pretty(&config).ok()?; + fs::write(&config_path, content).ok()?; + Some(config_path.display().to_string()) +} + +/// Expand `${VAR}` patterns using the process environment. +/// Unknown variables expand to the empty string. Single-pass scan so +/// self-referencing values (e.g. `VAR=${VAR}`) don't cause infinite loops. +fn expand_env_vars(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.char_indices().peekable(); + while let Some((i, ch)) = chars.next() { + if ch == '$' { + if let Some(&(_, '{')) = chars.peek() { + chars.next(); // consume '{' + if let Some(close) = input[i + 2..].find('}') { + let var_name = &input[i + 2..i + 2 + close]; + let value = std::env::var(var_name).unwrap_or_default(); + result.push_str(&value); + // skip chars until after the closing '}' + let end_pos = i + 2 + close; + while let Some(&(j, _)) = chars.peek() { + if j > end_pos { + break; + } + chars.next(); + } + continue; + } + result.push('$'); + result.push('{'); + continue; + } + } + result.push(ch); + } + result +} + +/// Resolve an external MCP server config and write it to the session dir. +/// Returns `None` if the server cannot be resolved (missing sidecar, empty command). +fn external_mcp_config_flag( + session_dir: &std::path::Path, + server: &crate::config::ExternalMcpServer, + project_path: &str, +) -> Option { + if let Some(ref discover_path) = server.discover_from { + let resolved = if discover_path.starts_with('/') { + PathBuf::from(discover_path) + } else { + PathBuf::from(project_path).join(discover_path) + }; + if resolved.exists() { + let contents = fs::read_to_string(&resolved).ok()?; + let sidecar: serde_json::Value = serde_json::from_str(&contents).ok()?; + if let Some(mcp_server) = sidecar.get("mcpServer") { + return write_mcp_server_config(session_dir, &server.name, mcp_server.clone()); + } + } + if server.command.is_empty() { + return None; + } + } + + let command = expand_env_vars(&server.command); + if command.is_empty() { + return None; + } + + let args: Vec = server.args.iter().map(|a| expand_env_vars(a)).collect(); + let env: std::collections::HashMap = server + .env + .iter() + .map(|(k, v)| (k.clone(), expand_env_vars(v))) + .collect(); + + let mut entry = serde_json::json!({ + "command": command, + "type": "stdio", + }); + if !args.is_empty() { + entry["args"] = serde_json::json!(args); + } + if !env.is_empty() { + entry["env"] = serde_json::json!(env); + } + + write_mcp_server_config(session_dir, &server.name, entry) +} + +/// Ensure the operator status line script is deployed in the session directory. +/// Returns the absolute path to the script, or `None` on failure. +fn ensure_statusline_script(session_dir: &std::path::Path) -> Option { + let script_path = session_dir.join("operator-statusline.sh"); + if !script_path.exists() { + fs::write(&script_path, STATUSLINE_SCRIPT).ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)); + } + } + Some(script_path) +} + +/// Write a per-session `operator-settings.json` that configures Claude Code's +/// status line to use the operator script, and return the file path. +fn statusline_settings_flag(session_dir: &std::path::Path) -> Option { + let script_path = ensure_statusline_script(session_dir)?; + let settings = serde_json::json!({ + "statusLine": { + "type": "command", + "command": script_path.display().to_string() + } + }); + let settings_file = session_dir.join("operator-settings.json"); + let content = serde_json::to_string_pretty(&settings).ok()?; + fs::write(&settings_file, &content).ok()?; + Some(settings_file.display().to_string()) +} + /// Write a per-session relay MCP config and return the path for `--mcp-config`. /// Returns `None` if the relay command cannot be located. fn relay_mcp_config_flag(session_dir: &std::path::Path) -> Option { @@ -291,16 +463,12 @@ fn relay_mcp_config_flag_with_command( binary: PathBuf, args: Vec, ) -> Option { - let config_path = session_dir.join("relay-mcp.json"); let relay_entry = if args.is_empty() { serde_json::json!({ "command": binary.display().to_string(), "type": "stdio" }) } else { serde_json::json!({ "command": binary.display().to_string(), "args": args, "type": "stdio" }) }; - let config = serde_json::json!({ "mcpServers": { "relay": relay_entry } }); - let content = serde_json::to_string_pretty(&config).ok()?; - fs::write(&config_path, content).ok()?; - Some(config_path.display().to_string()) + write_mcp_server_config(session_dir, "relay", relay_entry) } /// Locate the relay command, returning `(binary_path, extra_args)`. @@ -499,7 +667,8 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude --model sonnet", "/home/user/project"); + let result = + build_docker_command(&config, "claude --model sonnet", "/home/user/project", None); assert!(result.is_ok()); let cmd = result.unwrap(); @@ -512,7 +681,7 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude", "/home/user/project"); + let result = build_docker_command(&config, "claude", "/home/user/project", None); let cmd = result.unwrap(); assert!( @@ -527,7 +696,7 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude", "/home/user/project"); + let result = build_docker_command(&config, "claude", "/home/user/project", None); let cmd = result.unwrap(); assert!( @@ -544,7 +713,7 @@ mod tests { config.launch.docker.env_vars = vec!["ANTHROPIC_API_KEY".to_string(), "HOME=/root".to_string()]; - let result = build_docker_command(&config, "claude", "/project"); + let result = build_docker_command(&config, "claude", "/project", None); let cmd = result.unwrap(); assert!( @@ -565,7 +734,7 @@ mod tests { config.launch.docker.extra_args = vec!["--network=host".to_string(), "--privileged".to_string()]; - let result = build_docker_command(&config, "claude", "/project"); + let result = build_docker_command(&config, "claude", "/project", None); let cmd = result.unwrap(); assert!(cmd.contains("--network=host"), "Should include extra arg 1"); @@ -576,7 +745,7 @@ mod tests { fn test_build_docker_command_no_image_errors() { let config = Config::default(); // image is empty by default - let result = build_docker_command(&config, "claude", "/project"); + let result = build_docker_command(&config, "claude", "/project", None); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -592,7 +761,7 @@ mod tests { config.launch.docker.image = "my-claude:latest".to_string(); config.launch.docker.mount_path = "/workspace".to_string(); - let result = build_docker_command(&config, "claude --model sonnet", "/project"); + let result = build_docker_command(&config, "claude --model sonnet", "/project", None); let cmd = result.unwrap(); assert!( @@ -1262,4 +1431,322 @@ mod tests { let result = resolve_relay_injection(None, true, true); assert!(result); } + + // ======================================== + // expand_env_vars() tests + // ======================================== + + #[test] + fn test_expand_env_vars_known_var() { + std::env::set_var("_TEST_EXPAND_VAR", "hello"); + let result = expand_env_vars("prefix-${_TEST_EXPAND_VAR}-suffix"); + assert_eq!(result, "prefix-hello-suffix"); + std::env::remove_var("_TEST_EXPAND_VAR"); + } + + #[test] + fn test_expand_env_vars_unknown_var_expands_to_empty() { + std::env::remove_var("_TEST_NONEXISTENT_VAR"); + let result = expand_env_vars("before-${_TEST_NONEXISTENT_VAR}-after"); + assert_eq!(result, "before--after"); + } + + #[test] + fn test_expand_env_vars_no_vars_unchanged() { + let result = expand_env_vars("plain string without vars"); + assert_eq!(result, "plain string without vars"); + } + + #[test] + fn test_expand_env_vars_multiple_vars() { + std::env::set_var("_TEST_A", "alpha"); + std::env::set_var("_TEST_B", "beta"); + let result = expand_env_vars("${_TEST_A}/${_TEST_B}"); + assert_eq!(result, "alpha/beta"); + std::env::remove_var("_TEST_A"); + std::env::remove_var("_TEST_B"); + } + + #[test] + fn test_expand_env_vars_empty_string() { + let result = expand_env_vars(""); + assert_eq!(result, ""); + } + + #[test] + fn test_expand_env_vars_self_referencing_does_not_loop() { + std::env::set_var("_TEST_SELF_REF", "${_TEST_SELF_REF}"); + let result = expand_env_vars("${_TEST_SELF_REF}"); + assert_eq!( + result, "${_TEST_SELF_REF}", + "Should expand once, not infinitely" + ); + std::env::remove_var("_TEST_SELF_REF"); + } + + #[test] + fn test_expand_env_vars_unclosed_brace() { + let result = expand_env_vars("prefix-${UNCLOSED"); + assert_eq!(result, "prefix-${UNCLOSED"); + } + + // ======================================== + // write_mcp_server_config() tests + // ======================================== + + #[test] + fn test_write_mcp_server_config_creates_named_file() { + let dir = tempfile::tempdir().unwrap(); + let entry = serde_json::json!({ + "command": "/usr/bin/test", + "type": "stdio" + }); + let result = write_mcp_server_config(dir.path(), "myserver", entry); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.contains("myserver-mcp.json")); + assert!(std::path::Path::new(&path).exists()); + } + + #[test] + fn test_write_mcp_server_config_json_structure() { + let dir = tempfile::tempdir().unwrap(); + let entry = serde_json::json!({ + "command": "/bin/foo", + "args": ["--bar"], + "env": { "KEY": "val" }, + "type": "stdio" + }); + let path = write_mcp_server_config(dir.path(), "testsvr", entry).unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["testsvr"]["command"].as_str(), + Some("/bin/foo") + ); + assert_eq!( + json["mcpServers"]["testsvr"]["args"][0].as_str(), + Some("--bar") + ); + assert_eq!( + json["mcpServers"]["testsvr"]["env"]["KEY"].as_str(), + Some("val") + ); + } + + // ======================================== + // external_mcp_config_flag() tests + // ======================================== + + #[test] + fn test_external_mcp_config_flag_static_config() { + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("_TEST_EXT_KEY", "secret123"); + let server = crate::config::ExternalMcpServer { + name: "mytools".to_string(), + command: "/usr/bin/my-mcp".to_string(), + args: vec!["--port".to_string(), "9090".to_string()], + env: std::collections::HashMap::from([( + "API_KEY".to_string(), + "${_TEST_EXT_KEY}".to_string(), + )]), + enabled: true, + discover_from: None, + }; + let result = external_mcp_config_flag(dir.path(), &server, "/project"); + assert!(result.is_some()); + let path = result.unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["mytools"]["command"].as_str(), + Some("/usr/bin/my-mcp") + ); + assert_eq!( + json["mcpServers"]["mytools"]["args"][0].as_str(), + Some("--port") + ); + assert_eq!( + json["mcpServers"]["mytools"]["args"][1].as_str(), + Some("9090") + ); + assert_eq!( + json["mcpServers"]["mytools"]["env"]["API_KEY"].as_str(), + Some("secret123") + ); + std::env::remove_var("_TEST_EXT_KEY"); + } + + #[test] + fn test_external_mcp_config_flag_sidecar_discovery() { + let session_dir = tempfile::tempdir().unwrap(); + let project_dir = tempfile::tempdir().unwrap(); + let sidecar_path = project_dir.path().join(".kanbots"); + std::fs::create_dir_all(&sidecar_path).unwrap(); + let sidecar_file = sidecar_path.join("active-session.json"); + let sidecar_content = serde_json::json!({ + "mcpServer": { + "command": "/usr/bin/node", + "args": ["/path/to/server.js"], + "env": { + "BRIDGE_URL": "http://127.0.0.1:54321", + "BRIDGE_TOKEN": "abc123" + } + }, + "pid": 12345 + }); + std::fs::write( + &sidecar_file, + serde_json::to_string_pretty(&sidecar_content).unwrap(), + ) + .unwrap(); + + let server = crate::config::ExternalMcpServer { + name: "kanbots".to_string(), + command: String::new(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: Some(".kanbots/active-session.json".to_string()), + }; + let result = external_mcp_config_flag( + session_dir.path(), + &server, + &project_dir.path().to_string_lossy(), + ); + assert!(result.is_some()); + let path = result.unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["kanbots"]["command"].as_str(), + Some("/usr/bin/node") + ); + assert_eq!( + json["mcpServers"]["kanbots"]["args"][0].as_str(), + Some("/path/to/server.js") + ); + assert_eq!( + json["mcpServers"]["kanbots"]["env"]["BRIDGE_TOKEN"].as_str(), + Some("abc123") + ); + } + + #[test] + fn test_external_mcp_config_flag_missing_sidecar_empty_command_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let server = crate::config::ExternalMcpServer { + name: "kanbots".to_string(), + command: String::new(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: Some(".kanbots/active-session.json".to_string()), + }; + let result = external_mcp_config_flag(dir.path(), &server, "/nonexistent/project"); + assert!( + result.is_none(), + "Should return None when sidecar missing and command empty" + ); + } + + #[test] + fn test_external_mcp_config_flag_missing_sidecar_static_fallback() { + let dir = tempfile::tempdir().unwrap(); + let server = crate::config::ExternalMcpServer { + name: "kanbots".to_string(), + command: "kanbots-mcp-server".to_string(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: Some(".kanbots/active-session.json".to_string()), + }; + let result = external_mcp_config_flag(dir.path(), &server, "/nonexistent/project"); + assert!( + result.is_some(), + "Should fall back to static command when sidecar missing" + ); + let path = result.unwrap(); + let content = std::fs::read_to_string(&path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + json["mcpServers"]["kanbots"]["command"].as_str(), + Some("kanbots-mcp-server") + ); + } + + #[test] + fn test_external_mcp_config_flag_disabled_server_not_called() { + // This is tested at the generate_config_flags level, but we verify + // the function itself handles empty command correctly + let dir = tempfile::tempdir().unwrap(); + let server = crate::config::ExternalMcpServer { + name: "disabled".to_string(), + command: String::new(), + args: vec![], + env: std::collections::HashMap::new(), + enabled: true, + discover_from: None, + }; + let result = external_mcp_config_flag(dir.path(), &server, "/project"); + assert!( + result.is_none(), + "Empty command without discover_from should return None" + ); + } + + // ======================================== + // statusline script + settings tests + // ======================================== + + #[test] + fn test_ensure_statusline_script_creates_executable_file() { + let dir = tempfile::tempdir().unwrap(); + let result = ensure_statusline_script(dir.path()); + assert!(result.is_some()); + let path = result.unwrap(); + assert!(path.exists()); + assert!(path.to_string_lossy().contains("operator-statusline.sh")); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("#!/usr/bin/env bash")); + assert!(content.contains("OPERATOR_")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o111, 0o111, "Script should be executable"); + } + } + + #[test] + fn test_ensure_statusline_script_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path1 = ensure_statusline_script(dir.path()).unwrap(); + let content1 = std::fs::read_to_string(&path1).unwrap(); + + let path2 = ensure_statusline_script(dir.path()).unwrap(); + let content2 = std::fs::read_to_string(&path2).unwrap(); + + assert_eq!(path1, path2); + assert_eq!(content1, content2); + } + + #[test] + fn test_statusline_settings_flag_creates_valid_json() { + let dir = tempfile::tempdir().unwrap(); + let result = statusline_settings_flag(dir.path()); + assert!(result.is_some()); + let settings_path = result.unwrap(); + assert!(settings_path.contains("operator-settings.json")); + + let content = std::fs::read_to_string(&settings_path).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["statusLine"]["type"].as_str(), Some("command")); + assert!(json["statusLine"]["command"] + .as_str() + .unwrap() + .contains("operator-statusline.sh")); + } } diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 4a60543a..0d09ba2a 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -7,9 +7,9 @@ mod cmux_session; pub mod interpolation; -mod llm_command; +pub(crate) mod llm_command; mod options; -mod prompt; +pub(crate) mod prompt; mod step_config; mod tmux_session; pub mod worktree_setup; @@ -23,6 +23,8 @@ use std::sync::Arc; use anyhow::{Context, Result}; +use uuid::Uuid; + use crate::agents::cmux::{CmuxClient, SystemCmuxClient}; use crate::agents::tmux::{sanitize_session_name, SystemTmuxClient, TmuxClient, TmuxError}; use crate::agents::zellij::{SystemZellijClient, ZellijClient}; @@ -89,6 +91,8 @@ pub struct PreparedLaunch { pub session_window_ref: Option, /// Session context reference (e.g. cmux workspace, zellij session) pub session_context_ref: Option, + /// Operator environment variables for the terminal session + pub env_vars: std::collections::HashMap, } /// Minimum required tmux version @@ -317,6 +321,26 @@ impl Launcher { initial_prompt: &str, options: &LaunchOptions, ) -> Result<(String, String)> { + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + + // Build operator environment variables for the terminal session + let operator_env = prompt::OperatorEnvVars { + agent_id: agent_id.clone(), + ticket_id: ticket.id.clone(), + project: ticket.project.clone(), + step: if ticket.step.is_empty() { + "initial".to_string() + } else { + ticket.step.clone() + }, + ui_url: format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ui_port: self.config.rest_api.port, + }; + // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -330,6 +354,7 @@ impl Launcher { working_dir_str, initial_prompt, options, + &operator_env, )?; ( result.session_name, @@ -347,6 +372,7 @@ impl Launcher { working_dir_str, initial_prompt, options, + &operator_env, )?; (result.session_name, "zellij", None) } else { @@ -358,6 +384,7 @@ impl Launcher { working_dir_str, initial_prompt, options, + &operator_env, )?; (name, "tmux", None) }; @@ -375,15 +402,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with launch options + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_mode_string()), + None, )?; // Store session name in state for later recovery @@ -467,9 +496,10 @@ impl Launcher { })?; let mut opts = base.clone(); - opts.provider = Some(crate::agents::delegator_resolution::delegator_to_provider( - delegator, - )); + opts.provider = Some( + crate::agents::delegator_resolution::delegator_to_provider(&self.config, delegator) + .map_err(|e| anyhow::anyhow!("{e}"))?, + ); opts.delegator_name = Some(delegator.name.clone()); crate::agents::delegator_resolution::apply_delegator_launch_config( &mut opts, @@ -499,6 +529,13 @@ impl Launcher { Ok(cap.saturating_sub(running)) } + fn project_available_slots(&self, project: &str) -> Result { + let state = State::load(&self.config)?; + let count = state.project_agent_count(project); + let cap = self.config.effective_max_agents_per_repo(); + Ok(cap.saturating_sub(count)) + } + /// Fan out a `multi_model` step: N delegators, same prompt for all. /// /// Launches up to `available_slots()` sub-agents immediately; any that @@ -685,7 +722,9 @@ impl Launcher { base_options: &LaunchOptions, ) -> Result<()> { loop { - let budget = self.available_slots()?; + let budget = self + .available_slots()? + .min(self.project_available_slots(&ticket.project)?); if budget == 0 { break; } @@ -771,6 +810,9 @@ impl Launcher { // Generate session UUID let session_uuid = generate_session_uuid(); + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + // Get the step name (use "initial" if not set) let step_name = if ticket.step.is_empty() { "initial".to_string() @@ -778,6 +820,24 @@ impl Launcher { ticket.step.clone() }; + // Build operator environment variables HashMap for PreparedLaunch + let mut env_vars = std::collections::HashMap::new(); + env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone()); + env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone()); + env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone()); + env_vars.insert("OPERATOR_STEP".to_string(), step_name.clone()); + env_vars.insert( + "OPERATOR_UI_URL".to_string(), + format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ); + env_vars.insert( + "OPERATOR_UI_PORT".to_string(), + self.config.rest_api.port.to_string(), + ); + // Store the session UUID in the ticket file (now in in-progress) let ticket_in_progress_path = self .config @@ -867,7 +927,7 @@ impl Launcher { // Wrap in docker command if docker mode is enabled if options.docker_mode { - llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?; + llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?; } // Determine tool name from options or default @@ -883,15 +943,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with launch + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_mode_string()), + None, )?; // Store session name in state for later recovery @@ -931,6 +993,7 @@ impl Launcher { session_wrapper: None, session_window_ref: None, session_context_ref: None, + env_vars, }) } @@ -1005,6 +1068,9 @@ impl Launcher { .clone() .unwrap_or_else(generate_session_uuid); + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + // Get the step name (use "initial" if not set) let step_name = if ticket.step.is_empty() { "initial".to_string() @@ -1012,6 +1078,24 @@ impl Launcher { ticket.step.clone() }; + // Build operator environment variables HashMap for PreparedLaunch + let mut env_vars = std::collections::HashMap::new(); + env_vars.insert("OPERATOR_AGENT_ID".to_string(), agent_id.clone()); + env_vars.insert("OPERATOR_TICKET_ID".to_string(), ticket.id.clone()); + env_vars.insert("OPERATOR_PROJECT".to_string(), ticket.project.clone()); + env_vars.insert("OPERATOR_STEP".to_string(), step_name.clone()); + env_vars.insert( + "OPERATOR_UI_URL".to_string(), + format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ); + env_vars.insert( + "OPERATOR_UI_PORT".to_string(), + self.config.rest_api.port.to_string(), + ); + // Store the session UUID in the ticket file let ticket_in_progress_path = self .config @@ -1112,7 +1196,7 @@ impl Launcher { // Wrap in docker command if docker mode is enabled if options.launch_options.docker_mode { - llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str)?; + llm_cmd = build_docker_command(&self.config, &llm_cmd, &working_dir_str, None)?; } // Determine tool name from options or default @@ -1129,15 +1213,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with launch + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_options.launch_mode_string()), + None, )?; // Store session name in state for later recovery @@ -1179,6 +1265,7 @@ impl Launcher { session_wrapper: None, session_window_ref: None, session_context_ref: None, + env_vars, }) } @@ -1234,6 +1321,26 @@ impl Launcher { let initial_prompt = generate_prompt(&self.config, &ticket); let initial_prompt = apply_prompt_wrapping(initial_prompt, &options.launch_options); + // Pre-allocate agent ID so we can inject it into the environment + let agent_id = Uuid::new_v4().to_string(); + + // Build operator environment variables for the terminal session + let operator_env = prompt::OperatorEnvVars { + agent_id: agent_id.clone(), + ticket_id: ticket.id.clone(), + project: ticket.project.clone(), + step: if ticket.step.is_empty() { + "initial".to_string() + } else { + ticket.step.clone() + }, + ui_url: format!( + "http://localhost:{}/#/agent/{}", + self.config.rest_api.port, agent_id + ), + ui_port: self.config.rest_api.port, + }; + // Dispatch based on session wrapper type let (session_name, wrapper_name, cmux_refs) = if self.config.sessions.wrapper == SessionWrapperType::Cmux { @@ -1247,6 +1354,7 @@ impl Launcher { &working_dir_str, &initial_prompt, &options, + &operator_env, )?; ( result.session_name, @@ -1264,6 +1372,7 @@ impl Launcher { &working_dir_str, &initial_prompt, &options, + &operator_env, )?; (result.session_name, "zellij", None) } else { @@ -1274,6 +1383,7 @@ impl Launcher { &working_dir_str, &initial_prompt, &options, + &operator_env, )?; (name, "tmux", None) }; @@ -1292,15 +1402,17 @@ impl Launcher { .map(|t| t.name.clone()) }); - // Update state with new agent + // Update state with pre-allocated agent ID let mut state = State::load(&self.config)?; - let agent_id = state.add_agent_with_options( + let agent_id = state.add_agent_with_explicit_id( + agent_id, ticket.id.clone(), ticket.ticket_type.clone(), ticket.project.clone(), ticket.is_paired(), llm_tool, Some(options.launch_options.launch_mode_string()), + None, )?; // Store session name in state for later recovery diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index a8719cc0..5e4e3549 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -10,6 +10,42 @@ use crate::config::Config; use crate::queue::Ticket; use crate::templates::{schema::TemplateSchema, TemplateType}; +/// Environment variables injected into operator-spawned agent command scripts +/// for branding (status line, pane title, UI deep-links). +#[derive(Debug, Clone, Default)] +pub struct OperatorEnvVars { + pub agent_id: String, + pub ticket_id: String, + pub project: String, + pub step: String, + pub ui_url: String, + pub ui_port: u16, +} + +impl OperatorEnvVars { + /// Render shell `export` lines for all operator env vars. + pub fn to_export_block(&self) -> String { + format!( + "export OPERATOR_AGENT_ID={}\nexport OPERATOR_TICKET_ID={}\nexport OPERATOR_PROJECT={}\nexport OPERATOR_STEP={}\nexport OPERATOR_UI_URL={}\nexport OPERATOR_UI_PORT={}\n", + shell_escape(&self.agent_id), + shell_escape(&self.ticket_id), + shell_escape(&self.project), + shell_escape(&self.step), + shell_escape(&self.ui_url), + self.ui_port, + ) + } + + /// Render an OSC 2 escape sequence to set the terminal pane title. + pub fn to_pane_title_line(&self) -> String { + format!( + "printf '\\033]2;[OPR8R] %s | %s\\033\\\\' {} {}\n", + shell_escape(&self.ticket_id), + shell_escape(&self.project), + ) + } +} + /// Generate the initial prompt for a ticket based on its type pub fn generate_prompt(config: &Config, ticket: &Ticket) -> String { let ticket_path = config @@ -156,15 +192,29 @@ pub fn write_command_file( session_uuid: &str, project_path: &str, llm_command: &str, + operator_env: Option<&OperatorEnvVars>, + provider_env: Option<&std::collections::HashMap>, ) -> Result { let commands_dir = config.tickets_path().join("operator/commands"); fs::create_dir_all(&commands_dir).context("Failed to create commands directory")?; let command_file = commands_dir.join(format!("{session_uuid}.sh")); - // Build script content with shebang, cd, and exec + // Build script content with shebang, optional env vars, cd, and exec + let env_block = operator_env + .map(OperatorEnvVars::to_export_block) + .unwrap_or_default(); + + // Model-server env (base URL / API key / extra env) so the agent CLI targets + // the resolved server. Exported after the operator branding vars. + let provider_block = provider_env.map(render_env_exports).unwrap_or_default(); + + let pane_title = operator_env + .map(OperatorEnvVars::to_pane_title_line) + .unwrap_or_default(); + let script_content = format!( - "#!/bin/bash\ncd {}\nexec {}\n", + "#!/bin/bash\n{env_block}{provider_block}{pane_title}cd {}\nexec {}\n", shell_escape(project_path), llm_command ); @@ -183,6 +233,43 @@ pub fn write_command_file( Ok(command_file) } +/// Render a map of environment variables as shell `export` lines. +/// +/// Keys are sorted for deterministic output. Values are shell-escaped, *except* +/// a pure shell-variable reference like `${OLLAMA_API_KEY}` is emitted unquoted +/// so the shell expands it at run time — this lets an API key be passed by +/// reference (inherited from operator's env) without writing the secret value +/// into the on-disk command script. +fn render_env_exports(env: &std::collections::HashMap) -> String { + let mut keys: Vec<&String> = env.keys().collect(); + keys.sort(); + let mut out = String::new(); + for key in keys { + let value = &env[key]; + let rendered = if is_shell_var_reference(value) { + value.clone() + } else { + shell_escape(value) + }; + out.push_str(&format!("export {key}={rendered}\n")); + } + out +} + +/// Whether a value is exactly a single shell-variable reference like `${FOO}` +/// (a valid env-var name in braces). Such values are emitted unquoted so the +/// shell expands them; anything else is shell-escaped. +fn is_shell_var_reference(value: &str) -> bool { + let Some(inner) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) else { + return false; + }; + !inner.is_empty() + && inner + .chars() + .enumerate() + .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())) +} + /// Escape a string for safe use in shell command pub fn shell_escape(s: &str) -> String { // Use single quotes and escape any single quotes within @@ -267,7 +354,8 @@ mod tests { let project_path = "/path/to/project"; let llm_command = "claude --session-id abc123 --print-prompt-path /tmp/prompt.txt"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = + write_command_file(&config, session_uuid, project_path, llm_command, None, None); assert!(result.is_ok()); let command_file = result.unwrap(); @@ -291,7 +379,8 @@ mod tests { let project_path = "/path/with spaces/to/project"; let llm_command = "claude --arg value"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = + write_command_file(&config, session_uuid, project_path, llm_command, None, None); assert!(result.is_ok()); let content = std::fs::read_to_string(result.unwrap()).unwrap(); @@ -310,7 +399,8 @@ mod tests { let project_path = "/path/with'quotes/and$dollar"; let llm_command = "claude --arg value"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = + write_command_file(&config, session_uuid, project_path, llm_command, None, None); assert!(result.is_ok()); let content = std::fs::read_to_string(result.unwrap()).unwrap(); @@ -329,7 +419,8 @@ mod tests { let project_path = "/project"; let llm_command = r#"claude --session-id abc --print-prompt-path /tmp/file.txt --add-dir "/dir with spaces" --model sonnet"#; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = + write_command_file(&config, session_uuid, project_path, llm_command, None, None); assert!(result.is_ok()); let content = std::fs::read_to_string(result.unwrap()).unwrap(); @@ -350,7 +441,8 @@ mod tests { let project_path = "/project"; let llm_command = "claude --arg value"; - let result = write_command_file(&config, session_uuid, project_path, llm_command); + let result = + write_command_file(&config, session_uuid, project_path, llm_command, None, None); assert!(result.is_ok()); let command_file = result.unwrap(); @@ -360,4 +452,165 @@ mod tests { // Check that the file is executable (0o755 = rwxr-xr-x) assert_eq!(permissions.mode() & 0o777, 0o755); } + + #[test] + fn test_operator_env_vars_to_export_block() { + let env = OperatorEnvVars { + agent_id: "abc-123".to_string(), + ticket_id: "FEAT-042".to_string(), + project: "gamesvc".to_string(), + step: "implement".to_string(), + ui_url: "http://localhost:7007/#/agent/abc-123".to_string(), + ui_port: 7007, + }; + let block = env.to_export_block(); + assert!(block.contains("export OPERATOR_AGENT_ID='abc-123'")); + assert!(block.contains("export OPERATOR_TICKET_ID='FEAT-042'")); + assert!(block.contains("export OPERATOR_PROJECT='gamesvc'")); + assert!(block.contains("export OPERATOR_STEP='implement'")); + assert!(block.contains("export OPERATOR_UI_URL='http://localhost:7007/#/agent/abc-123'")); + assert!(block.contains("export OPERATOR_UI_PORT=7007")); + } + + #[test] + fn test_operator_env_vars_to_pane_title_line() { + let env = OperatorEnvVars { + agent_id: "abc-123".to_string(), + ticket_id: "FEAT-042".to_string(), + project: "gamesvc".to_string(), + step: "implement".to_string(), + ui_url: "http://localhost:7007/#/agent/abc-123".to_string(), + ui_port: 7007, + }; + let line = env.to_pane_title_line(); + assert!(line.contains("\\033]2;")); + assert!(line.contains("FEAT-042")); + assert!(line.contains("gamesvc")); + } + + #[test] + fn test_write_command_file_with_operator_env() { + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp_dir.path()); + + let env = OperatorEnvVars { + agent_id: "test-agent-id".to_string(), + ticket_id: "FEAT-001".to_string(), + project: "myproject".to_string(), + step: "plan".to_string(), + ui_url: "http://localhost:7007/#/agent/test-agent-id".to_string(), + ui_port: 7007, + }; + + let result = write_command_file( + &config, + "test-uuid-env", + "/path/to/project", + "claude --session-id abc123", + Some(&env), + None, + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(result.unwrap()).unwrap(); + assert!(content.contains("export OPERATOR_AGENT_ID='test-agent-id'")); + assert!(content.contains("export OPERATOR_TICKET_ID='FEAT-001'")); + assert!(content.contains("export OPERATOR_PROJECT='myproject'")); + assert!(content.contains("\\033]2;")); + assert!(content.contains("cd '/path/to/project'")); + assert!(content.contains("exec claude --session-id abc123")); + } + + #[test] + fn test_write_command_file_exports_provider_env() { + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp_dir.path()); + + let mut provider_env = std::collections::HashMap::new(); + provider_env.insert( + "OPENAI_BASE_URL".to_string(), + "http://localhost:11434".to_string(), + ); + + let result = write_command_file( + &config, + "test-uuid-provider-env", + "/path/to/project", + "codex --model qwen2.5-coder", + None, + Some(&provider_env), + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(result.unwrap()).unwrap(); + // Model-server env is exported before the agent command. + assert!(content.contains("export OPENAI_BASE_URL='http://localhost:11434'")); + let export_pos = content.find("export OPENAI_BASE_URL").unwrap(); + let exec_pos = content.find("exec codex").unwrap(); + assert!(export_pos < exec_pos, "env must be exported before exec"); + } + + #[test] + fn test_write_command_file_api_key_reference_is_unquoted_not_baked() { + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp_dir.path()); + + let mut provider_env = std::collections::HashMap::new(); + provider_env.insert("OPENAI_BASE_URL".to_string(), "http://gpu:8000".to_string()); + // API key passed by reference — must NOT be written as a literal secret. + provider_env.insert("OPENAI_API_KEY".to_string(), "${MY_SECRET_KEY}".to_string()); + + let result = write_command_file( + &config, + "test-uuid-apikey", + "/project", + "codex", + None, + Some(&provider_env), + ); + let content = std::fs::read_to_string(result.unwrap()).unwrap(); + // Reference emitted unquoted so the shell expands it at run time. + assert!(content.contains("export OPENAI_API_KEY=${MY_SECRET_KEY}")); + // Plain values are still escaped. + assert!(content.contains("export OPENAI_BASE_URL='http://gpu:8000'")); + } + + #[test] + fn test_is_shell_var_reference() { + assert!(is_shell_var_reference("${FOO}")); + assert!(is_shell_var_reference("${MY_KEY_2}")); + assert!(!is_shell_var_reference("${}")); + assert!(!is_shell_var_reference("plain")); + assert!(!is_shell_var_reference("${FOO} bar")); // not a pure reference + assert!(!is_shell_var_reference("http://localhost")); + } + + #[test] + fn test_write_command_file_without_operator_env_unchanged() { + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let config = make_test_config_with_tickets_path(temp_dir.path()); + + let result = write_command_file( + &config, + "test-uuid-noenv", + "/path/to/project", + "claude --session-id abc123", + None, + None, + ); + assert!(result.is_ok()); + + let content = std::fs::read_to_string(result.unwrap()).unwrap(); + assert!(!content.contains("OPERATOR_")); + assert!(!content.contains("\\033]2;")); + assert!(content.starts_with("#!/bin/bash\ncd")); + } } diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 46701fd1..78c27869 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -501,9 +501,21 @@ async fn test_launch_global_ticket_uses_root() { // ======================================== use super::options::RelaunchOptions; +use super::prompt::OperatorEnvVars; use super::tmux_session::{launch_in_tmux_with_options, launch_in_tmux_with_relaunch_options}; use crate::agents::tmux::TmuxClient; +fn make_test_operator_env() -> OperatorEnvVars { + OperatorEnvVars { + agent_id: Uuid::new_v4().to_string(), + ticket_id: "TEST-001".to_string(), + project: "test-project".to_string(), + step: "initial".to_string(), + ui_url: "http://localhost:7008/#/agent/test".to_string(), + ui_port: 7008, + } +} + fn make_test_config_with_docker(temp_dir: &TempDir, image: &str) -> Config { let mut config = make_test_config(temp_dir); config.launch.docker.image = image.to_string(); @@ -539,6 +551,7 @@ fn test_launch_in_tmux_session_uses_prefix() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok(), "Launch failed: {:?}", result.err()); @@ -575,6 +588,7 @@ fn test_launch_in_tmux_existing_session_returns_error() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_err()); @@ -607,6 +621,7 @@ fn test_launch_in_tmux_sends_cd_command() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -652,6 +667,7 @@ fn test_launch_in_tmux_sends_llm_command() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -697,6 +713,7 @@ fn test_launch_in_tmux_yolo_mode_applies_flags() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -738,6 +755,7 @@ fn test_launch_in_tmux_yolo_mode_disabled_no_flags() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -779,6 +797,7 @@ fn test_launch_in_tmux_docker_mode_wraps() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -821,6 +840,7 @@ fn test_launch_in_tmux_both_modes() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -884,6 +904,7 @@ fn test_launch_in_tmux_uses_provider_from_options() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -926,6 +947,7 @@ fn test_launch_in_tmux_writes_prompt_file() { &project_path, "Test prompt content", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -968,6 +990,7 @@ fn test_launch_in_tmux_tmux_not_installed() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_err()); @@ -1007,6 +1030,7 @@ fn test_relaunch_fresh_start_new_uuid() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1043,6 +1067,7 @@ fn test_relaunch_inherits_yolo_mode() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1088,6 +1113,7 @@ fn test_relaunch_inherits_docker_mode() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1131,6 +1157,7 @@ fn test_relaunch_existing_session_errors() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_err()); @@ -1182,6 +1209,7 @@ fn test_relaunch_with_resume_adds_flag() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1231,6 +1259,7 @@ fn test_relaunch_missing_prompt_fresh_start() { &project_path, "Fallback prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1310,6 +1339,7 @@ fn test_launch_correct_project_directory_from_ticket() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1389,6 +1419,7 @@ fn test_launch_provider_from_delegator_determines_tool() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1434,6 +1465,7 @@ fn test_launch_yolo_flags_per_tool() { &project_path, "Test prompt", &options, + &make_test_operator_env(), ); assert!(result.is_ok()); @@ -1475,6 +1507,7 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() { let mut config = make_test_config(&temp_dir); config.agents.max_parallel = 10; config.agents.cores_reserved = 0; + config.agents.max_agents_per_repo = 10; add_delegators(&mut config, &["claude-opus", "gemini-pro"]); let mock = Arc::new(MockTmuxClient::new()); @@ -1547,9 +1580,10 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() { async fn test_launch_pending_sub_agents_respects_slot_budget() { let temp_dir = TempDir::new().unwrap(); let mut config = make_test_config(&temp_dir); - // Budget of exactly 1 slot + // Budget of exactly 1 global slot (per-repo cap is high so only max_parallel constrains) config.agents.max_parallel = 1; config.agents.cores_reserved = 0; + config.agents.max_agents_per_repo = 10; add_delegators(&mut config, &["claude-opus", "gemini-pro"]); let mock = Arc::new(MockTmuxClient::new()); diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs index 58c1dfb7..c658444d 100644 --- a/src/agents/launcher/tmux_session.rs +++ b/src/agents/launcher/tmux_session.rs @@ -16,7 +16,7 @@ use super::llm_command::{ use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, - write_prompt_file, + write_prompt_file, OperatorEnvVars, }; use super::SESSION_PREFIX; @@ -28,6 +28,7 @@ pub fn launch_in_tmux_with_options( project_path: &str, initial_prompt: &str, options: &LaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Create session name from ticket ID (sanitize for tmux). // For multi-agent fan-out, append the session_suffix to distinguish @@ -180,12 +181,24 @@ pub fn launch_in_tmux_with_options( // Wrap in docker command if docker mode is enabled if options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command( + config, + &llm_cmd, + project_path, + options.provider.as_ref().map(|p| &p.env), + )?; } // Write the command to a shell script file to avoid issues with long commands // and special characters when using tmux send-keys - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + options.provider.as_ref().map(|p| &p.env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { @@ -228,6 +241,7 @@ pub fn launch_in_tmux_with_relaunch_options( project_path: &str, initial_prompt: &str, options: &RelaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Create session name from ticket ID (sanitize for tmux) let session_name = format!("{}{}", SESSION_PREFIX, sanitize_session_name(&ticket.id)); @@ -404,12 +418,24 @@ pub fn launch_in_tmux_with_relaunch_options( // Wrap in docker command if docker mode is enabled if options.launch_options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command( + config, + &llm_cmd, + project_path, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; } // Write the command to a shell script file to avoid issues with long commands // and special characters when using tmux send-keys - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs index 5f93b73f..4ee3d016 100644 --- a/src/agents/launcher/worktree_setup.rs +++ b/src/agents/launcher/worktree_setup.rs @@ -273,7 +273,6 @@ async fn detect_default_branch(repo_path: &Path) -> Option { /// * `cleanup_script` - Optional cleanup script to run before removal /// * `prune_branch` - Whether to delete the branch /// * `delete_remote_branch` - Whether to delete the remote branch too -#[allow(dead_code)] // Will be used in sync.rs for PR merge cleanup pub async fn cleanup_ticket_worktree( config: &Config, worktree_path: &Path, diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index 009b06c0..1eb46796 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -20,7 +20,7 @@ use super::llm_command::{ use super::options::{LaunchOptions, RelaunchOptions}; use super::prompt::{ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file, - write_prompt_file, + write_prompt_file, OperatorEnvVars, }; /// Result of launching in zellij — includes tab name for state tracking #[derive(Debug, Clone)] @@ -38,6 +38,7 @@ pub fn launch_in_zellij_with_options( project_path: &str, initial_prompt: &str, options: &LaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check zellij is available and we're inside zellij zellij @@ -132,11 +133,23 @@ pub fn launch_in_zellij_with_options( } if options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command( + config, + &llm_cmd, + project_path, + options.provider.as_ref().map(|p| &p.env), + )?; } // Write the command to a shell script file - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + options.provider.as_ref().map(|p| &p.env), + )?; // Inject relay env vars so agents can find the hub and register with their ticket ID if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { @@ -183,6 +196,7 @@ pub fn launch_in_zellij_with_relaunch_options( project_path: &str, initial_prompt: &str, options: &RelaunchOptions, + operator_env: &OperatorEnvVars, ) -> Result { // Check zellij is available and we're inside zellij zellij @@ -292,11 +306,23 @@ pub fn launch_in_zellij_with_relaunch_options( } if options.launch_options.docker_mode { - llm_cmd = build_docker_command(config, &llm_cmd, project_path)?; + llm_cmd = build_docker_command( + config, + &llm_cmd, + project_path, + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; } // Write and send command - let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?; + let command_file = write_command_file( + config, + &session_uuid, + project_path, + &llm_cmd, + Some(operator_env), + options.launch_options.provider.as_ref().map(|p| &p.env), + )?; if let Ok(socket_path) = std::env::var("RELAY_HUB_SOCKET") { let export_cmd = format!( "export RELAY_HUB_SOCKET={socket_path} RELAY_AGENT_NAME={}\n", diff --git a/src/agents/mod.rs b/src/agents/mod.rs index e0d6edc9..7f2e4a6c 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -9,7 +9,7 @@ pub mod delegator_resolution; mod generator; pub mod hooks; pub mod idle_detector; -mod launcher; +pub(crate) mod launcher; mod monitor; mod pr_workflow; mod session; diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index 619ee020..2dc9244d 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -396,7 +396,7 @@ impl SessionMonitor { if elapsed >= self.check_interval { Duration::ZERO } else { - self.check_interval - elapsed + self.check_interval.saturating_sub(elapsed) } } } diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 686caa0b..d4a00448 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -618,7 +618,7 @@ impl TicketSessionSync { if elapsed >= self.sync_interval { Duration::ZERO } else { - self.sync_interval - elapsed + self.sync_interval.saturating_sub(elapsed) } } diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index cc4ce1f4..bf78804f 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -272,6 +272,18 @@ pub enum KanbanProviderType { } impl KanbanProviderType { + /// The canonical list of supported kanban providers, in display order. + /// + /// This is the single source of truth for "which providers exist". Every + /// surface (TUI status section, web `/#/kanban`, the REST provider catalog + /// endpoint, and the VS Code onboarding picker) derives its list from here + /// so the options can't drift apart. + pub const ALL: [KanbanProviderType; 3] = [ + KanbanProviderType::Jira, + KanbanProviderType::Linear, + KanbanProviderType::Github, + ]; + /// Get the display name pub fn display_name(&self) -> &'static str { match self { @@ -281,6 +293,54 @@ impl KanbanProviderType { } } + /// Lowercase wire slug — the stable identifier used in config keys, the + /// `ConfigureKanbanProvider` action, and the REST catalog. + pub fn slug(&self) -> &'static str { + match self { + KanbanProviderType::Jira => "jira", + KanbanProviderType::Linear => "linear", + KanbanProviderType::Github => "github", + } + } + + /// Parse a provider from its lowercase [`slug`](Self::slug). + pub fn from_slug(slug: &str) -> Option { + KanbanProviderType::ALL + .into_iter() + .find(|p| p.slug() == slug) + } + + /// One-line "connect" description shown next to the provider in list views. + pub fn connect_blurb(&self) -> &'static str { + match self { + KanbanProviderType::Jira => "Connect to Jira Cloud", + KanbanProviderType::Linear => "Connect to Linear", + KanbanProviderType::Github => "Connect to GitHub Projects", + } + } + + /// The provider's credential/token page. Opened by the TUI "Configure" + /// action and surfaced as the clickable link on the web `/#/kanban` rows + /// (there is no in-browser onboarding wizard — this opens the token page). + pub fn setup_url(&self) -> &'static str { + match self { + KanbanProviderType::Jira => { + "https://id.atlassian.com/manage-profile/security/api-tokens" + } + KanbanProviderType::Linear => "https://linear.app/settings/api", + KanbanProviderType::Github => "https://github.com/settings/personal-access-tokens", + } + } + + /// Codicon hint for the VS Code onboarding picker (rendered as `$(icon)`). + pub fn icon(&self) -> &'static str { + match self { + KanbanProviderType::Jira => "operator-atlassian", + KanbanProviderType::Linear => "operator-linear", + KanbanProviderType::Github => "github", + } + } + /// Get the default environment variable name for the API key pub fn default_api_key_env(&self) -> &'static str { match self { @@ -832,4 +892,55 @@ mod tests { assert!(parse_custom_jira_env_var("OPERATOR_LINEAR_API_KEY").is_none()); assert!(parse_custom_jira_env_var("SOME_OTHER_VAR").is_none()); } + + #[test] + fn test_provider_type_all_covers_three_providers() { + assert_eq!(KanbanProviderType::ALL.len(), 3); + assert_eq!( + KanbanProviderType::ALL, + [ + KanbanProviderType::Jira, + KanbanProviderType::Linear, + KanbanProviderType::Github, + ] + ); + } + + #[test] + fn test_provider_type_slug_from_slug_round_trips() { + for provider in KanbanProviderType::ALL { + assert_eq!( + KanbanProviderType::from_slug(provider.slug()), + Some(provider) + ); + } + assert_eq!(KanbanProviderType::Jira.slug(), "jira"); + assert_eq!(KanbanProviderType::Linear.slug(), "linear"); + assert_eq!(KanbanProviderType::Github.slug(), "github"); + assert_eq!(KanbanProviderType::from_slug("nope"), None); + } + + #[test] + fn test_provider_type_catalog_metadata() { + assert_eq!( + KanbanProviderType::Jira.connect_blurb(), + "Connect to Jira Cloud" + ); + assert_eq!( + KanbanProviderType::Linear.connect_blurb(), + "Connect to Linear" + ); + assert_eq!( + KanbanProviderType::Github.connect_blurb(), + "Connect to GitHub Projects" + ); + // Setup URLs are absolute https token pages. + for provider in KanbanProviderType::ALL { + assert!(provider.setup_url().starts_with("https://")); + } + assert_eq!( + KanbanProviderType::Github.setup_url(), + "https://github.com/settings/personal-access-tokens" + ); + } } diff --git a/src/api/providers/mod.rs b/src/api/providers/mod.rs index 829e3172..665c98fc 100644 --- a/src/api/providers/mod.rs +++ b/src/api/providers/mod.rs @@ -10,9 +10,11 @@ pub mod ai; pub mod kanban; +pub mod model_server; pub mod repo; // Re-export commonly used types pub use ai::{AiProvider, RateLimitInfo}; pub use kanban::{ExternalIssueType, KanbanProvider, ProjectInfo}; +pub use model_server::{ModelInfo, ModelServerKind, ProbeOutcome}; pub use repo::{PrStatus, RepoProvider}; diff --git a/src/api/providers/model_server/mod.rs b/src/api/providers/model_server/mod.rs new file mode 100644 index 00000000..65975afb --- /dev/null +++ b/src/api/providers/model_server/mod.rs @@ -0,0 +1,339 @@ +#![allow(dead_code)] + +//! Model server provider catalog and live model-listing probe. +//! +//! A *model server* is a named inference endpoint a delegator can target +//! (anthropic-api / openai-api / google-api builtins, plus user-declared +//! ollama / openai-compat / lmstudio hosts). The [`ModelServerKind`] enum is the +//! single source of truth for the closed set of supported protocols — every +//! surface (TUI status section, web `/#/model-servers` projection, the REST +//! catalog endpoint, and the VS Code status tree) derives its list from +//! [`ModelServerKind::ALL`] so the options can't drift apart. +//! +//! This mirrors the `KanbanProviderType` pattern in +//! [`crate::api::providers::kanban`]. + +mod probe; + +use std::collections::HashMap; + +pub use probe::{probe_models, ModelInfo, ProbeError, ProbeOutcome}; + +use crate::config::ModelServer; + +/// Build the environment variables an agent CLI needs to target this server. +/// +/// This is the mapping the `TODO(model-servers-v2)` called for: a resolved +/// [`ModelServer`] → the env vars exported before the agent spawns. +/// +/// - `base_url` → the protocol's [`ModelServerKind::base_url_env_var`]. +/// - `api_key_env` (if set) → a **shell indirection** `${api_key_env}` under the +/// protocol's [`ModelServerKind::api_key_env_var`]. The spawned script inherits +/// operator's environment, so the reference resolves at run time and the secret +/// value is never written to the on-disk command file. +/// - `extra_env` → passed through verbatim as the user's explicit escape hatch +/// (takes precedence over the derived vars). +/// +/// Pure (no environment reads), so the secret never transits this function. +/// Returns an empty map for implicit builtins with no `base_url` — preserving the +/// vendor-default path exactly as before. +pub fn env_for_server(server: &ModelServer) -> HashMap { + let mut env = HashMap::new(); + + if let Some(kind) = ModelServerKind::from_slug(&server.kind) { + if let Some(base) = server.base_url.as_deref().filter(|u| !u.is_empty()) { + env.insert(kind.base_url_env_var().to_string(), base.to_string()); + } + // Map the user's key var to the canonical one the CLI reads, by reference + // (no value copy). When they already match this is a harmless self-export. + if let Some(var) = server.api_key_env.as_deref().filter(|v| !v.is_empty()) { + env.insert(kind.api_key_env_var().to_string(), format!("${{{var}}}")); + } + } + + for (k, v) in &server.extra_env { + env.insert(k.clone(), v.clone()); + } + + env +} + +/// A model-server protocol kind. +/// +/// `OpenAiCompat` is the explicit catch-all for any OpenAI-API-compatible server +/// (vllm, groq, together.ai, …). Distinct from a *server instance*, which is +/// open and user-named (`ModelServer.name`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelServerKind { + AnthropicApi, + OpenAiApi, + GoogleApi, + Ollama, + OpenAiCompat, + LmStudio, +} + +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] = [ + ModelServerKind::AnthropicApi, + ModelServerKind::OpenAiApi, + ModelServerKind::GoogleApi, + ModelServerKind::Ollama, + ModelServerKind::OpenAiCompat, + ModelServerKind::LmStudio, + ]; + + /// Stable wire slug — matches the `kind` string stored on + /// [`crate::config::ModelServer`] and used in config, the REST catalog, and + /// the `ConfigureModelServer` action. + pub fn slug(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi => "anthropic-api", + ModelServerKind::OpenAiApi => "openai-api", + ModelServerKind::GoogleApi => "google-api", + ModelServerKind::Ollama => "ollama", + ModelServerKind::OpenAiCompat => "openai-compat", + ModelServerKind::LmStudio => "lmstudio", + } + } + + /// Parse a kind from its [`slug`](Self::slug) (i.e. a `ModelServer.kind`). + pub fn from_slug(slug: &str) -> Option { + ModelServerKind::ALL.into_iter().find(|k| k.slug() == slug) + } + + /// Human-friendly display name. + pub fn display_name(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi => "Anthropic API", + ModelServerKind::OpenAiApi => "OpenAI API", + ModelServerKind::GoogleApi => "Google Gemini API", + ModelServerKind::Ollama => "Ollama", + ModelServerKind::OpenAiCompat => "OpenAI-compatible", + ModelServerKind::LmStudio => "LM Studio", + } + } + + /// Whether this kind is one of the implicit vendor builtins + /// (`anthropic-api` / `openai-api` / `google-api`) that always exist and + /// cannot be deleted. + pub fn is_builtin(&self) -> bool { + matches!( + self, + ModelServerKind::AnthropicApi | ModelServerKind::OpenAiApi | ModelServerKind::GoogleApi + ) + } + + /// One-line "connect" blurb shown next to the kind in catalog rows. + pub fn connect_blurb(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi => "Anthropic Console or a compatible proxy", + ModelServerKind::OpenAiApi => "OpenAI or a compatible proxy", + ModelServerKind::GoogleApi => "Google Gemini API", + ModelServerKind::Ollama => "Local ollama server (ollama serve)", + ModelServerKind::OpenAiCompat => "Any OpenAI-compatible server (vllm, groq, …)", + ModelServerKind::LmStudio => "LM Studio's local server", + } + } + + /// Help/credential page opened by the "Configure" action and the web link. + pub fn setup_url(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi => "https://console.anthropic.com/settings/keys", + ModelServerKind::OpenAiApi => "https://platform.openai.com/api-keys", + ModelServerKind::GoogleApi => "https://aistudio.google.com/app/apikey", + ModelServerKind::Ollama => "https://ollama.com/download", + ModelServerKind::OpenAiCompat => { + "https://operator.untra.io/getting-started/model-servers/" + } + ModelServerKind::LmStudio => "https://lmstudio.ai/", + } + } + + /// Codicon hint (rendered as `$(icon)` in VS Code, `codicon-{icon}` on web). + pub fn icon(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi + | ModelServerKind::OpenAiApi + | ModelServerKind::GoogleApi => "cloud", + ModelServerKind::Ollama | ModelServerKind::OpenAiCompat | ModelServerKind::LmStudio => { + "server" + } + } + } + + /// Path appended to a server's `base_url` to list the models it serves. + /// + /// The protocol determines the shape of the response — see + /// [`probe::probe_models`] for parsing. Endpoints reflect each vendor's + /// documented model-list route. + pub fn models_endpoint(&self) -> &'static str { + match self { + // ollama's native tag list + ModelServerKind::Ollama => "/api/tags", + // OpenAI-protocol model list + ModelServerKind::OpenAiApi + | ModelServerKind::OpenAiCompat + | ModelServerKind::LmStudio => "/v1/models", + // Anthropic's model list + ModelServerKind::AnthropicApi => "/v1/models", + // Gemini's model list + ModelServerKind::GoogleApi => "/v1beta/models", + } + } + + /// The env var an agent CLI reads to override its inference base URL. + /// + /// Keyed by *protocol* (the kind), not the tool: the SDK that reads the var + /// is determined by which API protocol the server speaks. e.g. codex talking + /// to ollama uses `OPENAI_BASE_URL` because ollama speaks the `OpenAI` + /// protocol; claude via an anthropic bridge uses `ANTHROPIC_BASE_URL`. + pub fn base_url_env_var(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi => "ANTHROPIC_BASE_URL", + ModelServerKind::OpenAiApi + | ModelServerKind::OpenAiCompat + | ModelServerKind::Ollama + | ModelServerKind::LmStudio => "OPENAI_BASE_URL", + ModelServerKind::GoogleApi => "GOOGLE_GEMINI_BASE_URL", + } + } + + /// The canonical env var an agent CLI reads for its API key, by protocol. + pub fn api_key_env_var(&self) -> &'static str { + match self { + ModelServerKind::AnthropicApi => "ANTHROPIC_API_KEY", + ModelServerKind::OpenAiApi + | ModelServerKind::OpenAiCompat + | ModelServerKind::Ollama + | ModelServerKind::LmStudio => "OPENAI_API_KEY", + ModelServerKind::GoogleApi => "GEMINI_API_KEY", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_slug_roundtrip_for_all_kinds() { + for kind in ModelServerKind::ALL { + assert_eq!(ModelServerKind::from_slug(kind.slug()), Some(kind)); + } + } + + #[test] + fn test_from_slug_matches_existing_config_kind_strings() { + // These are the kind strings already documented + persisted in configs. + assert_eq!( + ModelServerKind::from_slug("ollama"), + Some(ModelServerKind::Ollama) + ); + assert_eq!( + ModelServerKind::from_slug("openai-compat"), + Some(ModelServerKind::OpenAiCompat) + ); + assert_eq!( + ModelServerKind::from_slug("anthropic-api"), + Some(ModelServerKind::AnthropicApi) + ); + assert_eq!(ModelServerKind::from_slug("nope"), None); + } + + #[test] + fn test_builtins_are_the_three_vendor_apis() { + let builtins: Vec<_> = ModelServerKind::ALL + .into_iter() + .filter(ModelServerKind::is_builtin) + .map(|k| k.slug()) + .collect(); + assert_eq!(builtins, vec!["anthropic-api", "openai-api", "google-api"]); + } + + #[test] + fn test_models_endpoint_by_protocol() { + assert_eq!(ModelServerKind::Ollama.models_endpoint(), "/api/tags"); + assert_eq!( + ModelServerKind::OpenAiCompat.models_endpoint(), + "/v1/models" + ); + assert_eq!( + ModelServerKind::GoogleApi.models_endpoint(), + "/v1beta/models" + ); + } + + fn server(kind: &str, base_url: Option<&str>) -> ModelServer { + ModelServer { + name: "test".into(), + kind: kind.into(), + base_url: base_url.map(str::to_string), + api_key_env: None, + extra_env: HashMap::new(), + display_name: None, + } + } + + #[test] + fn test_env_for_server_maps_base_url_by_protocol() { + let s = server("ollama", Some("http://localhost:11434")); + let env = env_for_server(&s); + assert_eq!( + env.get("OPENAI_BASE_URL").map(String::as_str), + Some("http://localhost:11434") + ); + } + + #[test] + fn test_env_for_server_builtin_without_base_url_is_empty() { + let s = server("anthropic-api", None); + assert!(env_for_server(&s).is_empty()); + } + + #[test] + fn test_env_for_server_api_key_is_reference_not_value() { + let mut s = server("openai-compat", Some("http://gpu:8000")); + s.api_key_env = Some("MY_SECRET_KEY".into()); + let env = env_for_server(&s); + // Mapped to the canonical var by reference — the secret value is never read. + assert_eq!( + env.get("OPENAI_API_KEY").map(String::as_str), + Some("${MY_SECRET_KEY}") + ); + } + + #[test] + fn test_env_for_server_extra_env_passthrough_and_precedence() { + let mut s = server("openai-compat", Some("http://gpu:8000")); + s.extra_env + .insert("OPENAI_BASE_URL".into(), "http://override:9000".into()); + s.extra_env + .insert("HTTP_PROXY".into(), "http://proxy".into()); + let env = env_for_server(&s); + // extra_env wins over the derived base_url mapping. + assert_eq!( + env.get("OPENAI_BASE_URL").map(String::as_str), + Some("http://override:9000") + ); + assert_eq!( + env.get("HTTP_PROXY").map(String::as_str), + Some("http://proxy") + ); + } + + #[test] + fn test_base_url_env_var_is_protocol_keyed() { + assert_eq!( + ModelServerKind::Ollama.base_url_env_var(), + "OPENAI_BASE_URL" + ); + assert_eq!( + ModelServerKind::AnthropicApi.base_url_env_var(), + "ANTHROPIC_BASE_URL" + ); + } +} diff --git a/src/api/providers/model_server/probe.rs b/src/api/providers/model_server/probe.rs new file mode 100644 index 00000000..c6f6acaa --- /dev/null +++ b/src/api/providers/model_server/probe.rs @@ -0,0 +1,292 @@ +//! Live model-listing probe for a configured model server. +//! +//! [`probe_models`] hits a server's [`ModelServerKind::models_endpoint`] and +//! returns the models it serves. The same request doubles as a reachability +//! health check — a successful probe means the endpoint is up and (where +//! relevant) the API key is accepted, so there is no separate "test connection". +//! +//! Parsing is split out from the HTTP call ([`parse_models`]) so the per-protocol +//! response shapes can be unit-tested without a live server. + +use std::time::Duration; + +use serde_json::Value; + +use super::ModelServerKind; +use crate::config::ModelServer; + +/// A single model offered by a server. Minimal by design — id is the wire name +/// passed to `--model`; `display_name` is shown in UIs when the server provides one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelInfo { + pub id: String, + pub display_name: Option, +} + +/// Why a probe failed. Distinguished so UIs can show a useful status. +#[derive(Debug, thiserror::Error)] +pub enum ProbeError { + #[error("server '{0}' has no base_url to probe")] + NoBaseUrl(String), + #[error("unknown model server kind '{0}'")] + UnknownKind(String), + #[error("request failed: {0}")] + Network(String), + #[error("server returned {status}: {body}")] + HttpStatus { status: u16, body: String }, + #[error("could not parse model list: {0}")] + Parse(String), +} + +/// Result of probing a server: reachability plus the model list (when reachable). +#[derive(Debug, Clone)] +pub struct ProbeOutcome { + pub reachable: bool, + pub models: Vec, + pub error: Option, +} + +impl ProbeOutcome { + fn ok(models: Vec) -> Self { + Self { + reachable: true, + models, + error: None, + } + } + + fn failed(err: &ProbeError) -> Self { + Self { + reachable: false, + models: Vec::new(), + error: Some(err.to_string()), + } + } +} + +/// Probe a server for its model list. Never panics; returns a [`ProbeOutcome`] +/// summarizing reachability so callers can render status without handling errors. +pub async fn probe_models(server: &ModelServer) -> ProbeOutcome { + match probe_models_inner(server).await { + Ok(models) => ProbeOutcome::ok(models), + Err(err) => ProbeOutcome::failed(&err), + } +} + +async fn probe_models_inner(server: &ModelServer) -> Result, ProbeError> { + let kind = ModelServerKind::from_slug(&server.kind) + .ok_or_else(|| ProbeError::UnknownKind(server.kind.clone()))?; + + let base = server + .base_url + .as_deref() + .filter(|u| !u.is_empty()) + .ok_or_else(|| ProbeError::NoBaseUrl(server.name.clone()))?; + + let url = format!("{}{}", base.trim_end_matches('/'), kind.models_endpoint()); + + let api_key = server + .api_key_env + .as_deref() + .and_then(|var| std::env::var(var).ok()) + .filter(|k| !k.is_empty()); + + let client = reqwest::Client::builder() + .user_agent("operator-tui") + .timeout(Duration::from_secs(5)) + .build() + .map_err(|e| ProbeError::Network(e.to_string()))?; + + let mut req = client.get(&url); + // Per-protocol auth header conventions. + match kind { + ModelServerKind::AnthropicApi => { + req = req.header("anthropic-version", "2023-06-01"); + if let Some(key) = &api_key { + req = req.header("x-api-key", key); + } + } + ModelServerKind::GoogleApi => { + if let Some(key) = &api_key { + req = req.query(&[("key", key.as_str())]); + } + } + // OpenAI-protocol + ollama use bearer auth (ollama ignores it). + _ => { + if let Some(key) = &api_key { + req = req.bearer_auth(key); + } + } + } + + let resp = req + .send() + .await + .map_err(|e| ProbeError::Network(e.to_string()))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ProbeError::Network(e.to_string()))?; + + if !status.is_success() { + return Err(ProbeError::HttpStatus { + status: status.as_u16(), + body: body.chars().take(200).collect(), + }); + } + + parse_models(kind, &body) +} + +/// Parse a model-list response body according to the server's protocol. +pub fn parse_models(kind: ModelServerKind, body: &str) -> Result, ProbeError> { + let value: Value = serde_json::from_str(body).map_err(|e| ProbeError::Parse(e.to_string()))?; + + let models = match kind { + ModelServerKind::Ollama => parse_ollama(&value), + ModelServerKind::GoogleApi => parse_gemini(&value), + // OpenAI-protocol + anthropic both use a top-level `data` array of + // objects keyed by `id`. + _ => parse_openai_like(&value), + }; + + Ok(models) +} + +/// ollama `/api/tags`: `{ "models": [ { "name": "llama3:latest", ... } ] }`. +fn parse_ollama(value: &Value) -> Vec { + value + .get("models") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| m.get("name").and_then(Value::as_str)) + .map(|name| ModelInfo { + id: name.to_string(), + display_name: None, + }) + .collect() + }) + .unwrap_or_default() +} + +/// `OpenAI` `/v1/models` and Anthropic `/v1/models`: +/// `{ "data": [ { "id": "...", "display_name"?: "..." } ] }`. +fn parse_openai_like(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)?; + Some(ModelInfo { + id: id.to_string(), + display_name: m + .get("display_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 { + value + .get("models") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|m| { + 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); + Some(ModelInfo { + id: id.to_string(), + display_name: m + .get("displayName") + .and_then(Value::as_str) + .map(std::string::ToString::to_string), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ollama_tags() { + let body = r#"{"models":[{"name":"llama3:latest","model":"llama3:latest"}, + {"name":"qwen2.5-coder:7b"}]}"#; + let models = parse_models(ModelServerKind::Ollama, body).unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].id, "llama3:latest"); + assert_eq!(models[1].id, "qwen2.5-coder:7b"); + } + + #[test] + fn test_parse_openai_models() { + let body = r#"{"object":"list","data":[{"id":"gpt-4o","object":"model"}, + {"id":"gpt-4o-mini","object":"model"}]}"#; + let models = parse_models(ModelServerKind::OpenAiCompat, body).unwrap(); + assert_eq!(models.len(), 2); + assert_eq!(models[0].id, "gpt-4o"); + } + + #[test] + fn test_parse_anthropic_models_with_display_name() { + let body = + r#"{"data":[{"type":"model","id":"claude-opus-4","display_name":"Claude Opus 4"}]}"#; + let models = parse_models(ModelServerKind::AnthropicApi, body).unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "claude-opus-4"); + assert_eq!(models[0].display_name.as_deref(), Some("Claude Opus 4")); + } + + #[test] + fn test_parse_gemini_strips_models_prefix() { + let body = + r#"{"models":[{"name":"models/gemini-1.5-pro","displayName":"Gemini 1.5 Pro"}]}"#; + 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_invalid_json_errors() { + let err = parse_models(ModelServerKind::Ollama, "not json").unwrap_err(); + assert!(matches!(err, ProbeError::Parse(_))); + } + + #[test] + fn test_parse_empty_list() { + let models = parse_models(ModelServerKind::OpenAiCompat, r#"{"data":[]}"#).unwrap(); + assert!(models.is_empty()); + } + + #[tokio::test] + async fn test_probe_no_base_url_is_unreachable() { + let server = ModelServer { + name: "anthropic-api".into(), + kind: "anthropic-api".into(), + base_url: None, + api_key_env: None, + extra_env: std::collections::HashMap::new(), + display_name: None, + }; + let outcome = probe_models(&server).await; + assert!(!outcome.reachable); + assert!(outcome.error.is_some()); + } +} diff --git a/src/app/agents.rs b/src/app/agents.rs index 015a4286..8be1b3e3 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -160,10 +160,11 @@ impl App { // Get selected ticket if let Some(ticket) = self.dashboard.selected_ticket().cloned() { - // Check if project is already busy - if state.is_project_busy(&ticket.project) { + let project_count = state.project_agent_count(&ticket.project); + let max_per_repo = self.config.effective_max_agents_per_repo(); + if project_count >= max_per_repo { self.dashboard.set_status(&format!( - "Cannot launch: {} has an active agent", + "Cannot launch: {} has {project_count}/{max_per_repo} agents", ticket.project )); return Ok(()); @@ -211,9 +212,11 @@ impl App { return Ok(()); }; - if state.is_project_busy(&ticket.project) { + let project_count = state.project_agent_count(&ticket.project); + let max_per_repo = self.config.effective_max_agents_per_repo(); + if project_count >= max_per_repo { self.dashboard.set_status(&format!( - "Cannot launch: {} has an active agent", + "Cannot launch: {} has {project_count}/{max_per_repo} agents", ticket.project )); return Ok(()); @@ -244,6 +247,7 @@ impl App { None, // no explicit delegator — let the chain resolve None, // no explicit provider None, // no explicit model + None, // no explicit model_server false, agent_context.as_ref(), ) { diff --git a/src/app/data_sync.rs b/src/app/data_sync.rs index 3279e743..ef195577 100644 --- a/src/app/data_sync.rs +++ b/src/app/data_sync.rs @@ -33,11 +33,6 @@ impl App { .collect(); self.dashboard.update_completed(completed); - // Update Backstage server status - self.backstage_server.refresh_status(); - self.dashboard - .update_backstage_status(self.backstage_server.status()); - // Update wrapper connection status let wrapper_status = self.check_wrapper_connection(); self.dashboard diff --git a/src/app/keyboard.rs b/src/app/keyboard.rs index 9f5b7b35..9bc6ad89 100644 --- a/src/app/keyboard.rs +++ b/src/app/keyboard.rs @@ -20,12 +20,10 @@ impl App { // Setup screen takes absolute priority if let Some(ref mut setup) = self.setup_screen { match code { - KeyCode::Char('i' | 'I') => { - if setup.confirm_selected { - self.initialize_tickets()?; - self.setup_screen = None; - self.refresh_data()?; - } + KeyCode::Char('i' | 'I') if setup.confirm_selected => { + self.initialize_tickets()?; + self.setup_screen = None; + self.refresh_data()?; } KeyCode::Enter => { match setup.confirm() { @@ -225,11 +223,9 @@ impl App { // Session recovery dialog handling if self.session_recovery_dialog.visible { match code { - KeyCode::Char('r' | 'R') => { - if self.session_recovery_dialog.has_session_id() { - self.handle_session_recovery(SessionRecoverySelection::ResumeSession) - .await?; - } + KeyCode::Char('r' | 'R') if self.session_recovery_dialog.has_session_id() => { + self.handle_session_recovery(SessionRecoverySelection::ResumeSession) + .await?; } KeyCode::Char('s' | 'S') => { self.handle_session_recovery(SessionRecoverySelection::StartFresh) @@ -389,9 +385,8 @@ impl App { match code { KeyCode::Char('q') => { // Stop servers if running before exiting - if self.rest_api_server.is_running() || self.backstage_server.is_running() { + if self.rest_api_server.is_running() { self.rest_api_server.stop(); - let _ = self.backstage_server.stop(); } // Shut down PR monitor if let Some(tx) = self.pr_shutdown_tx.take() { @@ -491,7 +486,7 @@ impl App { } } KeyCode::Char('W' | 'w') => { - self.toggle_web_servers(terminal)?; + self.open_web_ui()?; } KeyCode::Char('T' | 't') => { // Open collection switch dialog @@ -530,7 +525,6 @@ impl App { } else { // First Ctrl+C - stop servers and enter confirmation mode self.rest_api_server.stop(); - let _ = self.backstage_server.stop(); self.exit_confirmation_mode = true; self.exit_confirmation_time = Some(std::time::Instant::now()); diff --git a/src/app/mod.rs b/src/app/mod.rs index 90c99c43..39edf1f9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,6 @@ use tokio::sync::{mpsc, RwLock}; use crate::agents::tmux::SystemTmuxClient; use crate::agents::{SessionMonitor, TicketSessionSync}; -use crate::backstage::BackstageServer; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; use crate::notifications::NotificationService; @@ -66,8 +65,6 @@ pub struct App { pub(crate) ticket_sync: TicketSessionSync, /// Last sync status message for display pub(crate) sync_status_message: Option, - /// Backstage server lifecycle manager - pub(crate) backstage_server: BackstageServer, /// REST API server lifecycle manager pub(crate) rest_api_server: RestApiServer, /// Exit confirmation mode (first Ctrl+C pressed) @@ -76,6 +73,8 @@ pub struct App { pub(crate) exit_confirmation_time: Option, /// Start web servers on launch (--web flag) pub(crate) start_web_on_launch: bool, + /// Open the embedded web UI in browser on launch (--ui flag) + pub(crate) open_ui_on_launch: bool, /// Session recovery dialog for handling dead tmux sessions pub(crate) session_recovery_dialog: SessionRecoveryDialog, /// Collection switch dialog for changing active issue type collection @@ -118,7 +117,7 @@ pub struct App { } impl App { - pub async fn new(mut config: Config, start_web: bool) -> Result { + pub async fn new(mut config: Config, start_web: bool, open_ui: bool) -> Result { // Run LLM tool detection on first startup if !config.llm_tools.detection_complete { tracing::info!("Detecting LLM CLI tools..."); @@ -212,15 +211,6 @@ impl App { }; let ticket_sync = TicketSessionSync::new(&config, Arc::clone(&tmux_client)); - // Initialize Backstage server lifecycle manager using compiled binary mode - let backstage_server = BackstageServer::with_compiled_binary( - config.state_path(), - config.backstage.release_url.clone(), - config.backstage.local_binary_path.clone(), - config.backstage.port, - ) - .map_err(|e| anyhow::anyhow!("Failed to initialize backstage server: {e}"))?; - // Initialize REST API server lifecycle manager let rest_api_server = RestApiServer::new(config.clone(), config.rest_api.port); @@ -303,11 +293,11 @@ impl App { session_preview: SessionPreview::new(), ticket_sync, sync_status_message: None, - backstage_server, rest_api_server, exit_confirmation_mode: false, exit_confirmation_time: None, start_web_on_launch: start_web, + open_ui_on_launch: open_ui, session_recovery_dialog: SessionRecoveryDialog::new(), collection_dialog: CollectionSwitchDialog::new(), kanban_view: KanbanView::new(), @@ -331,6 +321,7 @@ impl App { }) } + #[allow(clippy::cognitive_complexity)] pub async fn run(&mut self) -> Result<()> { // Reconcile state with actual tmux sessions on startup self.reconcile_sessions()?; @@ -360,23 +351,21 @@ impl App { } } - // Start Backstage web server if --web flag was passed - if self.start_web_on_launch { - if let Err(e) = self.backstage_server.start() { - tracing::error!("Backstage start failed: {}", e); + // Start web servers if --web flag was passed + if self.start_web_on_launch && self.rest_api_server.is_running() { + let port = self.config.rest_api.port; + let url = format!("http://localhost:{port}/"); + if let Err(e) = status_actions::open_in_browser(&url) { + tracing::warn!("Failed to open web UI: {}", e); } - // Wait for server to be ready then open browser - if self.backstage_server.is_running() { - match self.backstage_server.wait_for_ready(25000) { - Ok(()) => { - if let Err(e) = self.backstage_server.open_browser() { - tracing::warn!("Failed to open browser: {}", e); - } - } - Err(e) => { - tracing::error!("Server not ready: {}", e); - } - } + } + + // Open embedded web UI in browser if --ui flag was passed + if self.open_ui_on_launch && self.rest_api_server.is_running() { + let port = self.config.rest_api.port; + let url = format!("http://localhost:{port}/"); + if let Err(e) = status_actions::open_in_browser(&url) { + tracing::warn!("Failed to open web UI: {}", e); } } @@ -396,6 +385,14 @@ impl App { // Update dashboard with server statuses and exit confirmation mode self.dashboard .update_rest_api_status(self.rest_api_server.status()); + // MCP session count — try_lock so we never block the UI tick; + // a contended lock falls back to the previous frame's count. + let mcp_sessions = self + .rest_api_server + .api_state() + .and_then(|s| s.mcp_sessions.try_lock().ok().map(|m| m.len())) + .unwrap_or(0); + self.dashboard.update_mcp_active_sessions(mcp_sessions); self.dashboard .update_exit_confirmation_mode(self.exit_confirmation_mode); diff --git a/src/app/status_actions.rs b/src/app/status_actions.rs index 1a818dfb..6c0e9cec 100644 --- a/src/app/status_actions.rs +++ b/src/app/status_actions.rs @@ -1,12 +1,49 @@ use anyhow::Result; use crate::config::SessionWrapperType; +use crate::rest::web_ui::EmbeddedUiState; use crate::ui::status_panel::StatusAction; use crate::ui::with_suspended_tui; use super::git_onboarding; use super::{App, AppTerminal}; +/// Decision from `decide_open_web_ui`: either open a URL or surface a status +/// message explaining why we can't. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum WebUiOutcome { + Open(String), + StatusOnly(String), +} + +/// Pure decision logic for "user pressed `w` / clicked Open Web UI". +/// +/// Kept free of `&self` so it can be unit-tested without spinning up an `App`. +/// Callers resolve the inputs from runtime state and act on the returned +/// outcome (open the URL or just show the status message). +pub(super) fn decide_open_web_ui( + api_running: bool, + url: &str, + state: EmbeddedUiState, +) -> WebUiOutcome { + if !api_running { + return WebUiOutcome::StatusOnly( + "API not running — press Enter on the Operator API row to start it.".into(), + ); + } + match state { + EmbeddedUiState::Ready => WebUiOutcome::Open(url.to_string()), + EmbeddedUiState::Placeholder => WebUiOutcome::StatusOnly( + "Web UI placeholder detected — run `cd ui && bun run build` and rebuild operator." + .into(), + ), + EmbeddedUiState::Missing => WebUiOutcome::StatusOnly( + "Binary built without `embed-ui` feature — rebuild with `cargo build` (default) or `--features embed-ui`." + .into(), + ), + } +} + /// Open a URL in the default browser. pub(super) fn open_in_browser(url: &str) -> std::io::Result<()> { let opener = if cfg!(target_os = "macos") { @@ -83,17 +120,23 @@ impl App { StatusAction::RestartWrapperConnection => { self.restart_wrapper_connection(); } - StatusAction::ToggleWebServers => { - self.toggle_web_servers(terminal)?; + StatusAction::OpenWebUi { port } => { + let url = format!("http://localhost:{port}/"); + self.try_open_web_ui(&url); + } + StatusAction::OpenWebUiAt { port, route } => { + let url = format!("http://localhost:{port}/#{route}"); + self.try_open_web_ui(&url); } StatusAction::SetDefaultLlm { tool_name, model } => { self.set_default_llm(&tool_name, &model); } StatusAction::ConfigureKanbanProvider { provider } => { - let url = match provider.as_str() { - "jira" => "https://id.atlassian.com/manage-profile/security/api-tokens", - "linear" => "https://linear.app/settings/api", - _ => return Ok(()), + let Some(url) = + crate::api::providers::kanban::KanbanProviderType::from_slug(&provider) + .map(|p| p.setup_url()) + else { + return Ok(()); }; if let Err(e) = open_in_browser(url) { self.dashboard @@ -104,6 +147,22 @@ impl App { )); } } + StatusAction::ConfigureModelServer { kind } => { + let Some(url) = + crate::api::providers::model_server::ModelServerKind::from_slug(&kind) + .map(|k| k.setup_url()) + else { + return Ok(()); + }; + if let Err(e) = open_in_browser(url) { + self.dashboard + .set_status(&format!("Failed to open {kind} setup: {e}")); + } else { + self.dashboard.set_status(&format!( + "Opened {kind} setup page — add a [[model_servers]] entry to your config" + )); + } + } StatusAction::ConfigureGitProvider { provider } => { match git_onboarding::resolve_onboarding(&provider) { Some(git_onboarding::OnboardingStep::InstallCli { @@ -181,6 +240,103 @@ impl App { .set_status(&format!("Failed to reload config: {e}")); } }, + StatusAction::ToggleMcpHttp => { + self.config.mcp.http_enabled = !self.config.mcp.http_enabled; + self.dashboard.update_config(&self.config); + self.dashboard.set_status(if self.config.mcp.http_enabled { + "MCP HTTP enabled — restart the API to mount routes" + } else { + "MCP HTTP disabled — restart the API to unmount routes" + }); + } + StatusAction::WriteAndOpenMcpClientConfig { client } => { + let cwd = std::env::current_dir().unwrap_or_default(); + let Some(snippet) = crate::mcp::client_configs::snippet_for(&client, &cwd) else { + self.dashboard + .set_status(&format!("Unknown MCP client: {client}")); + return Ok(()); + }; + let dir = self.config.tickets_path().join("operator/mcp"); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dashboard + .set_status(&format!("Failed to create {}: {e}", dir.display())); + return Ok(()); + } + let path = dir.join(format!("{client}.json")); + let body = serde_json::to_string_pretty(&snippet).unwrap_or_default(); + if let Err(e) = std::fs::write(&path, body) { + self.dashboard + .set_status(&format!("Failed to write {}: {e}", path.display())); + return Ok(()); + } + let cmd = self.dashboard.editor_config.file_editor().to_string(); + with_suspended_tui(terminal, || { + let (prog, args) = crate::editors::EditorConfig::split_command(&cmd); + let result = std::process::Command::new(prog) + .args(&args) + .arg(&path) + .status(); + if let Err(e) = result { + tracing::warn!("Failed to open editor: {}", e); + } + Ok(()) + })?; + } + StatusAction::OpenMcpDocs => { + if let Err(e) = open_in_browser("https://operator.untra.io/mcp/") { + self.dashboard + .set_status(&format!("Failed to open MCP docs: {e}")); + } + } + StatusAction::WriteAndOpenAcpEditorConfig { editor } => { + let Some(snippet) = crate::acp::client_configs::snippet_for(&editor) else { + self.dashboard + .set_status(&format!("Unknown ACP editor: {editor}")); + return Ok(()); + }; + let dir = self.config.tickets_path().join("operator/acp"); + if let Err(e) = std::fs::create_dir_all(&dir) { + self.dashboard + .set_status(&format!("Failed to create {}: {e}", dir.display())); + return Ok(()); + } + // Text-format editors (emacs elisp, kiro TOML) deserialise into + // a JSON string; everything else is a structured Value. + let (extension, body) = match snippet { + serde_json::Value::String(s) => { + let ext = if editor == "emacs" { "el" } else { "toml" }; + (ext, s) + } + other => ( + "json", + serde_json::to_string_pretty(&other).unwrap_or_default(), + ), + }; + let path = dir.join(format!("{editor}.{extension}")); + if let Err(e) = std::fs::write(&path, body) { + self.dashboard + .set_status(&format!("Failed to write {}: {e}", path.display())); + return Ok(()); + } + let cmd = self.dashboard.editor_config.file_editor().to_string(); + with_suspended_tui(terminal, || { + let (prog, args) = crate::editors::EditorConfig::split_command(&cmd); + let result = std::process::Command::new(prog) + .args(&args) + .arg(&path) + .status(); + if let Err(e) = result { + tracing::warn!("Failed to open editor: {}", e); + } + Ok(()) + })?; + } + StatusAction::OpenAcpDocs => { + if let Err(e) = open_in_browser("https://operator.untra.io/acp/") { + self.dashboard + .set_status(&format!("Failed to open ACP docs: {e}")); + } + } StatusAction::None => {} } Ok(()) @@ -245,49 +401,92 @@ impl App { .set_status(&format!("Default LLM set to {tool_name}:{model}")); } - /// Toggle both REST API and Backstage servers together. - pub(super) fn toggle_web_servers(&mut self, terminal: &mut AppTerminal) -> Result<()> { - let backstage_running = self.backstage_server.is_running(); - let rest_running = self.rest_api_server.is_running(); + /// Open the embedded web UI in the default browser. + pub(super) fn open_web_ui(&mut self) -> Result<()> { + let port = self.config.rest_api.port; + let url = format!("http://localhost:{port}/"); + self.try_open_web_ui(&url); + Ok(()) + } - if backstage_running && rest_running { - // Both running - stop both - self.rest_api_server.stop(); - if let Err(e) = self.backstage_server.stop() { - tracing::error!("Backstage stop failed: {}", e); + /// Shared implementation: consult `decide_open_web_ui`, either spawn the + /// browser or surface a status message explaining the failure. + fn try_open_web_ui(&mut self, url: &str) { + let outcome = decide_open_web_ui( + self.rest_api_server.is_running(), + url, + crate::rest::web_ui::embedded_ui_state(), + ); + match outcome { + WebUiOutcome::Open(url) => match open_in_browser(&url) { + Ok(()) => self + .dashboard + .set_status(&format!("Opened web UI at {url}")), + Err(e) => self + .dashboard + .set_status(&format!("Failed to open browser: {e}")), + }, + WebUiOutcome::StatusOnly(msg) => self.dashboard.set_status(&msg), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const URL: &str = "http://localhost:7008/"; + + #[test] + fn test_decide_open_web_ui_api_stopped_returns_status_message() { + let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Ready); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("API not running"), "got: {msg}"); } - } else { - // Show yellow "Starting" indicator immediately for feedback - use crate::backstage::ServerStatus; - self.dashboard - .update_backstage_status(ServerStatus::Starting); - terminal.draw(|f| self.dashboard.render(f))?; + other => panic!("expected StatusOnly, got {other:?}"), + } + } - // Start both if not running - if !rest_running { - if let Err(e) = self.rest_api_server.start() { - tracing::error!("REST API start failed: {}", e); - } + #[test] + fn test_decide_open_web_ui_ready_returns_url() { + let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Ready); + assert_eq!(outcome, WebUiOutcome::Open(URL.to_string())); + } + + #[test] + fn test_decide_open_web_ui_placeholder_warns_user() { + let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Placeholder); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("placeholder"), "got: {msg}"); + assert!(msg.contains("bun run build"), "got: {msg}"); } - if !backstage_running { - if let Err(e) = self.backstage_server.start() { - tracing::error!("Backstage start failed: {}", e); - } + other => panic!("expected StatusOnly, got {other:?}"), + } + } + + #[test] + fn test_decide_open_web_ui_missing_warns_user() { + let outcome = decide_open_web_ui(true, URL, EmbeddedUiState::Missing); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("embed-ui"), "got: {msg}"); } - // Wait for server to be ready before opening browser - if self.backstage_server.is_running() { - match self.backstage_server.wait_for_ready(25000) { - Ok(()) => { - if let Err(e) = self.backstage_server.open_browser() { - tracing::warn!("Failed to open browser: {}", e); - } - } - Err(e) => { - tracing::error!("Server not ready: {}", e); - } - } + other => panic!("expected StatusOnly, got {other:?}"), + } + } + + #[test] + fn test_decide_open_web_ui_api_stopped_takes_precedence_over_missing() { + // Even if the UI is missing, the user's first problem to solve is + // starting the API — surface that message, not the embed-ui one. + let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Missing); + match outcome { + WebUiOutcome::StatusOnly(msg) => { + assert!(msg.contains("API not running"), "got: {msg}"); } + other => panic!("expected StatusOnly, got {other:?}"), } - Ok(()) } } diff --git a/src/app/tests.rs b/src/app/tests.rs index 006d4584..bd3a3072 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -247,14 +247,16 @@ mod launch_validation { let dashboard_paused = state.paused; let running_count = state.running_agents().len(); let max_agents = config.effective_max_agents(); - let project_busy = state.is_project_busy("test-project"); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); // All conditions for launch should be met - let can_launch = !dashboard_paused && running_count < max_agents && !project_busy; + let can_launch = + !dashboard_paused && running_count < max_agents && project_count < max_per_repo; assert!( can_launch, - "Should be allowed to launch when not paused, under max, and project not busy" + "Should be allowed to launch when not paused, under max, and project under cap" ); } @@ -303,7 +305,7 @@ mod launch_validation { } #[test] - fn test_try_launch_blocked_project_busy() { + fn test_try_launch_blocked_project_at_capacity() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); @@ -318,22 +320,87 @@ mod launch_validation { ) .unwrap(); - // Check if project is busy let state = State::load(&config).unwrap(); - let project_busy = state.is_project_busy("test-project"); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); - assert!(project_busy, "Project should be busy with running agent"); + assert!( + project_count >= max_per_repo, + "Project should be at capacity with running agent" + ); } #[test] - fn test_try_launch_project_not_busy_when_empty() { + fn test_try_launch_project_empty() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let state = State::load(&config).unwrap(); - let project_busy = state.is_project_busy("test-project"); + let project_count = state.project_agent_count("test-project"); + + assert_eq!(project_count, 0, "Project should have no agents"); + } + + #[test] + fn test_try_launch_allowed_when_under_per_repo_cap() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_agents_per_repo = 2; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); + + assert_eq!(project_count, 1); + assert_eq!(max_per_repo, 2); + assert!( + project_count < max_per_repo, + "Should allow second agent when cap is 2" + ); + } + + #[test] + fn test_try_launch_blocked_at_per_repo_cap() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_agents_per_repo = 2; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "TASK-002".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + let project_count = state.project_agent_count("test-project"); + let max_per_repo = config.effective_max_agents_per_repo(); - assert!(!project_busy, "Project should not be busy without agents"); + assert!( + project_count >= max_per_repo, + "Should block third agent when cap is 2" + ); } #[test] @@ -939,3 +1006,670 @@ mod agent_switches { ); } } + +// ============================================ +// Ticket Creation Tests +// ============================================ + +mod ticket_creation { + use super::*; + use crate::queue::TicketCreator; + use crate::templates::TemplateType; + use std::collections::HashMap; + + #[test] + fn test_headless_ticket_creation_writes_file_to_queue() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + values.insert("summary".to_string(), "Add user auth".to_string()); + + let result = creator.create_ticket_headless(TemplateType::Task, &values); + assert!(result.is_ok(), "Ticket creation should succeed"); + + let filepath = result.unwrap(); + assert!(filepath.exists(), "Ticket file should exist on disk"); + assert!( + filepath.to_string_lossy().contains("queue"), + "Ticket should be created in the queue directory" + ); + } + + #[test] + fn test_headless_ticket_filename_contains_type_and_project() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + + let filepath = creator + .create_ticket_headless(TemplateType::Feature, &values) + .unwrap(); + let filename = filepath.file_name().unwrap().to_string_lossy(); + + assert!( + filename.contains("FEAT"), + "Filename should contain the ticket type: {filename}" + ); + assert!( + filename.contains("test-project"), + "Filename should contain the project: {filename}" + ); + } + + #[test] + fn test_headless_ticket_defaults_project_to_global() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let values = HashMap::new(); // No project specified + + let filepath = creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + let filename = filepath.file_name().unwrap().to_string_lossy(); + + assert!( + filename.contains("global"), + "Filename should default to 'global' when no project specified: {filename}" + ); + } + + #[test] + fn test_headless_ticket_content_is_not_empty() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + + let filepath = creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + let content = std::fs::read_to_string(&filepath).unwrap(); + + assert!(!content.is_empty(), "Ticket file should have content"); + } + + #[test] + fn test_created_ticket_appears_in_queue_listing() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), "test-project".to_string()); + values.insert("summary".to_string(), "Test listing".to_string()); + + creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_queue().unwrap(); + + assert_eq!(tickets.len(), 1, "Queue should have the created ticket"); + assert_eq!(tickets[0].ticket_type, "TASK"); + } + + #[test] + fn test_multiple_ticket_types_created_and_sorted_by_priority() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create tickets of different types + let types_and_projects = [ + (TemplateType::Feature, "test-project"), + (TemplateType::Fix, "test-project"), + (TemplateType::Task, "test-project"), + ]; + + for (template_type, project) in &types_and_projects { + let creator = TicketCreator::new(&config); + let mut values = HashMap::new(); + values.insert("project".to_string(), project.to_string()); + creator + .create_ticket_headless(*template_type, &values) + .unwrap(); + } + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 3, "Queue should have 3 tickets"); + } +} + +// ============================================ +// Ticket Directory Initialization Tests +// ============================================ + +mod ticket_initialization { + use super::*; + + #[test] + fn test_queue_directories_exist_after_config_setup() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let tickets_path = config.tickets_path(); + assert!( + tickets_path.join("queue").exists(), + "queue directory should exist" + ); + assert!( + tickets_path.join("in-progress").exists(), + "in-progress directory should exist" + ); + assert!( + tickets_path.join("completed").exists(), + "completed directory should exist" + ); + assert!( + tickets_path.join("operator").exists(), + "operator directory should exist" + ); + } + + #[test] + fn test_queue_starts_empty() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let queue = Queue::new(&config).unwrap(); + assert!(queue.list_queue().unwrap().is_empty()); + assert!(queue.list_in_progress().unwrap().is_empty()); + assert!(queue.list_completed().unwrap().is_empty()); + } + + #[test] + fn test_claim_ticket_moves_to_in_progress() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create a ticket file in the queue + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + let ticket_filename = "20241225-1200-TASK-test-project-test.md"; + let queue_path = config.tickets_path().join("queue").join(ticket_filename); + std::fs::write(&queue_path, ticket_content).unwrap(); + + // Verify it's in the queue + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_queue().unwrap(); + assert_eq!(tickets.len(), 1); + + // Claim (move to in-progress) + queue.claim_ticket(&tickets[0]).unwrap(); + + // Verify moved + assert!(queue.list_queue().unwrap().is_empty()); + assert_eq!(queue.list_in_progress().unwrap().len(), 1); + } +} + +// ============================================ +// Auto-Launch Decision Logic Tests +// ============================================ + +mod auto_launch_logic { + use super::*; + + fn can_auto_launch(config: &Config, state: &State, queue: &Queue) -> bool { + if state.paused { + return false; + } + let running = state.running_agents().len(); + let max = config.effective_max_agents(); + if running >= max { + return false; + } + let tickets = queue.list_by_priority().unwrap_or_default(); + if tickets.is_empty() { + return false; + } + // Check per-repo cap for the top ticket's project + let top = &tickets[0]; + let project_count = state.project_agent_count(&top.project); + let max_per_repo = config.effective_max_agents_per_repo(); + project_count < max_per_repo + } + + #[test] + fn test_auto_launch_blocked_when_paused() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state.set_paused(true).unwrap(); + let state = State::load(&config).unwrap(); + + // Add a ticket to queue + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-test.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch when paused" + ); + } + + #[test] + fn test_auto_launch_blocked_when_queue_empty() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let state = State::load(&config).unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch with empty queue" + ); + } + + #[test] + fn test_auto_launch_blocked_at_max_agents() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_parallel = 1; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + let state = State::load(&config).unwrap(); + + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-launch.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch when at max agents" + ); + } + + #[test] + fn test_auto_launch_allowed_when_conditions_met() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + let state = State::load(&config).unwrap(); + + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-go.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + can_auto_launch(&config, &state, &queue), + "Should auto-launch when not paused, under capacity, with tickets" + ); + } + + #[test] + fn test_auto_launch_blocked_when_project_at_per_repo_cap() { + let temp_dir = TempDir::new().unwrap(); + let mut config = make_test_config(&temp_dir); + config.agents.max_parallel = 5; + config.agents.max_agents_per_repo = 1; + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-existing".to_string(), + "TASK".to_string(), + "testproject".to_string(), + false, + ) + .unwrap(); + let state = State::load(&config).unwrap(); + + // Project name in filename must be [a-z0-9]+ (no hyphens) to match parser regex + let ticket_content = "---\npriority: P2-medium\n---\n# Test\n\nContent\n"; + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-testproject-blocked.md"), + ticket_content, + ) + .unwrap(); + let queue = Queue::new(&config).unwrap(); + + assert!( + !can_auto_launch(&config, &state, &queue), + "Should not auto-launch when project is at per-repo capacity" + ); + } +} + +// ============================================ +// Status Action Tests +// ============================================ + +mod status_actions { + use crate::ui::status_panel::StatusAction; + + #[test] + fn test_status_action_none_is_noop() { + let action = StatusAction::None; + assert!(matches!(action, StatusAction::None)); + } + + #[test] + fn test_status_action_toggle_section_carries_id() { + use crate::ui::status_panel::SectionId; + let action = StatusAction::ToggleSection(SectionId::Configuration); + if let StatusAction::ToggleSection(id) = action { + assert!(matches!(id, SectionId::Configuration)); + } else { + panic!("Expected ToggleSection"); + } + } + + #[test] + fn test_open_in_browser_constructs_url() { + let port = 8080; + let url = format!("http://localhost:{port}/swagger-ui/"); + assert_eq!(url, "http://localhost:8080/swagger-ui/"); + } +} + +// ============================================ +// Queue Priority Ordering Tests +// ============================================ + +mod queue_priority { + use super::*; + + #[test] + fn test_priority_ordering_inv_before_feat() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + // Create tickets with different types + // INV should sort before FEAT per priority_order config + let feat_content = "---\npriority: P2-medium\n---\n# Feature\n\nContent\n"; + let inv_content = "---\npriority: P0-critical\n---\n# Investigation\n\nContent\n"; + + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-FEAT-test-project-feat.md"), + feat_content, + ) + .unwrap(); + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1201-INV-test-project-inv.md"), + inv_content, + ) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 2); + // INV has higher priority (lower index) in default priority_order + let first_type = &tickets[0].ticket_type; + let second_type = &tickets[1].ticket_type; + + let first_idx = config.priority_index(first_type); + let second_idx = config.priority_index(second_type); + assert!( + first_idx <= second_idx, + "First ticket type '{first_type}' (idx {first_idx}) should have equal or higher priority than '{second_type}' (idx {second_idx})" + ); + } + + #[test] + fn test_same_type_tickets_sorted_fifo_by_timestamp() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let content = "---\npriority: P2-medium\n---\n# Task\n\nContent\n"; + + // Earlier timestamp first + std::fs::write( + config + .tickets_path() + .join("queue/20241225-0800-TASK-test-project-early.md"), + content, + ) + .unwrap(); + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1400-TASK-test-project-late.md"), + content, + ) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 2); + assert!( + tickets[0].timestamp < tickets[1].timestamp, + "Earlier timestamp should sort first: {} vs {}", + tickets[0].timestamp, + tickets[1].timestamp + ); + } + + #[test] + fn test_unknown_ticket_type_sorts_last() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let task_content = "---\npriority: P2-medium\n---\n# Task\n\nContent\n"; + let unknown_content = "---\npriority: P2-medium\n---\n# Custom\n\nContent\n"; + + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-TASK-test-project-task.md"), + task_content, + ) + .unwrap(); + std::fs::write( + config + .tickets_path() + .join("queue/20241225-1200-CUSTOM-test-project-custom.md"), + unknown_content, + ) + .unwrap(); + + let queue = Queue::new(&config).unwrap(); + let tickets = queue.list_by_priority().unwrap(); + + assert_eq!(tickets.len(), 2); + // TASK is in priority_order, CUSTOM is not (sorts to usize::MAX) + assert_eq!( + tickets[0].ticket_type, "TASK", + "Known type should sort before unknown" + ); + } +} + +// ============================================ +// Agent State Lifecycle Tests +// ============================================ + +mod agent_lifecycle { + use super::*; + + #[test] + fn test_agent_added_with_correct_metadata() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "FEAT-042".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + let agent = state + .agents + .iter() + .find(|a| a.id == agent_id) + .expect("Agent should exist"); + + assert_eq!(agent.ticket_id, "FEAT-042"); + assert_eq!(agent.ticket_type, "FEAT"); + assert_eq!(agent.project, "test-project"); + } + + #[test] + fn test_multiple_agents_tracked_independently() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let id1 = state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "project-a".to_string(), + false, + ) + .unwrap(); + let id2 = state + .add_agent( + "FEAT-002".to_string(), + "FEAT".to_string(), + "project-b".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.running_agents().len(), 2); + assert_ne!(id1, id2, "Agent IDs should be unique"); + } + + #[test] + fn test_project_agent_count_tracks_per_project() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + state + .add_agent( + "TASK-001".to_string(), + "TASK".to_string(), + "project-a".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "TASK-002".to_string(), + "TASK".to_string(), + "project-a".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "TASK-003".to_string(), + "TASK".to_string(), + "project-b".to_string(), + false, + ) + .unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.project_agent_count("project-a"), 2); + assert_eq!(state.project_agent_count("project-b"), 1); + assert_eq!(state.project_agent_count("project-c"), 0); + } + + #[test] + fn test_agent_session_update_and_lookup() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "TASK-session".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state + .update_agent_session(&agent_id, "op-TASK-session") + .unwrap(); + + let state = State::load(&config).unwrap(); + let agent = state.agent_by_session("op-TASK-session"); + assert!(agent.is_some(), "Should find agent by session name"); + assert_eq!(agent.unwrap().ticket_id, "TASK-session"); + } + + #[test] + fn test_remove_agent_by_session_cleans_state() { + let temp_dir = TempDir::new().unwrap(); + let config = make_test_config(&temp_dir); + + let mut state = State::load(&config).unwrap(); + let agent_id = state + .add_agent( + "TASK-remove".to_string(), + "TASK".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .update_agent_session(&agent_id, "op-TASK-remove") + .unwrap(); + + assert_eq!(state.running_agents().len(), 1); + + let mut state = State::load(&config).unwrap(); + state.remove_agent_by_session("op-TASK-remove").unwrap(); + + let state = State::load(&config).unwrap(); + assert_eq!(state.running_agents().len(), 0); + assert!(state.agent_by_session("op-TASK-remove").is_none()); + } +} diff --git a/src/app/tickets.rs b/src/app/tickets.rs index f4797ece..fa3d1cd3 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -3,7 +3,6 @@ use std::fs; use crate::agents::{generate_status_script, generate_tmux_conf}; use crate::agents::{AgentTicketCreator, AssessTicketCreator}; -use crate::backstage::scaffold::{BackstageScaffold, ScaffoldOptions}; use crate::queue::TicketCreator; use crate::setup::filter_schema_fields; use crate::state::State; @@ -171,26 +170,6 @@ impl App { } } - // Generate Backstage scaffold - let backstage_path = self.config.backstage_path(); - if !BackstageScaffold::exists(&backstage_path) { - let options = ScaffoldOptions::from_config(&self.config); - let scaffold = BackstageScaffold::new(backstage_path, options); - match scaffold.generate() { - Ok(result) => { - tracing::info!( - created = result.created.len(), - skipped = result.skipped.len(), - "Generated Backstage scaffold: {}", - result.summary() - ); - } - Err(e) => { - tracing::warn!("Failed to generate Backstage scaffold: {}", e); - } - } - } - Ok(()) } @@ -297,7 +276,7 @@ impl App { return Ok(()); } - // Create ASSESS ticket for Backstage catalog assessment + // Create ASSESS ticket for catalog assessment let ticket_result = AssessTicketCreator::create_assess_ticket( &result.project_path, &result.project, diff --git a/src/backstage/branding.rs b/src/backstage/branding.rs deleted file mode 100644 index 920bb156..00000000 --- a/src/backstage/branding.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Static branding defaults for Backstage scaffold. -//! -//! Provides default branding configuration and assets. Users can edit -//! generated files manually after scaffold to customize branding. - -#![allow(dead_code)] - -use serde::{Deserialize, Serialize}; - -/// Default branding configuration for Backstage app-config. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BrandingDefaults { - /// Portal title displayed in header - pub title: String, - /// Subtitle shown below title - pub subtitle: String, - /// Primary brand color (hex) - pub primary_color: String, - /// Secondary brand color (hex) - pub secondary_color: String, - /// Optional logo path relative to branding directory - pub logo_path: Option, - /// Optional favicon path relative to branding directory - pub favicon_path: Option, -} - -impl Default for BrandingDefaults { - fn default() -> Self { - Self { - title: "Developer Portal".to_string(), - subtitle: "Powered by Backstage".to_string(), - primary_color: "#0052CC".to_string(), // Backstage blue - secondary_color: "#172B4D".to_string(), - logo_path: Some("logo.svg".to_string()), - favicon_path: None, - } - } -} - -impl BrandingDefaults { - /// Create branding with a custom portal name. - pub fn with_name(name: &str) -> Self { - Self { - title: name.to_string(), - ..Default::default() - } - } - - /// Generate the app section YAML for branding. - pub fn to_app_config_yaml(&self) -> String { - format!( - r#"app: - title: {} - branding: - theme: - light: - primaryColor: "{}" - secondaryColor: "{}" - dark: - primaryColor: "{}" - secondaryColor: "{}""#, - self.title, - self.primary_color, - self.secondary_color, - self.primary_color, - self.secondary_color - ) - } -} - -/// Static branding assets embedded in the binary. -pub struct BrandingAssets; - -impl BrandingAssets { - /// Default logo SVG content - a simple developer portal icon. - pub fn default_logo_svg() -> &'static str { - r##" - - DP -"## - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_branding_defaults() { - let branding = BrandingDefaults::default(); - assert_eq!(branding.title, "Developer Portal"); - assert!(branding.primary_color.starts_with('#')); - assert!(branding.secondary_color.starts_with('#')); - } - - #[test] - fn test_branding_with_name() { - let branding = BrandingDefaults::with_name("GBQR Portal"); - assert_eq!(branding.title, "GBQR Portal"); - assert_eq!(branding.subtitle, "Powered by Backstage"); - } - - #[test] - fn test_app_config_yaml() { - let branding = BrandingDefaults::default(); - let yaml = branding.to_app_config_yaml(); - assert!(yaml.contains("Developer Portal")); - assert!(yaml.contains("#0052CC")); - assert!(yaml.contains("primaryColor")); - } - - #[test] - fn test_default_logo_svg() { - let svg = BrandingAssets::default_logo_svg(); - assert!(svg.contains("")); - assert!(svg.contains("#0052CC")); - } -} diff --git a/src/backstage/mod.rs b/src/backstage/mod.rs deleted file mode 100644 index 18eceb89..00000000 --- a/src/backstage/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Backstage integration for project cataloging and taxonomy management. -//! -//! This module provides: -//! - A 24-Kind project taxonomy (source of truth in `taxonomy.toml`) -//! - catalog-info.yaml generation for Backstage catalog -//! - Backstage scaffold generator for local deployment -//! - Static branding defaults -//! - Project analysis and Kind detection -//! - Bun-based server lifecycle management -//! - Compiled binary runtime management - -pub mod analyzer; -pub mod branding; -pub mod runtime; -pub mod scaffold; -pub mod server; -pub mod taxonomy; - -// Re-exports for TUI integration and testing (used by main.rs binary) -#[allow(unused_imports)] -pub use server::{BackstageServer, ServerStatus}; - -// Runtime management re-exports (public API for future use) -#[allow(unused_imports)] -pub use runtime::{BackstageRuntime, Platform, RuntimeError}; - -// Additional re-exports for future use -#[allow(unused_imports)] -pub use server::{ - copy_default_logo, generate_branding_config, BackstageError, BunClient, BunVersion, - RuntimeBinaryClient, SystemBunClient, -}; diff --git a/src/backstage/runtime.rs b/src/backstage/runtime.rs deleted file mode 100644 index 65364650..00000000 --- a/src/backstage/runtime.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Backstage runtime binary management. -//! -//! Downloads and manages the pre-compiled backstage-server binary -//! for the current platform. - -use std::fs; -use std::path::PathBuf; - -/// Supported platforms for backstage-server binary. -/// -/// Variants are constructed via compile-time cfg attributes in `Platform::current()`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] // Variants are selected at compile time via cfg attributes -pub enum Platform { - DarwinArm64, - DarwinX64, - LinuxX64, - LinuxArm64, -} - -impl Platform { - /// Detect the current platform at compile time. - pub fn current() -> Option { - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - return Some(Platform::DarwinArm64); - - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - return Some(Platform::DarwinX64); - - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - return Some(Platform::LinuxX64); - - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - return Some(Platform::LinuxArm64); - - #[cfg(not(any( - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "macos", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - )))] - return None; - } - - /// Get the Bun target identifier for this platform. - pub fn bun_target(&self) -> &'static str { - match self { - Platform::DarwinArm64 => "bun-darwin-arm64", - Platform::DarwinX64 => "bun-darwin-x64", - Platform::LinuxX64 => "bun-linux-x64", - Platform::LinuxArm64 => "bun-linux-arm64", - } - } - - /// Get a human-readable name for this platform. - pub fn display_name(&self) -> &'static str { - match self { - Platform::DarwinArm64 => "macOS ARM64", - Platform::DarwinX64 => "macOS x64", - Platform::LinuxX64 => "Linux x64", - Platform::LinuxArm64 => "Linux ARM64", - } - } -} - -/// Manages the backstage-server binary lifecycle. -pub struct BackstageRuntime { - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - platform: Platform, -} - -/// Error types for runtime operations. -#[derive(Debug, thiserror::Error)] -pub enum RuntimeError { - #[error("Unsupported platform - backstage-server is not available for this OS/architecture")] - UnsupportedPlatform, - - #[error("Failed to download binary: {0}")] - DownloadFailed(String), - - #[error("Failed to write binary: {0}")] - WriteFailed(String), - - #[error("Binary not found at {0}")] - #[allow(dead_code)] // Reserved for future validation - BinaryNotFound(PathBuf), - - #[error("Local file not found: {0}")] - LocalFileNotFound(PathBuf), - - #[error("Local file is not executable: {0}")] - #[allow(dead_code)] // Only used on Unix platforms - LocalFileNotExecutable(PathBuf), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), -} - -impl BackstageRuntime { - /// Create a new runtime manager. - /// - /// # Arguments - /// * `state_path` - Directory to store the binary (e.g., .tickets/operator) - /// * `release_url` - Base URL for downloading binaries - /// * `local_binary_path` - Optional local path to binary (takes precedence over URL) - pub fn new( - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - ) -> Result { - let platform = Platform::current().ok_or(RuntimeError::UnsupportedPlatform)?; - - Ok(Self { - state_path, - release_url, - local_binary_path: local_binary_path.map(PathBuf::from), - platform, - }) - } - - /// Get the path where the binary should be stored. - pub fn binary_path(&self) -> PathBuf { - self.state_path.join("bin").join("backstage-server") - } - - /// Check if the binary exists. - pub fn binary_exists(&self) -> bool { - self.binary_path().exists() - } - - /// Get the current platform. - pub fn platform(&self) -> Platform { - self.platform - } - - /// Ensure the binary is available, downloading if necessary. - /// - /// Returns the path to the binary. - pub fn ensure_binary(&self) -> Result { - let binary_path = self.binary_path(); - - if binary_path.exists() { - tracing::debug!( - "Backstage binary already exists at {}", - binary_path.display() - ); - return Ok(binary_path); - } - - self.download_binary()?; - Ok(binary_path) - } - - /// Download or copy the binary for the current platform. - /// - /// If `local_binary_path` is set, copies from local path. - /// Otherwise, downloads from `release_url` with platform suffix appended. - fn download_binary(&self) -> Result<(), RuntimeError> { - // Create bin directory - let bin_dir = self.state_path.join("bin"); - fs::create_dir_all(&bin_dir)?; - - let binary_path = self.binary_path(); - - if let Some(ref local_path) = self.local_binary_path { - self.copy_local_binary(local_path, &binary_path)?; - } else { - self.download_remote_binary(&binary_path)?; - } - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&binary_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&binary_path, perms)?; - } - - Ok(()) - } - - /// Copy binary from a local path. - fn copy_local_binary( - &self, - source_path: &PathBuf, - dest_path: &PathBuf, - ) -> Result<(), RuntimeError> { - // Verify source exists - if !source_path.exists() { - return Err(RuntimeError::LocalFileNotFound(source_path.clone())); - } - - // Verify source is executable (on Unix) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = fs::metadata(source_path)?.permissions(); - if perms.mode() & 0o111 == 0 { - return Err(RuntimeError::LocalFileNotExecutable(source_path.clone())); - } - } - - tracing::info!( - "Copying local backstage-server from {} to {}", - source_path.display(), - dest_path.display() - ); - - // Copy the file (not symlink) - fs::copy(source_path, dest_path).map_err(|e| RuntimeError::WriteFailed(e.to_string()))?; - - let bytes = fs::metadata(dest_path)?.len(); - tracing::info!( - "Copied backstage-server ({} bytes) to {}", - bytes, - dest_path.display() - ); - - Ok(()) - } - - /// Download binary from a remote https:// URL. - /// - /// Appends platform suffix to the URL (e.g., /backstage-server-bun-darwin-arm64). - fn download_remote_binary(&self, dest_path: &PathBuf) -> Result<(), RuntimeError> { - let url = format!( - "{}/backstage-server-{}", - self.release_url, - self.platform.bun_target() - ); - - tracing::info!( - "Downloading backstage-server for {} from {}", - self.platform.display_name(), - url - ); - - // Download using reqwest blocking client - let response = reqwest::blocking::get(&url) - .map_err(|e| RuntimeError::DownloadFailed(e.to_string()))?; - - if !response.status().is_success() { - return Err(RuntimeError::DownloadFailed(format!( - "HTTP {}: {}", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ))); - } - - let bytes = response - .bytes() - .map_err(|e| RuntimeError::DownloadFailed(e.to_string()))?; - - // Write binary - fs::write(dest_path, &bytes).map_err(|e| RuntimeError::WriteFailed(e.to_string()))?; - - tracing::info!( - "Downloaded backstage-server ({} bytes) to {}", - bytes.len(), - dest_path.display() - ); - - Ok(()) - } - - /// Remove the downloaded binary. - #[allow(dead_code)] // Reserved for cleanup/maintenance operations - pub fn remove_binary(&self) -> Result<(), RuntimeError> { - let binary_path = self.binary_path(); - if binary_path.exists() { - fs::remove_file(&binary_path)?; - tracing::info!("Removed backstage-server binary"); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_platform_current() { - // This should return Some on supported platforms - let platform = Platform::current(); - #[cfg(any( - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "macos", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - ))] - assert!(platform.is_some()); - } - - #[test] - fn test_platform_bun_target() { - assert_eq!(Platform::DarwinArm64.bun_target(), "bun-darwin-arm64"); - assert_eq!(Platform::DarwinX64.bun_target(), "bun-darwin-x64"); - assert_eq!(Platform::LinuxX64.bun_target(), "bun-linux-x64"); - assert_eq!(Platform::LinuxArm64.bun_target(), "bun-linux-arm64"); - } - - #[test] - fn test_platform_display_name() { - assert_eq!(Platform::DarwinArm64.display_name(), "macOS ARM64"); - assert_eq!(Platform::LinuxX64.display_name(), "Linux x64"); - } - - #[test] - fn test_runtime_binary_path() { - let runtime = BackstageRuntime::new( - PathBuf::from("/tmp/test-state"), - "https://example.com/releases".to_string(), - None, - ); - - if let Ok(runtime) = runtime { - let path = runtime.binary_path(); - assert_eq!(path, PathBuf::from("/tmp/test-state/bin/backstage-server")); - } - } - - #[test] - fn test_runtime_with_local_path() { - let runtime = BackstageRuntime::new( - PathBuf::from("/tmp/test-state"), - "https://example.com/releases".to_string(), - Some("/path/to/local/binary".to_string()), - ); - - if let Ok(runtime) = runtime { - assert_eq!( - runtime.local_binary_path, - Some(PathBuf::from("/path/to/local/binary")) - ); - } - } - - #[test] - fn test_runtime_without_local_path() { - let runtime = BackstageRuntime::new( - PathBuf::from("/tmp/test-state"), - "https://example.com/releases".to_string(), - None, - ); - - if let Ok(runtime) = runtime { - assert!(runtime.local_binary_path.is_none()); - } - } - - #[test] - fn test_runtime_unsupported_platform() { - // This test verifies the error type exists - let err = RuntimeError::UnsupportedPlatform; - assert!(err.to_string().contains("Unsupported platform")); - } - - #[test] - fn test_local_file_not_found_error() { - let err = RuntimeError::LocalFileNotFound(PathBuf::from("/nonexistent/path")); - assert!(err.to_string().contains("Local file not found")); - } - - #[test] - fn test_local_file_not_executable_error() { - let err = RuntimeError::LocalFileNotExecutable(PathBuf::from("/some/path")); - assert!(err.to_string().contains("not executable")); - } -} diff --git a/src/backstage/scaffold.rs b/src/backstage/scaffold.rs deleted file mode 100644 index 8fb7105e..00000000 --- a/src/backstage/scaffold.rs +++ /dev/null @@ -1,1180 +0,0 @@ -//! Backstage scaffold generator. -//! -//! Generates a minimal, Bun-compatible Backstage deployment including: -//! - package.json with workspace configuration -//! - app-config.yaml with guest auth and file:// catalog locations -//! - Docker files for containerized deployment -//! - Minimal app and backend packages - -#![allow(dead_code)] - -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use super::branding::{BrandingAssets, BrandingDefaults}; -use super::taxonomy::Taxonomy; -use crate::config::Config; - -/// Result of scaffold generation. -#[derive(Debug, Clone)] -pub struct ScaffoldResult { - /// Files that were created. - pub created: Vec, - /// Files that were skipped (already exist). - pub skipped: Vec, - /// Errors encountered (path, error message). - pub errors: Vec<(PathBuf, String)>, - /// Output directory for the scaffold. - pub output_dir: PathBuf, -} - -impl ScaffoldResult { - /// Create a new empty result. - pub fn new(output_dir: PathBuf) -> Self { - Self { - created: Vec::new(), - skipped: Vec::new(), - errors: Vec::new(), - output_dir, - } - } - - /// Check if scaffold completed without errors. - pub fn is_success(&self) -> bool { - self.errors.is_empty() - } - - /// Generate a summary string. - pub fn summary(&self) -> String { - format!( - "{} created, {} skipped, {} errors", - self.created.len(), - self.skipped.len(), - self.errors.len() - ) - } -} - -/// Configuration for scaffold generation. -#[derive(Debug, Clone)] -pub struct ScaffoldOptions { - /// Force overwrite existing files. - pub force: bool, - /// Custom branding name (e.g., "My Company Portal"). - pub branding_name: Option, - /// Generate Docker files. - pub include_docker: bool, - /// Port for Backstage server. - pub port: u16, - /// Projects directory to scan for catalog locations. - pub projects_dir: PathBuf, -} - -impl Default for ScaffoldOptions { - fn default() -> Self { - Self { - force: false, - branding_name: None, - include_docker: true, - port: 7007, - projects_dir: PathBuf::from("."), - } - } -} - -impl ScaffoldOptions { - /// Create options from operator config. - pub fn from_config(config: &Config) -> Self { - Self { - force: false, - branding_name: None, - include_docker: true, - port: config.backstage.port, - projects_dir: config.projects_path(), - } - } - - /// Get branding configuration. - pub fn branding(&self) -> BrandingDefaults { - match &self.branding_name { - Some(name) => BrandingDefaults::with_name(name), - None => BrandingDefaults::default(), - } - } -} - -/// A single file to be scaffolded. -pub trait ScaffoldFile { - /// Relative path within the backstage output directory. - fn path(&self) -> &'static str; - - /// Generate the file content. - fn generate(&self, options: &ScaffoldOptions) -> Result; - - /// Whether this file should be created (allows conditional generation). - fn should_create(&self, _options: &ScaffoldOptions) -> bool { - true - } -} - -// ============================================================================= -// FILE GENERATORS -// ============================================================================= - -/// Generates package.json for Bun/Node workspace. -pub struct PackageJsonGenerator; - -impl ScaffoldFile for PackageJsonGenerator { - fn path(&self) -> &'static str { - "package.json" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - let name = options.branding_name.as_ref().map_or_else( - || "backstage-local".to_string(), - |n| n.to_lowercase().replace(' ', "-"), - ); - - Ok(format!( - r#"{{ - "name": "{name}", - "version": "1.0.0", - "private": true, - "engines": {{ - "node": ">=18" - }}, - "scripts": {{ - "dev": "concurrently \"yarn start\" \"yarn start-backend\"", - "start": "yarn workspace app start", - "start-backend": "yarn workspace backend start", - "build": "backstage-cli repo build --all", - "build:backend": "yarn workspace backend build" - }}, - "workspaces": {{ - "packages": [ - "packages/*", - "packages/plugins/*" - ] - }}, - "devDependencies": {{ - "@backstage/cli": "^0.27.0", - "concurrently": "^8.0.0" - }}, - "resolutions": {{ - "@types/react": "^18" - }} -}}"# - )) - } -} - -/// Generates app-config.yaml with guest auth and taxonomy kinds. -pub struct AppConfigGenerator; - -impl ScaffoldFile for AppConfigGenerator { - fn path(&self) -> &'static str { - "app-config.yaml" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - let taxonomy = Taxonomy::load(); - let branding = options.branding(); - - // Generate allowed types from taxonomy (all 24 kinds) - let kind_types: String = taxonomy - .kinds - .iter() - .map(|k| format!(" - {}", k.key)) - .collect::>() - .join("\n"); - - Ok(format!( - r"app: - title: {title} - baseUrl: http://localhost:{port} - -organization: - name: {title} - -backend: - baseUrl: http://localhost:{port} - listen: - port: {port} - database: - client: better-sqlite3 - connection: ':memory:' - -auth: - providers: - guest: - dangerouslyAllowOutsideDevelopment: true - -# Proxy configuration for Operator REST API -proxy: - '/operator': - target: 'http://localhost:7008' - changeOrigin: true - -catalog: - rules: - - allow: - - Component - - API - - Resource - - System - - Domain - - Location - - Template - - Group - - User - # Extended component types from operator taxonomy - # See: https://backstage.io/docs/features/software-catalog/extending-the-model - processors: - catalogModuleKinds: - allowedTypes: -{kind_types} - locations: - # Scan workspace for catalog-info.yaml files - - type: file - target: ../../**/catalog-info.yaml - rules: - - allow: [Component, API, Resource, System, Domain] -", - title = branding.title, - port = options.port, - kind_types = kind_types, - )) - } -} - -/// Generates app-config.production.yaml for Docker deployment. -pub struct AppConfigProductionGenerator; - -impl ScaffoldFile for AppConfigProductionGenerator { - fn path(&self) -> &'static str { - "app-config.production.yaml" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - Ok(format!( - r"app: - baseUrl: ${{BACKSTAGE_BASE_URL:-http://localhost:{port}}} - -backend: - baseUrl: ${{BACKSTAGE_BACKEND_URL:-http://localhost:{port}}} - listen: - port: {port} - database: - client: better-sqlite3 - connection: /app/data/backstage.sqlite - -# Production should use proper auth, not guest -# Uncomment and configure for production use: -# auth: -# providers: -# github: -# development: -# clientId: ${{GITHUB_CLIENT_ID}} -# clientSecret: ${{GITHUB_CLIENT_SECRET}} -", - port = options.port - )) - } -} - -/// Generates Dockerfile for containerized deployment. -pub struct DockerfileGenerator; - -impl ScaffoldFile for DockerfileGenerator { - fn path(&self) -> &'static str { - "Dockerfile" - } - - fn should_create(&self, options: &ScaffoldOptions) -> bool { - options.include_docker - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"# Stage 1: Build -FROM node:20-bookworm-slim AS builder - -WORKDIR /app - -# Install dependencies -COPY package.json yarn.lock ./ -RUN corepack enable && yarn install --immutable - -# Copy source and build -COPY . . -RUN yarn build - -# Stage 2: Runtime -FROM node:20-bookworm-slim - -WORKDIR /app - -# Copy built artifacts -COPY --from=builder /app/packages/backend/dist /app/dist -COPY --from=builder /app/node_modules /app/node_modules -COPY app-config.yaml app-config.production.yaml ./ - -# Create data directory for SQLite -RUN mkdir -p /app/data && chown node:node /app/data - -USER node - -EXPOSE 7007 - -CMD ["node", "dist/index.cjs"] -"# - .to_string()) - } -} - -/// Generates docker-compose.yaml for local Docker orchestration. -pub struct DockerComposeGenerator; - -impl ScaffoldFile for DockerComposeGenerator { - fn path(&self) -> &'static str { - "docker-compose.yaml" - } - - fn should_create(&self, options: &ScaffoldOptions) -> bool { - options.include_docker - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - Ok(format!( - r#"version: '3.8' - -services: - backstage: - build: . - ports: - - "{port}:{port}" - environment: - - BACKSTAGE_BASE_URL=http://localhost:{port} - - BACKSTAGE_BACKEND_URL=http://localhost:{port} - volumes: - - backstage-data:/app/data - # Mount workspace for catalog scanning (read-only) - - ../..:/workspace:ro - restart: unless-stopped - -volumes: - backstage-data: -"#, - port = options.port - )) - } -} - -/// Generates README.md with usage instructions. -pub struct ReadmeGenerator; - -impl ScaffoldFile for ReadmeGenerator { - fn path(&self) -> &'static str { - "README.md" - } - - fn generate(&self, options: &ScaffoldOptions) -> Result { - let branding = options.branding(); - Ok(format!( - r"# {title} - -A local Backstage deployment generated by operator. - -## Quick Start - -### Local Development (Bun/Yarn) - -```bash -# Install dependencies -yarn install - -# Start dev server -yarn dev -``` - -Open http://localhost:{port} in your browser. - -### Docker - -```bash -# Build and run -docker-compose up --build - -# Or build only -docker build -t backstage-local . -docker run -p {port}:{port} backstage-local -``` - -## Configuration - -- `app-config.yaml` - Main configuration (guest auth, local catalog) -- `app-config.production.yaml` - Production overrides (Docker) - -## Catalog - -The catalog scans `../../**/catalog-info.yaml` for components. -Create a `catalog-info.yaml` in your projects to register them. - -## Branding - -Customize the portal by editing: -- `branding/logo.svg` - Portal logo -- `app-config.yaml` - Title and colors - -## Generated by - -This scaffold was generated by [operator](https://github.com/untra/operator). -", - title = branding.title, - port = options.port - )) - } -} - -/// Generates packages/app/package.json for frontend. -pub struct AppPackageGenerator; - -impl ScaffoldFile for AppPackageGenerator { - fn path(&self) -> &'static str { - "packages/app/package.json" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"{ - "name": "app", - "version": "0.0.0", - "private": true, - "bundled": true, - "scripts": { - "start": "backstage-cli package start" - }, - "dependencies": { - "@backstage/app-defaults": "^1.5.0", - "@backstage/core-app-api": "^1.14.0", - "@backstage/core-components": "^0.14.0", - "@backstage/core-plugin-api": "^1.9.0", - "@backstage/plugin-catalog": "^1.21.0", - "@backstage/plugin-catalog-graph": "^0.4.0", - "@backstage/plugin-catalog-import": "^0.12.0", - "@backstage/plugin-catalog-react": "^1.12.0", - "@backstage/theme": "^0.5.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.27.0" - } -} -"# - .to_string()) - } -} - -/// Generates packages/backend/package.json for backend. -pub struct BackendPackageGenerator; - -impl ScaffoldFile for BackendPackageGenerator { - fn path(&self) -> &'static str { - "packages/backend/package.json" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"{ - "name": "backend", - "version": "0.0.0", - "private": true, - "main": "dist/index.cjs.js", - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build" - }, - "dependencies": { - "@backstage/backend-defaults": "^0.4.0", - "@backstage/plugin-app-backend": "^0.3.0", - "@backstage/plugin-auth-backend": "^0.22.0", - "@backstage/plugin-auth-backend-module-guest-provider": "^0.1.0", - "@backstage/plugin-catalog-backend": "^1.24.0", - "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.1.0", - "@backstage/plugin-proxy-backend": "^0.5.0", - "better-sqlite3": "^9.0.0" - }, - "devDependencies": { - "@backstage/cli": "^0.27.0" - } -} -"# - .to_string()) - } -} - -/// Generates branding/logo.svg with default logo. -pub struct LogoGenerator; - -impl ScaffoldFile for LogoGenerator { - fn path(&self) -> &'static str { - "branding/logo.svg" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(BrandingAssets::default_logo_svg().to_string()) - } -} - -/// Generates packages/app/src/App.tsx - React frontend entry component. -pub struct AppTsxGenerator; - -impl ScaffoldFile for AppTsxGenerator { - fn path(&self) -> &'static str { - "packages/app/src/App.tsx" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"/** - * Operator Backstage Frontend - * - * Catalog browser with issue types management. - */ - -import React from 'react'; -import { Route } from 'react-router-dom'; -import { createApp, FlatRoutes } from '@backstage/app-defaults'; -import { catalogPlugin } from '@backstage/plugin-catalog'; -import { - issueTypesPlugin, - IssueTypesPage, - IssueTypeDetailPage, - IssueTypeFormPage, - CollectionsPage, -} from '@operator/plugin-issuetypes'; - -const app = createApp({ - plugins: [catalogPlugin, issueTypesPlugin], -}); - -const AppProvider = app.getProvider(); -const AppRouter = app.getRouter(); - -const routes = ( - - }> - } /> - } /> - } /> - } /> - - -); - -export default function App() { - return ( - - - {routes} - - - ); -} -"# - .to_string()) - } -} - -/// Generates packages/app/src/index.tsx - React DOM entry point. -pub struct AppIndexTsxGenerator; - -impl ScaffoldFile for AppIndexTsxGenerator { - fn path(&self) -> &'static str { - "packages/app/src/index.tsx" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r"/** - * Operator Backstage Frontend Entry Point - */ - -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; - -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); - -root.render( - - - -); -" - .to_string()) - } -} - -/// Generates packages/backend/src/index.ts - Bun backend entry point. -pub struct BackendIndexTsGenerator; - -impl ScaffoldFile for BackendIndexTsGenerator { - fn path(&self) -> &'static str { - "packages/backend/src/index.ts" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r"/** - * Operator Backstage Backend - * - * Minimal Bun-based backend for local catalog browsing. - * Includes proxy for Operator API integration. - */ - -import { createBackend } from '@backstage/backend-defaults'; - -const backend = createBackend(); - -// Core plugins -backend.add(import('@backstage/plugin-app-backend')); -backend.add(import('@backstage/plugin-catalog-backend')); - -// Proxy for Operator REST API -backend.add(import('@backstage/plugin-proxy-backend')); - -// Start the backend -backend.start(); -" - .to_string()) - } -} - -/// Generates tsconfig.json for TypeScript configuration. -pub struct TsConfigGenerator; - -impl ScaffoldFile for TsConfigGenerator { - fn path(&self) -> &'static str { - "tsconfig.json" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./packages", - "baseUrl": ".", - "paths": { - "backend": ["packages/backend/src"], - "app": ["packages/app/src"] - } - }, - "include": ["packages/*/src/**/*"], - "exclude": ["node_modules", "dist", "**/dist"] -} -"# - .to_string()) - } -} - -/// Generates bunfig.toml for Bun configuration. -pub struct BunfigGenerator; - -impl ScaffoldFile for BunfigGenerator { - fn path(&self) -> &'static str { - "bunfig.toml" - } - - fn generate(&self, _options: &ScaffoldOptions) -> Result { - Ok(r#"# Bun configuration for Operator Backstage -# https://bun.sh/docs/runtime/bunfig - -[install] -# Use Bun's native lockfile format -lockfile = "bun.lockb" - -# NPM registry -registry = "https://registry.npmjs.org/" - -# Save dependencies to package.json -save = true -"# - .to_string()) - } -} - -// ============================================================================= -// SCAFFOLD ORCHESTRATOR -// ============================================================================= - -/// Copy a directory recursively. -fn copy_dir_recursive(src: &Path, dst: &Path, force: bool) -> Result> { - let mut copied = Vec::new(); - - if !src.exists() { - return Ok(copied); - } - - fs::create_dir_all(dst)?; - - for entry in fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if src_path.is_dir() { - let sub_copied = copy_dir_recursive(&src_path, &dst_path, force)?; - copied.extend(sub_copied); - } else { - // Skip if exists and not forcing - if dst_path.exists() && !force { - continue; - } - fs::copy(&src_path, &dst_path)?; - copied.push(dst_path); - } - } - - Ok(copied) -} - -/// The main scaffold generator. -pub struct BackstageScaffold { - output_dir: PathBuf, - options: ScaffoldOptions, - /// Path to the operator source plugins directory (for copying) - plugins_source: Option, -} - -impl BackstageScaffold { - /// Create a new scaffold generator. - pub fn new(output_dir: PathBuf, options: ScaffoldOptions) -> Self { - Self { - output_dir, - options, - plugins_source: None, - } - } - - /// Create a new scaffold generator with plugin source path. - pub fn with_plugins_source( - output_dir: PathBuf, - options: ScaffoldOptions, - plugins_source: PathBuf, - ) -> Self { - Self { - output_dir, - options, - plugins_source: Some(plugins_source), - } - } - - /// Check if scaffold already exists. - pub fn exists(output_dir: &Path) -> bool { - output_dir.join("package.json").exists() - } - - /// Get all file generators. - fn generators(&self) -> Vec> { - vec![ - Box::new(PackageJsonGenerator), - Box::new(AppConfigGenerator), - Box::new(AppConfigProductionGenerator), - Box::new(DockerfileGenerator), - Box::new(DockerComposeGenerator), - Box::new(ReadmeGenerator), - Box::new(AppPackageGenerator), - Box::new(BackendPackageGenerator), - Box::new(LogoGenerator), - // TypeScript source files - Box::new(AppTsxGenerator), - Box::new(AppIndexTsxGenerator), - Box::new(BackendIndexTsGenerator), - Box::new(TsConfigGenerator), - Box::new(BunfigGenerator), - ] - } - - /// Copy plugins from source directory to scaffold output. - fn copy_plugins(&self, result: &mut ScaffoldResult) { - if let Some(plugins_source) = &self.plugins_source { - let plugins_dest = self.output_dir.join("packages/plugins"); - - match copy_dir_recursive(plugins_source, &plugins_dest, self.options.force) { - Ok(copied) => { - result.created.extend(copied); - } - Err(e) => { - result - .errors - .push((plugins_dest, format!("Failed to copy plugins: {e}"))); - } - } - } - } - - /// Generate all scaffold files. - pub fn generate(&self) -> Result { - let mut result = ScaffoldResult::new(self.output_dir.clone()); - - // Create output directory - fs::create_dir_all(&self.output_dir).with_context(|| { - format!("Failed to create directory: {}", self.output_dir.display()) - })?; - - for generator in self.generators() { - // Check if this file should be created - if !generator.should_create(&self.options) { - continue; - } - - let rel_path = generator.path(); - let full_path = self.output_dir.join(rel_path); - - // Check if file exists and skip if not forcing - if full_path.exists() && !self.options.force { - result.skipped.push(full_path); - continue; - } - - // Create parent directories - if let Some(parent) = full_path.parent() { - if let Err(e) = fs::create_dir_all(parent) { - result.errors.push(( - full_path.clone(), - format!("Failed to create directory: {e}"), - )); - continue; - } - } - - // Generate and write content - match generator.generate(&self.options) { - Ok(content) => { - if let Err(e) = fs::write(&full_path, content) { - result - .errors - .push((full_path, format!("Failed to write: {e}"))); - } else { - result.created.push(full_path); - } - } - Err(e) => { - result - .errors - .push((full_path, format!("Failed to generate: {e}"))); - } - } - } - - // Copy plugins directory if source is provided - self.copy_plugins(&mut result); - - Ok(result) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_scaffold_result_summary() { - let mut result = ScaffoldResult::new(PathBuf::from("/test")); - result.created.push(PathBuf::from("package.json")); - result.skipped.push(PathBuf::from("existing.yaml")); - assert!(result.summary().contains("1 created")); - assert!(result.summary().contains("1 skipped")); - assert!(result.is_success()); - } - - #[test] - fn test_scaffold_result_with_errors() { - let mut result = ScaffoldResult::new(PathBuf::from("/test")); - result - .errors - .push((PathBuf::from("bad.txt"), "error".to_string())); - assert!(!result.is_success()); - } - - #[test] - fn test_scaffold_options_default() { - let options = ScaffoldOptions::default(); - assert!(!options.force); - assert!(options.include_docker); - assert_eq!(options.port, 7007); - } - - #[test] - fn test_scaffold_options_branding() { - let options = ScaffoldOptions { - branding_name: Some("My Portal".to_string()), - ..Default::default() - }; - let branding = options.branding(); - assert_eq!(branding.title, "My Portal"); - } - - #[test] - fn test_package_json_generator() { - let gen = PackageJsonGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("\"name\": \"backstage-local\"")); - assert!(content.contains("backstage-cli")); - assert!(content.contains("workspaces")); - } - - #[test] - fn test_package_json_with_custom_name() { - let gen = PackageJsonGenerator; - let options = ScaffoldOptions { - branding_name: Some("My Company Portal".to_string()), - ..Default::default() - }; - let content = gen.generate(&options).unwrap(); - assert!(content.contains("\"name\": \"my-company-portal\"")); - } - - #[test] - fn test_app_config_includes_all_kinds() { - let gen = AppConfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - - // Verify all kinds are present (flexible - works with any number) - let taxonomy = Taxonomy::load(); - assert!(!taxonomy.kinds.is_empty(), "Should have kinds"); - for kind in &taxonomy.kinds { - assert!(content.contains(&kind.key), "Missing kind: {}", kind.key); - } - } - - #[test] - fn test_app_config_has_guest_auth() { - let gen = AppConfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("guest:")); - assert!(content.contains("dangerouslyAllowOutsideDevelopment: true")); - } - - #[test] - fn test_dockerfile_conditional_creation() { - let gen = DockerfileGenerator; - - let with_docker = ScaffoldOptions { - include_docker: true, - ..Default::default() - }; - assert!(gen.should_create(&with_docker)); - - let without_docker = ScaffoldOptions { - include_docker: false, - ..Default::default() - }; - assert!(!gen.should_create(&without_docker)); - } - - #[test] - fn test_dockerfile_content() { - let gen = DockerfileGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("FROM node:20-bookworm-slim")); - assert!(content.contains("EXPOSE 7007")); - } - - #[test] - fn test_docker_compose_uses_port() { - let gen = DockerComposeGenerator; - let options = ScaffoldOptions { - port: 8080, - ..Default::default() - }; - let content = gen.generate(&options).unwrap(); - assert!(content.contains("8080:8080")); - } - - #[test] - fn test_readme_generator() { - let gen = ReadmeGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("Developer Portal")); - assert!(content.contains("yarn dev")); - assert!(content.contains("docker-compose")); - } - - #[test] - fn test_logo_generator() { - let gen = LogoGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("")); - } - - #[test] - fn test_app_tsx_generator() { - let gen = AppTsxGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("import React from 'react'")); - assert!(content.contains("@backstage/app-defaults")); - assert!(content.contains("catalogPlugin")); - assert!(content.contains("export default function App()")); - } - - #[test] - fn test_app_index_tsx_generator() { - let gen = AppIndexTsxGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("ReactDOM.createRoot")); - assert!(content.contains("import App from './App'")); - assert!(content.contains("")); - } - - #[test] - fn test_backend_index_ts_generator() { - let gen = BackendIndexTsGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("@backstage/backend-defaults")); - assert!(content.contains("createBackend")); - assert!(content.contains("plugin-app-backend")); - assert!(content.contains("plugin-catalog-backend")); - assert!(content.contains("backend.start()")); - } - - #[test] - fn test_tsconfig_generator() { - let gen = TsConfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("\"target\": \"ES2022\"")); - assert!(content.contains("\"jsx\": \"react-jsx\"")); - assert!(content.contains("\"moduleResolution\": \"bundler\"")); - } - - #[test] - fn test_bunfig_generator() { - let gen = BunfigGenerator; - let content = gen.generate(&ScaffoldOptions::default()).unwrap(); - assert!(content.contains("[install]")); - assert!(content.contains("lockfile = \"bun.lockb\"")); - assert!(content.contains("registry.npmjs.org")); - } - - #[test] - fn test_scaffold_exists() { - let temp_dir = TempDir::new().unwrap(); - assert!(!BackstageScaffold::exists(temp_dir.path())); - - // Create package.json - fs::write(temp_dir.path().join("package.json"), "{}").unwrap(); - assert!(BackstageScaffold::exists(temp_dir.path())); - } - - #[test] - fn test_scaffold_generate() { - let temp_dir = TempDir::new().unwrap(); - let scaffold = - BackstageScaffold::new(temp_dir.path().to_path_buf(), ScaffoldOptions::default()); - - let result = scaffold.generate().unwrap(); - assert!(result.is_success()); - assert!(!result.created.is_empty()); - assert!(result.skipped.is_empty()); - - // Verify key files exist - assert!(temp_dir.path().join("package.json").exists()); - assert!(temp_dir.path().join("app-config.yaml").exists()); - assert!(temp_dir.path().join("Dockerfile").exists()); - assert!(temp_dir.path().join("docker-compose.yaml").exists()); - assert!(temp_dir.path().join("README.md").exists()); - assert!(temp_dir.path().join("packages/app/package.json").exists()); - assert!(temp_dir - .path() - .join("packages/backend/package.json") - .exists()); - assert!(temp_dir.path().join("branding/logo.svg").exists()); - - // Verify TypeScript source files exist - assert!(temp_dir.path().join("packages/app/src/App.tsx").exists()); - assert!(temp_dir.path().join("packages/app/src/index.tsx").exists()); - assert!(temp_dir - .path() - .join("packages/backend/src/index.ts") - .exists()); - assert!(temp_dir.path().join("tsconfig.json").exists()); - assert!(temp_dir.path().join("bunfig.toml").exists()); - } - - #[test] - fn test_scaffold_idempotency() { - let temp_dir = TempDir::new().unwrap(); - let scaffold = - BackstageScaffold::new(temp_dir.path().to_path_buf(), ScaffoldOptions::default()); - - // First run - creates files - let result1 = scaffold.generate().unwrap(); - assert!(!result1.created.is_empty()); - assert!(result1.skipped.is_empty()); - - // Second run - skips existing - let result2 = scaffold.generate().unwrap(); - assert!(result2.created.is_empty()); - assert!(!result2.skipped.is_empty()); - } - - #[test] - fn test_scaffold_force_overwrites() { - let temp_dir = TempDir::new().unwrap(); - - // First run without force - let scaffold1 = - BackstageScaffold::new(temp_dir.path().to_path_buf(), ScaffoldOptions::default()); - scaffold1.generate().unwrap(); - - // Second run with force - let scaffold2 = BackstageScaffold::new( - temp_dir.path().to_path_buf(), - ScaffoldOptions { - force: true, - ..Default::default() - }, - ); - let result = scaffold2.generate().unwrap(); - assert!(!result.created.is_empty()); - assert!(result.skipped.is_empty()); - } - - #[test] - fn test_scaffold_without_docker() { - let temp_dir = TempDir::new().unwrap(); - let scaffold = BackstageScaffold::new( - temp_dir.path().to_path_buf(), - ScaffoldOptions { - include_docker: false, - ..Default::default() - }, - ); - - let result = scaffold.generate().unwrap(); - assert!(result.is_success()); - - // Docker files should not exist - assert!(!temp_dir.path().join("Dockerfile").exists()); - assert!(!temp_dir.path().join("docker-compose.yaml").exists()); - - // Other files should exist - assert!(temp_dir.path().join("package.json").exists()); - assert!(temp_dir.path().join("app-config.yaml").exists()); - } -} diff --git a/src/backstage/server.rs b/src/backstage/server.rs deleted file mode 100644 index 217ee484..00000000 --- a/src/backstage/server.rs +++ /dev/null @@ -1,1102 +0,0 @@ -//! Backstage server lifecycle management. -//! -//! Provides a trait-based abstraction over server operations to enable: -//! - Unit testing without real binaries -//! - Mocking server behavior -//! - Compiled binary mode (no Bun required) -//! - Development mode with Bun - -// M6 TUI integration complete - some methods still reserved for future Backstage API -#![allow(dead_code)] - -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use thiserror::Error; - -use super::runtime::{BackstageRuntime, RuntimeError}; -use crate::config::BrandingConfig; - -/// Errors specific to Backstage operations -#[derive(Error, Debug)] -pub enum BackstageError { - #[error("bun is not installed or not in PATH")] - BunNotInstalled, - - #[error("bun version {0} is below minimum required version {1}")] - #[allow(dead_code)] // Reserved for future version enforcement - BunVersionTooOld(String, String), - - #[error("backstage scaffold not found at {0}")] - ScaffoldNotFound(PathBuf), - - #[error("backstage server is not running")] - ServerNotRunning, - - #[error("backstage server is already running on port {0}")] - ServerAlreadyRunning(u16), - - #[error("failed to start backstage server: {0}")] - StartFailed(String), - - #[error("failed to stop backstage server: {0}")] - StopFailed(String), - - #[error("bun command failed: {0}")] - CommandFailed(String), - - #[error("server not ready after {timeout_ms}ms on port {port}")] - ServerNotReady { port: u16, timeout_ms: u64 }, - - #[error("runtime error: {0}")] - Runtime(#[from] RuntimeError), -} - -/// Version information for Bun -#[derive(Debug, Clone, PartialEq)] -pub struct BunVersion { - pub major: u32, - pub minor: u32, - pub patch: u32, - pub raw: String, -} - -impl BunVersion { - /// Parse a version string like "1.1.42" or "v1.1.42" - pub fn parse(version_str: &str) -> Option { - let clean = version_str.trim().trim_start_matches('v'); - let parts: Vec<&str> = clean.split('.').collect(); - - if parts.is_empty() { - return None; - } - - let major: u32 = parts[0].parse().ok()?; - let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); - let patch: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); - - Some(Self { - major, - minor, - patch, - raw: version_str.to_string(), - }) - } - - /// Check if this version meets the minimum requirement - #[allow(dead_code)] // Used in tests, reserved for future version enforcement - pub fn meets_minimum(&self, min: &BunVersion) -> bool { - (self.major, self.minor, self.patch) >= (min.major, min.minor, min.patch) - } -} - -impl std::fmt::Display for BunVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch) - } -} - -/// Generate branding configuration file for backstage-server. -/// -/// Creates a `theme.json` file in the branding directory that backstage-server -/// reads on startup to configure the portal's theme and branding. -/// -/// # Arguments -/// * `branding_path` - Directory to write the theme.json file -/// * `config` - Branding configuration from Operator config -/// -/// # Returns -/// * `Ok(())` on success -/// * `Err` if directory creation or file writing fails -pub fn generate_branding_config( - branding_path: &Path, - config: &BrandingConfig, -) -> std::io::Result<()> { - // Create branding directory if it doesn't exist - std::fs::create_dir_all(branding_path)?; - - // Build theme configuration JSON - let theme = serde_json::json!({ - "appTitle": config.app_title, - "orgName": config.org_name, - "logoPath": config.logo_path, - "colors": { - "primary": config.colors.primary, - "secondary": config.colors.secondary, - "accent": config.colors.accent, - "warning": config.colors.warning, - "muted": config.colors.muted, - } - }); - - // Write theme.json - let theme_path = branding_path.join("theme.json"); - let theme_json = serde_json::to_string_pretty(&theme) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - std::fs::write(&theme_path, theme_json)?; - - tracing::info!( - path = %theme_path.display(), - app_title = %config.app_title, - "Generated backstage branding config" - ); - - Ok(()) -} - -/// Copy default logo to branding directory if not already present. -/// -/// Copies `img/operator_logo.svg` to the branding directory as `logo.svg` -/// on first setup if no logo already exists. -/// -/// # Arguments -/// * `branding_path` - Directory containing branding assets -/// * `source_logo_path` - Path to the source logo file -pub fn copy_default_logo(branding_path: &Path, source_logo_path: &Path) -> std::io::Result<()> { - let dest_logo = branding_path.join("logo.svg"); - - // Only copy if destination doesn't exist - if !dest_logo.exists() && source_logo_path.exists() { - std::fs::create_dir_all(branding_path)?; - std::fs::copy(source_logo_path, &dest_logo)?; - tracing::info!( - source = %source_logo_path.display(), - dest = %dest_logo.display(), - "Copied default logo to branding directory" - ); - } - - Ok(()) -} - -/// Server status information -#[derive(Debug, Clone, PartialEq)] -pub enum ServerStatus { - Stopped, - Starting, - Stopping, - Running { - port: u16, - pid: u32, - }, - #[allow(dead_code)] // Used in StatusBar rendering - Error(String), -} - -impl ServerStatus { - /// Returns true if the server is running - pub fn is_running(&self) -> bool { - matches!(self, ServerStatus::Running { .. }) - } -} - -/// Trait abstracting Bun operations for testability -pub trait BunClient: Send + Sync { - /// Check if Bun is available and return version info - fn check_available(&self) -> Result; - - /// Check if dependencies are installed - fn check_dependencies(&self, scaffold_path: &Path) -> Result; - - /// Install dependencies (bun install) - fn install_dependencies(&self, scaffold_path: &Path) -> Result<(), BackstageError>; - - /// Start the Backstage server, returns process handle - fn start_server(&self, scaffold_path: &Path, port: u16) -> Result; - - /// Check if a process is still running - fn is_process_running(&self, pid: u32) -> bool; -} - -/// Real implementation using system Bun -pub struct SystemBunClient; - -impl SystemBunClient { - pub fn new() -> Self { - Self - } - - fn run_bun( - &self, - args: &[&str], - cwd: Option<&Path>, - ) -> Result { - let mut cmd = Command::new("bun"); - cmd.args(args); - - if let Some(dir) = cwd { - cmd.current_dir(dir); - } - - cmd.output().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - BackstageError::BunNotInstalled - } else { - BackstageError::CommandFailed(e.to_string()) - } - }) - } -} - -impl Default for SystemBunClient { - fn default() -> Self { - Self::new() - } -} - -impl BunClient for SystemBunClient { - fn check_available(&self) -> Result { - let output = self.run_bun(&["--version"], None)?; - - if !output.status.success() { - return Err(BackstageError::BunNotInstalled); - } - - let version_str = String::from_utf8_lossy(&output.stdout); - BunVersion::parse(version_str.trim()).ok_or_else(|| { - BackstageError::CommandFailed(format!("Could not parse version: {version_str}")) - }) - } - - fn check_dependencies(&self, scaffold_path: &Path) -> Result { - let node_modules = scaffold_path.join("node_modules"); - let lockfile = scaffold_path.join("bun.lockb"); - - Ok(node_modules.exists() && lockfile.exists()) - } - - fn install_dependencies(&self, scaffold_path: &Path) -> Result<(), BackstageError> { - let output = self.run_bun(&["install"], Some(scaffold_path))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BackstageError::CommandFailed(format!( - "bun install failed: {stderr}" - ))); - } - - Ok(()) - } - - fn start_server(&self, scaffold_path: &Path, port: u16) -> Result { - // Start backend with bun - // Command: bun run packages/backend/src/index.ts - let child = Command::new("bun") - .args(["run", "packages/backend/src/index.ts"]) - .current_dir(scaffold_path) - .env("PORT", port.to_string()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - BackstageError::BunNotInstalled - } else { - BackstageError::StartFailed(e.to_string()) - } - })?; - - Ok(child) - } - - fn is_process_running(&self, pid: u32) -> bool { - use sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - sys.process(sysinfo::Pid::from_u32(pid)).is_some() - } -} - -/// Client that uses a pre-compiled backstage-server binary. -/// -/// Downloads the platform-specific binary on first use and executes it directly. -/// This eliminates the need for users to have Bun/Node installed. -pub struct RuntimeBinaryClient { - runtime: BackstageRuntime, -} - -impl RuntimeBinaryClient { - /// Create a new runtime binary client. - /// - /// # Arguments - /// * `state_path` - Directory to store the binary (e.g., .tickets/operator) - /// * `release_url` - Base URL for downloading binaries - /// * `local_binary_path` - Optional local path to binary (takes precedence over URL) - pub fn new( - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - ) -> Result { - let runtime = BackstageRuntime::new(state_path, release_url, local_binary_path)?; - Ok(Self { runtime }) - } -} - -impl BunClient for RuntimeBinaryClient { - fn check_available(&self) -> Result { - // For compiled binary, we return a synthetic version indicating binary mode - // The binary is self-contained and doesn't need Bun - Ok(BunVersion { - major: 0, - minor: 0, - patch: 0, - raw: "compiled-binary".to_string(), - }) - } - - fn check_dependencies(&self, _scaffold_path: &Path) -> Result { - // For compiled binary, dependencies are bundled in the binary - // We just need to ensure the binary exists - Ok(self.runtime.binary_exists()) - } - - fn install_dependencies(&self, _scaffold_path: &Path) -> Result<(), BackstageError> { - // "Installing dependencies" means downloading the binary - self.runtime.ensure_binary()?; - Ok(()) - } - - fn start_server(&self, _scaffold_path: &Path, port: u16) -> Result { - // Ensure binary is available - let binary_path = self.runtime.ensure_binary()?; - - tracing::info!( - binary = %binary_path.display(), - port = port, - platform = %self.runtime.platform().display_name(), - "Starting backstage-server binary" - ); - - // Start the compiled binary - let child = Command::new(&binary_path) - .env("PORT", port.to_string()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| BackstageError::StartFailed(e.to_string()))?; - - Ok(child) - } - - fn is_process_running(&self, pid: u32) -> bool { - use sysinfo::System; - let mut sys = System::new(); - sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); - sys.process(sysinfo::Pid::from_u32(pid)).is_some() - } -} - -/// Backstage server handle for lifecycle management -pub struct BackstageServer { - client: Arc, - scaffold_path: PathBuf, - port: u16, - process: Mutex>, - status: Mutex, -} - -impl BackstageServer { - /// Create a new server handle - pub fn new(client: Arc, scaffold_path: PathBuf, port: u16) -> Self { - Self { - client, - scaffold_path, - port, - process: Mutex::new(None), - status: Mutex::new(ServerStatus::Stopped), - } - } - - /// Create with system Bun client (development mode) - pub fn with_system_client(scaffold_path: PathBuf, port: u16) -> Self { - Self::new(Arc::new(SystemBunClient::new()), scaffold_path, port) - } - - /// Create with compiled binary client (production mode). - /// - /// Downloads and uses a pre-compiled backstage-server binary that doesn't - /// require Bun/Node to be installed. The binary is downloaded on first use. - /// - /// # Arguments - /// * `state_path` - Directory to store the binary (e.g., .tickets/operator) - /// * `release_url` - Base URL for downloading binaries - /// * `local_binary_path` - Optional local path to binary (takes precedence over URL) - /// * `port` - Port to run the server on - pub fn with_compiled_binary( - state_path: PathBuf, - release_url: String, - local_binary_path: Option, - port: u16, - ) -> Result { - let client = RuntimeBinaryClient::new(state_path.clone(), release_url, local_binary_path)?; - Ok(Self::new(Arc::new(client), state_path, port)) - } - - /// Get current server status - pub fn status(&self) -> ServerStatus { - self.status.lock().unwrap().clone() - } - - /// Check if server is running - pub fn is_running(&self) -> bool { - self.status().is_running() - } - - /// Start the Backstage server - pub fn start(&self) -> Result<(), BackstageError> { - // Check if already running - if self.is_running() { - return Err(BackstageError::ServerAlreadyRunning(self.port)); - } - - // Check scaffold exists - if !self.scaffold_path.exists() { - return Err(BackstageError::ScaffoldNotFound(self.scaffold_path.clone())); - } - - // Check Bun is available - let version = self.client.check_available()?; - tracing::info!(version = %version.raw, "Bun available"); - - // Check/install dependencies - if !self.client.check_dependencies(&self.scaffold_path)? { - tracing::info!("Installing Backstage dependencies..."); - *self.status.lock().unwrap() = ServerStatus::Starting; - self.client.install_dependencies(&self.scaffold_path)?; - } - - // Start server - *self.status.lock().unwrap() = ServerStatus::Starting; - let mut child = self.client.start_server(&self.scaffold_path, self.port)?; - let pid = child.id(); - - // Spawn threads to forward stdout/stderr to tracing - if let Some(stdout) = child.stdout.take() { - std::thread::spawn(move || { - use std::io::{BufRead, BufReader}; - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - tracing::info!(target: "backstage", "{}", line); - } - }); - } - - if let Some(stderr) = child.stderr.take() { - std::thread::spawn(move || { - use std::io::{BufRead, BufReader}; - let reader = BufReader::new(stderr); - for line in reader.lines().map_while(Result::ok) { - tracing::warn!(target: "backstage", "{}", line); - } - }); - } - - *self.process.lock().unwrap() = Some(child); - *self.status.lock().unwrap() = ServerStatus::Running { - port: self.port, - pid, - }; - - tracing::info!(port = self.port, pid = pid, "Backstage server started"); - - Ok(()) - } - - /// Stop the Backstage server - pub fn stop(&self) -> Result<(), BackstageError> { - let mut process_guard = self.process.lock().unwrap(); - - if let Some(ref mut child) = *process_guard { - // Set stopping status first for visual feedback - *self.status.lock().unwrap() = ServerStatus::Stopping; - - child - .kill() - .map_err(|e| BackstageError::StopFailed(e.to_string()))?; - child - .wait() - .map_err(|e| BackstageError::StopFailed(e.to_string()))?; - tracing::info!("Backstage server stopped"); - } - - *process_guard = None; - *self.status.lock().unwrap() = ServerStatus::Stopped; - - Ok(()) - } - - /// Toggle server state (start if stopped, stop if running) - pub fn toggle(&self) -> Result<(), BackstageError> { - if self.is_running() { - self.stop() - } else { - self.start() - } - } - - /// Open Backstage in default browser - /// - /// Checks `$BROWSER` environment variable first, then falls back to - /// platform-specific defaults (`open` on macOS, `xdg-open` on Linux). - pub fn open_browser(&self) -> Result<(), BackstageError> { - if !self.is_running() { - return Err(BackstageError::ServerNotRunning); - } - - let url = format!("http://localhost:{}", self.port); - - // Check $BROWSER environment variable first - if let Ok(browser) = std::env::var("BROWSER") { - let _ = Command::new(&browser).arg(&url).spawn(); - return Ok(()); - } - - // Fall back to platform-specific defaults - #[cfg(target_os = "macos")] - { - let _ = Command::new("open").arg(&url).spawn(); - } - - #[cfg(target_os = "linux")] - { - let _ = Command::new("xdg-open").arg(&url).spawn(); - } - - #[cfg(target_os = "windows")] - { - let _ = Command::new("cmd").args(["/C", "start", &url]).spawn(); - } - - Ok(()) - } - - /// Wait for server to be ready (health endpoint responds). - /// - /// Polls the `/health` endpoint every 500ms until it responds with success - /// or the timeout is reached. - /// - /// # Arguments - /// * `timeout_ms` - Maximum time to wait in milliseconds - /// - /// # Returns - /// * `Ok(())` if server is ready - /// * `Err(BackstageError::ServerNotReady)` if timeout reached - pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<(), BackstageError> { - let url = format!("http://localhost:{}/health", self.port); - let check_interval = Duration::from_millis(500); - let max_attempts = (timeout_ms / 500) as usize; - - tracing::debug!( - url = %url, - max_attempts = max_attempts, - "Waiting for server to be ready" - ); - - for attempt in 1..=max_attempts { - match reqwest::blocking::Client::new() - .get(&url) - .timeout(Duration::from_secs(2)) - .send() - { - Ok(response) if response.status().is_success() => { - tracing::info!(attempts = attempt, "Server ready"); - return Ok(()); - } - Ok(response) => { - tracing::debug!( - attempt = attempt, - status = %response.status(), - "Health check returned non-success status" - ); - } - Err(e) => { - tracing::debug!( - attempt = attempt, - error = %e, - "Health check failed, retrying..." - ); - } - } - std::thread::sleep(check_interval); - } - - Err(BackstageError::ServerNotReady { - port: self.port, - timeout_ms, - }) - } - - /// Refresh status by checking if process is still running - pub fn refresh_status(&self) { - let mut status_guard = self.status.lock().unwrap(); - - if let ServerStatus::Running { pid, .. } = *status_guard { - if !self.client.is_process_running(pid) { - *status_guard = ServerStatus::Stopped; - *self.process.lock().unwrap() = None; - tracing::warn!(pid = pid, "Backstage server process died unexpectedly"); - } - } - } - - /// Get the server URL - #[allow(dead_code)] // Used in tests, reserved for future Backstage API - pub fn url(&self) -> String { - format!("http://localhost:{}", self.port) - } - - /// Get the port - #[allow(dead_code)] // Used in tests, reserved for future API - pub fn port(&self) -> u16 { - self.port - } - - /// Get the scaffold path - #[allow(dead_code)] // Used in tests - pub fn scaffold_path(&self) -> &Path { - &self.scaffold_path - } -} - -// Ensure server is stopped when handle is dropped -impl Drop for BackstageServer { - fn drop(&mut self) { - if self.is_running() { - let _ = self.stop(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Mock implementation for testing - #[derive(Default)] - pub struct MockBunClient { - pub installed: Mutex, - pub version: Mutex>, - pub deps_installed: Mutex, - pub running_pids: Mutex>, - pub next_pid: Mutex, - pub fail_start: Mutex, - } - - impl MockBunClient { - pub fn new() -> Self { - Self { - installed: Mutex::new(true), - version: Mutex::new(Some(BunVersion { - major: 1, - minor: 1, - patch: 42, - raw: "1.1.42".to_string(), - })), - deps_installed: Mutex::new(true), - running_pids: Mutex::new(Vec::new()), - next_pid: Mutex::new(1000), - fail_start: Mutex::new(false), - } - } - - pub fn not_installed() -> Self { - let mock = Self::new(); - *mock.installed.lock().unwrap() = false; - mock - } - - pub fn with_deps_not_installed() -> Self { - let mock = Self::new(); - *mock.deps_installed.lock().unwrap() = false; - mock - } - - pub fn mark_pid_stopped(&self, pid: u32) { - self.running_pids.lock().unwrap().retain(|&p| p != pid); - } - } - - impl BunClient for MockBunClient { - fn check_available(&self) -> Result { - if !*self.installed.lock().unwrap() { - return Err(BackstageError::BunNotInstalled); - } - - self.version - .lock() - .unwrap() - .clone() - .ok_or(BackstageError::BunNotInstalled) - } - - fn check_dependencies(&self, _scaffold_path: &Path) -> Result { - Ok(*self.deps_installed.lock().unwrap()) - } - - fn install_dependencies(&self, _scaffold_path: &Path) -> Result<(), BackstageError> { - *self.deps_installed.lock().unwrap() = true; - Ok(()) - } - - fn start_server(&self, _scaffold_path: &Path, _port: u16) -> Result { - if *self.fail_start.lock().unwrap() { - return Err(BackstageError::StartFailed( - "Mock configured to fail".to_string(), - )); - } - - // Track the PID - let mut next_pid = self.next_pid.lock().unwrap(); - let pid = *next_pid; - *next_pid += 1; - self.running_pids.lock().unwrap().push(pid); - - // Return a real dummy process (sleep) for testing - // This allows us to test the Child handling - Command::new("sleep") - .arg("3600") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| BackstageError::StartFailed(e.to_string())) - } - - fn is_process_running(&self, pid: u32) -> bool { - self.running_pids.lock().unwrap().contains(&pid) - } - } - - use tempfile::TempDir; - - // ==================== BunVersion Tests ==================== - - #[test] - fn test_bun_version_parse_standard() { - let v = BunVersion::parse("1.1.42").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 1); - assert_eq!(v.patch, 42); - } - - #[test] - fn test_bun_version_parse_with_v_prefix() { - let v = BunVersion::parse("v1.0.0").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - } - - #[test] - fn test_bun_version_parse_two_part() { - let v = BunVersion::parse("2.0").unwrap(); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - } - - #[test] - fn test_bun_version_parse_single_part() { - let v = BunVersion::parse("3").unwrap(); - assert_eq!(v.major, 3); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - } - - #[test] - fn test_bun_version_parse_with_whitespace() { - let v = BunVersion::parse(" 1.2.3 \n").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - } - - #[test] - fn test_bun_version_parse_invalid() { - assert!(BunVersion::parse("").is_none()); - assert!(BunVersion::parse("not-a-version").is_none()); - assert!(BunVersion::parse("a.b.c").is_none()); - } - - #[test] - fn test_bun_version_meets_minimum_equal() { - let v = BunVersion::parse("1.0.0").unwrap(); - let min = BunVersion::parse("1.0.0").unwrap(); - assert!(v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_meets_minimum_higher() { - let v = BunVersion::parse("1.1.42").unwrap(); - let min = BunVersion::parse("1.0.0").unwrap(); - assert!(v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_meets_minimum_lower() { - let v = BunVersion::parse("1.0.0").unwrap(); - let min = BunVersion::parse("2.0.0").unwrap(); - assert!(!v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_meets_minimum_minor() { - let v = BunVersion::parse("1.5.0").unwrap(); - let min = BunVersion::parse("1.4.0").unwrap(); - assert!(v.meets_minimum(&min)); - } - - #[test] - fn test_bun_version_display() { - let v = BunVersion::parse("1.2.3").unwrap(); - assert_eq!(format!("{v}"), "1.2.3"); - } - - // ==================== ServerStatus Tests ==================== - - #[test] - fn test_server_status_is_running() { - assert!(!ServerStatus::Stopped.is_running()); - assert!(!ServerStatus::Starting.is_running()); - assert!(!ServerStatus::Stopping.is_running()); - assert!(ServerStatus::Running { - port: 7007, - pid: 123 - } - .is_running()); - assert!(!ServerStatus::Error("test".to_string()).is_running()); - } - - // ==================== MockBunClient Tests ==================== - - #[test] - fn test_mock_client_available() { - let client = MockBunClient::new(); - let version = client.check_available().unwrap(); - assert_eq!(version.major, 1); - assert_eq!(version.minor, 1); - assert_eq!(version.patch, 42); - } - - #[test] - fn test_mock_client_not_installed() { - let client = MockBunClient::not_installed(); - assert!(matches!( - client.check_available(), - Err(BackstageError::BunNotInstalled) - )); - } - - #[test] - fn test_mock_client_deps_installed() { - let client = MockBunClient::new(); - let path = PathBuf::from("/test"); - assert!(client.check_dependencies(&path).unwrap()); - } - - #[test] - fn test_mock_client_deps_not_installed() { - let client = MockBunClient::with_deps_not_installed(); - let path = PathBuf::from("/test"); - assert!(!client.check_dependencies(&path).unwrap()); - } - - #[test] - fn test_mock_client_install_deps() { - let client = MockBunClient::with_deps_not_installed(); - let path = PathBuf::from("/test"); - assert!(!client.check_dependencies(&path).unwrap()); - - client.install_dependencies(&path).unwrap(); - assert!(client.check_dependencies(&path).unwrap()); - } - - #[test] - fn test_mock_client_process_running() { - let client = MockBunClient::new(); - client.running_pids.lock().unwrap().push(123); - assert!(client.is_process_running(123)); - assert!(!client.is_process_running(456)); - } - - #[test] - fn test_mock_client_mark_pid_stopped() { - let client = MockBunClient::new(); - client.running_pids.lock().unwrap().push(123); - assert!(client.is_process_running(123)); - - client.mark_pid_stopped(123); - assert!(!client.is_process_running(123)); - } - - // ==================== BackstageServer Tests ==================== - - #[test] - fn test_server_status_initial() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 7007); - - assert_eq!(server.status(), ServerStatus::Stopped); - assert!(!server.is_running()); - } - - #[test] - fn test_server_url() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 7007); - - assert_eq!(server.url(), "http://localhost:7007"); - } - - #[test] - fn test_server_port() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 8080); - - assert_eq!(server.port(), 8080); - } - - #[test] - fn test_server_scaffold_path() { - let client = Arc::new(MockBunClient::new()); - let path = PathBuf::from("/my/scaffold"); - let server = BackstageServer::new(client, path.clone(), 7007); - - assert_eq!(server.scaffold_path(), path); - } - - #[test] - fn test_server_scaffold_not_found() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/nonexistent/path"), 7007); - - let result = server.start(); - assert!(matches!(result, Err(BackstageError::ScaffoldNotFound(_)))); - } - - #[test] - fn test_server_bun_not_installed() { - let client = Arc::new(MockBunClient::not_installed()); - let temp_dir = TempDir::new().unwrap(); - - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - let result = server.start(); - assert!(matches!(result, Err(BackstageError::BunNotInstalled))); - } - - #[test] - fn test_server_start_and_stop() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - // Start - server.start().unwrap(); - assert!(server.is_running()); - - // Stop - server.stop().unwrap(); - assert!(!server.is_running()); - } - - #[test] - fn test_server_already_running() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - server.start().unwrap(); - - let result = server.start(); - assert!(matches!( - result, - Err(BackstageError::ServerAlreadyRunning(7007)) - )); - - server.stop().unwrap(); - } - - #[test] - fn test_server_toggle_start() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - assert!(!server.is_running()); - server.toggle().unwrap(); - assert!(server.is_running()); - - server.stop().unwrap(); - } - - #[test] - fn test_server_toggle_stop() { - let client = Arc::new(MockBunClient::new()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client, temp_dir.path().to_path_buf(), 7007); - - server.start().unwrap(); - assert!(server.is_running()); - - server.toggle().unwrap(); - assert!(!server.is_running()); - } - - #[test] - fn test_open_browser_when_not_running() { - let client = Arc::new(MockBunClient::new()); - let server = BackstageServer::new(client, PathBuf::from("/tmp/test"), 7007); - - let result = server.open_browser(); - assert!(matches!(result, Err(BackstageError::ServerNotRunning))); - } - - #[test] - fn test_server_with_system_client() { - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::with_system_client(temp_dir.path().to_path_buf(), 7007); - - assert_eq!(server.port(), 7007); - assert!(!server.is_running()); - } - - #[test] - fn test_server_installs_deps_if_missing() { - let client = Arc::new(MockBunClient::with_deps_not_installed()); - let temp_dir = TempDir::new().unwrap(); - let server = BackstageServer::new(client.clone(), temp_dir.path().to_path_buf(), 7007); - - // Verify deps not installed initially - assert!(!client.check_dependencies(temp_dir.path()).unwrap()); - - // Start should install them - server.start().unwrap(); - - // Deps should now be installed - assert!(client.check_dependencies(temp_dir.path()).unwrap()); - - server.stop().unwrap(); - } - - // ==================== Integration Tests ==================== - - #[test] - #[ignore = "requires bun installation"] - fn test_real_bun_version_check() { - let client = SystemBunClient::new(); - let version = client.check_available().expect("Bun should be available"); - - assert!(version.major >= 1, "Bun version should be 1.x or higher"); - } - - #[test] - #[ignore = "requires bun installation"] - fn test_real_check_dependencies_empty_dir() { - let client = SystemBunClient::new(); - let temp_dir = TempDir::new().unwrap(); - - // Empty directory should not have deps - assert!(!client.check_dependencies(temp_dir.path()).unwrap()); - } -} diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index 8a9d1a17..bb1249df 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -29,18 +29,18 @@ use operator::api::providers::kanban::{ JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, }; use operator::config::{ - AgentsConfig, ApiConfig, BackstageConfig, BrandingConfig, CollectionPreset, Config, Delegator, - DelegatorLaunchConfig, DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, - LoggingConfig, NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, - SkillDirectoriesOverride, TemplatesConfig, ThemeColors, TmuxConfig, ToolCapabilities, UiConfig, - YoloConfig, + AgentsConfig, ApiConfig, CollectionPreset, Config, Delegator, DelegatorLaunchConfig, + DetectedTool, DockerConfig, LaunchConfig, LlmProvider, LlmToolsConfig, LoggingConfig, + NotificationsConfig, PanelNamesConfig, PathsConfig, QueueConfig, RestApiConfig, + SkillDirectoriesOverride, TemplatesConfig, TmuxConfig, ToolCapabilities, UiConfig, YoloConfig, }; use operator::queue::LlmTask; use operator::rest::dto::{ CollectionResponse, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateStepRequest, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, - FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, SkillEntry, SkillsResponse, - StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanProviderCatalogEntry, + SectionDto, SectionRowDto, SkillEntry, SkillsResponse, StatusResponse, StepResponse, + UpdateIssueTypeRequest, UpdateStepRequest, WorkflowExportResponse, }; use operator::state::{AgentState, CompletedTicket, State}; use operator::types::{ @@ -105,9 +105,6 @@ fn generate_typescript() -> String { DockerConfig::decl(), YoloConfig::decl(), TmuxConfig::decl(), - BackstageConfig::decl(), - BrandingConfig::decl(), - ThemeColors::decl(), RestApiConfig::decl(), LlmToolsConfig::decl(), DetectedTool::decl(), @@ -137,6 +134,12 @@ fn generate_typescript() -> String { CollectionResponse::decl(), HealthResponse::decl(), StatusResponse::decl(), + SectionDto::decl(), + SectionRowDto::decl(), + // Kanban provider catalog DTO + KanbanProviderCatalogEntry::decl(), + // Workflow export DTO + WorkflowExportResponse::decl(), // Skills DTOs SkillEntry::decl(), SkillsResponse::decl(), diff --git a/src/collections/backstage_full/ASSESS.json b/src/collections/full/ASSESS.json similarity index 81% rename from src/collections/backstage_full/ASSESS.json rename to src/collections/full/ASSESS.json index 12ccc27f..4b2c4671 100644 --- a/src/collections/backstage_full/ASSESS.json +++ b/src/collections/full/ASSESS.json @@ -7,7 +7,7 @@ "glyph": "~", "color": "magenta", "project_required": true, - "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for Backstage). Output ONLY the agent system prompt.", + "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -24,7 +24,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Assess project for Backstage catalog", + "placeholder": "Assess project structure", "max_length": 120, "display_order": 1 }, @@ -52,7 +52,7 @@ "name": "generate", "display_name": "Generating", "outputs": ["code"], - "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for Backstage UI):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", + "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", "allowed_tools": ["Read", "Write", "Edit"], "review_type": "plan", "on_reject": { diff --git a/src/collections/backstage_full/ASSESS.md b/src/collections/full/ASSESS.md similarity index 100% rename from src/collections/backstage_full/ASSESS.md rename to src/collections/full/ASSESS.md diff --git a/src/collections/backstage_full/FEAT.json b/src/collections/full/FEAT.json similarity index 100% rename from src/collections/backstage_full/FEAT.json rename to src/collections/full/FEAT.json diff --git a/src/collections/backstage_full/FEAT.md b/src/collections/full/FEAT.md similarity index 100% rename from src/collections/backstage_full/FEAT.md rename to src/collections/full/FEAT.md diff --git a/src/collections/backstage_full/FIX.json b/src/collections/full/FIX.json similarity index 100% rename from src/collections/backstage_full/FIX.json rename to src/collections/full/FIX.json diff --git a/src/collections/backstage_full/FIX.md b/src/collections/full/FIX.md similarity index 100% rename from src/collections/backstage_full/FIX.md rename to src/collections/full/FIX.md diff --git a/src/collections/backstage_full/INIT.json b/src/collections/full/INIT.json similarity index 52% rename from src/collections/backstage_full/INIT.json rename to src/collections/full/INIT.json index c324bc69..1c2bf388 100644 --- a/src/collections/backstage_full/INIT.json +++ b/src/collections/full/INIT.json @@ -1,13 +1,13 @@ { "$schema": "../../schemas/issuetype_schema.json", "key": "INIT", - "name": "Backstage Init", - "description": "Initialize Backstage deployment in workspace", + "name": "Workspace Init", + "description": "Initialize workspace structure", "mode": "paired", "glyph": "%", "color": "green", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage initialization. The agent scaffolds the Backstage directory structure in .tickets/operator/backstage/, configures app-config.yaml with project locations and guest authentication, and verifies Bun installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -29,7 +29,7 @@ }, { "name": "branding_name", - "description": "Custom branding name for Backstage", + "description": "Custom branding name", "type": "string", "required": false, "default": "", @@ -42,7 +42,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Initialize Backstage in workspace", + "placeholder": "Initialize workspace", "max_length": 120, "display_order": 3 } @@ -52,7 +52,7 @@ "name": "scaffold", "display_name": "Scaffolding", "outputs": ["code"], - "prompt": "Create the Backstage directory structure:\n\n1. Create `.tickets/operator/backstage/` directory\n2. Generate minimal `package.json` for Bun:\n ```json\n {\n \"name\": \"backstage-local\",\n \"scripts\": {\n \"dev\": \"backstage-cli dev\",\n \"start\": \"backstage-cli start\"\n }\n }\n ```\n3. Create `packages/` directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", + "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", "allowed_tools": ["Read", "Write", "Bash"], "next_step": "configure" }, @@ -60,7 +60,7 @@ "name": "configure", "display_name": "Configuring", "outputs": ["code"], - "prompt": "Configure Backstage for local development:\n\n1. Generate `app-config.yaml` with:\n - Guest authentication (no login required)\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", + "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", "allowed_tools": ["Read", "Write", "Glob"], "review_type": "plan", "on_reject": { @@ -73,7 +73,7 @@ "name": "verify", "display_name": "Verifying", "outputs": ["report"], - "prompt": "Verify the Backstage setup:\n\n1. Check Bun is installed: `bun --version`\n2. Install dependencies: `bun install`\n3. Verify configuration is valid\n4. Document setup in `.tickets/operator/backstage/README.md`\n5. Provide instructions for starting: `bun run dev`\n\nReport any issues that need manual resolution.", + "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/backstage_full/INIT.md b/src/collections/full/INIT.md similarity index 90% rename from src/collections/backstage_full/INIT.md rename to src/collections/full/INIT.md index f6fa5ed9..05ef2b5d 100644 --- a/src/collections/backstage_full/INIT.md +++ b/src/collections/full/INIT.md @@ -7,7 +7,7 @@ id: {{ id }} created: {{ created_datetime }} --- -# Backstage Init: {{ summary }} +# Workspace Init: {{ summary }} ## Workspace {{ workspace }} diff --git a/src/collections/backstage_full/INV.json b/src/collections/full/INV.json similarity index 100% rename from src/collections/backstage_full/INV.json rename to src/collections/full/INV.json diff --git a/src/collections/backstage_full/INV.md b/src/collections/full/INV.md similarity index 100% rename from src/collections/backstage_full/INV.md rename to src/collections/full/INV.md diff --git a/src/collections/backstage_full/SPIKE.json b/src/collections/full/SPIKE.json similarity index 100% rename from src/collections/backstage_full/SPIKE.json rename to src/collections/full/SPIKE.json diff --git a/src/collections/backstage_full/SPIKE.md b/src/collections/full/SPIKE.md similarity index 100% rename from src/collections/backstage_full/SPIKE.md rename to src/collections/full/SPIKE.md diff --git a/src/collections/backstage_full/SYNC.json b/src/collections/full/SYNC.json similarity index 70% rename from src/collections/backstage_full/SYNC.json rename to src/collections/full/SYNC.json index 996d791a..519cd3b5 100644 --- a/src/collections/backstage_full/SYNC.json +++ b/src/collections/full/SYNC.json @@ -2,12 +2,12 @@ "$schema": "../../schemas/issuetype_schema.json", "key": "SYNC", "name": "Catalog Sync", - "description": "Refresh Backstage catalog entries from projects", + "description": "Refresh catalog entries from projects", "mode": "autonomous", "glyph": "@", "color": "blue", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the Backstage schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -43,7 +43,7 @@ "name": "scan", "display_name": "Scanning", "outputs": ["report"], - "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in Backstage)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", + "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", "allowed_tools": ["Read", "Glob", "Grep", "Write"], "next_step": "validate" }, @@ -59,7 +59,7 @@ "name": "update", "display_name": "Updating", "outputs": ["report"], - "prompt": "Update the Backstage catalog:\n\n1. If Backstage server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", + "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/backstage_full/SYNC.md b/src/collections/full/SYNC.md similarity index 100% rename from src/collections/backstage_full/SYNC.md rename to src/collections/full/SYNC.md diff --git a/src/collections/backstage_full/TASK.json b/src/collections/full/TASK.json similarity index 100% rename from src/collections/backstage_full/TASK.json rename to src/collections/full/TASK.json diff --git a/src/collections/backstage_full/TASK.md b/src/collections/full/TASK.md similarity index 100% rename from src/collections/backstage_full/TASK.md rename to src/collections/full/TASK.md diff --git a/src/collections/backstage_full/collection.toml b/src/collections/full/collection.toml similarity index 58% rename from src/collections/backstage_full/collection.toml rename to src/collections/full/collection.toml index 09a80505..c2c576a7 100644 --- a/src/collections/backstage_full/collection.toml +++ b/src/collections/full/collection.toml @@ -1,5 +1,5 @@ -name = "backstage_full" -description = "Full workflow plus Backstage: all types combined" +name = "full" +description = "Full workflow: all issue types combined" # Issue types in this collection (display order) types = ["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] diff --git a/src/collections/mod.rs b/src/collections/mod.rs index 5ec8ce19..059eb907 100644 --- a/src/collections/mod.rs +++ b/src/collections/mod.rs @@ -118,50 +118,50 @@ pub static EMBEDDED_COLLECTIONS: &[EmbeddedCollection] = &[ }, ], }, - // Backstage Full collection: All 8 issuetypes + // Full collection: All 8 issuetypes EmbeddedCollection { - name: "backstage_full", - manifest: include_str!("backstage_full/collection.toml"), + name: "full", + manifest: include_str!("full/collection.toml"), issuetypes: &[ EmbeddedIssueType { key: "TASK", - schema_json: include_str!("backstage_full/TASK.json"), - template_md: include_str!("backstage_full/TASK.md"), + schema_json: include_str!("full/TASK.json"), + template_md: include_str!("full/TASK.md"), }, EmbeddedIssueType { key: "FEAT", - schema_json: include_str!("backstage_full/FEAT.json"), - template_md: include_str!("backstage_full/FEAT.md"), + schema_json: include_str!("full/FEAT.json"), + template_md: include_str!("full/FEAT.md"), }, EmbeddedIssueType { key: "FIX", - schema_json: include_str!("backstage_full/FIX.json"), - template_md: include_str!("backstage_full/FIX.md"), + schema_json: include_str!("full/FIX.json"), + template_md: include_str!("full/FIX.md"), }, EmbeddedIssueType { key: "SPIKE", - schema_json: include_str!("backstage_full/SPIKE.json"), - template_md: include_str!("backstage_full/SPIKE.md"), + schema_json: include_str!("full/SPIKE.json"), + template_md: include_str!("full/SPIKE.md"), }, EmbeddedIssueType { key: "INV", - schema_json: include_str!("backstage_full/INV.json"), - template_md: include_str!("backstage_full/INV.md"), + schema_json: include_str!("full/INV.json"), + template_md: include_str!("full/INV.md"), }, EmbeddedIssueType { key: "ASSESS", - schema_json: include_str!("backstage_full/ASSESS.json"), - template_md: include_str!("backstage_full/ASSESS.md"), + schema_json: include_str!("full/ASSESS.json"), + template_md: include_str!("full/ASSESS.md"), }, EmbeddedIssueType { key: "SYNC", - schema_json: include_str!("backstage_full/SYNC.json"), - template_md: include_str!("backstage_full/SYNC.md"), + schema_json: include_str!("full/SYNC.json"), + template_md: include_str!("full/SYNC.md"), }, EmbeddedIssueType { key: "INIT", - schema_json: include_str!("backstage_full/INIT.json"), - template_md: include_str!("backstage_full/INIT.md"), + schema_json: include_str!("full/INIT.json"), + template_md: include_str!("full/INIT.md"), }, ], }, @@ -238,8 +238,8 @@ mod tests { assert_eq!(operator.name, "operator"); assert_eq!(operator.issuetypes.len(), 5); - let full = get_embedded_collection("backstage_full").unwrap(); - assert_eq!(full.name, "backstage_full"); + let full = get_embedded_collection("full").unwrap(); + assert_eq!(full.name, "full"); assert_eq!(full.issuetypes.len(), 8); } @@ -255,7 +255,7 @@ mod tests { assert!(names.contains(&"dev_kanban")); assert!(names.contains(&"devops_kanban")); assert!(names.contains(&"operator")); - assert!(names.contains(&"backstage_full")); + assert!(names.contains(&"full")); } #[test] diff --git a/src/collections/operator/ASSESS.json b/src/collections/operator/ASSESS.json index 12ccc27f..4b2c4671 100644 --- a/src/collections/operator/ASSESS.json +++ b/src/collections/operator/ASSESS.json @@ -7,7 +7,7 @@ "glyph": "~", "color": "magenta", "project_required": true, - "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for Backstage). Output ONLY the agent system prompt.", + "agent_prompt": "Review this project to create an agent for project assessment. The agent analyzes project structure, detects the project Kind from file patterns (using the 25-Kind taxonomy), identifies commands/entry points/environment variables, and generates both project-context.json (for AI agents) and catalog-info.yaml (for project catalog). Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -24,7 +24,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Assess project for Backstage catalog", + "placeholder": "Assess project structure", "max_length": 120, "display_order": 1 }, @@ -52,7 +52,7 @@ "name": "generate", "display_name": "Generating", "outputs": ["code"], - "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for Backstage UI):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", + "prompt": "Generate output files from the analysis JSON:\n\n1. **Write `project-context.json`** (for AI agents):\n - Save the complete structured analysis JSON to project root\n - This is the primary output for AI consumption\n\n2. **Write `catalog-info.yaml`** (for project catalog):\n ```yaml\n apiVersion: backstage.io/v1alpha1\n kind: Component\n metadata:\n name: {{ project }}\n description: \n annotations:\n backstage.io/techdocs-ref: dir:.\n tags:\n - \n - \n spec:\n type: \n lifecycle: production\n owner: \n ```\n\n3. **Document assessment** in `.tickets/assessments/{{ id }}.md`:\n - Kind detected with confidence score\n - Key technologies found\n - Commands available\n - Entry points identified", "allowed_tools": ["Read", "Write", "Edit"], "review_type": "plan", "on_reject": { diff --git a/src/collections/operator/INIT.json b/src/collections/operator/INIT.json index c324bc69..1c2bf388 100644 --- a/src/collections/operator/INIT.json +++ b/src/collections/operator/INIT.json @@ -1,13 +1,13 @@ { "$schema": "../../schemas/issuetype_schema.json", "key": "INIT", - "name": "Backstage Init", - "description": "Initialize Backstage deployment in workspace", + "name": "Workspace Init", + "description": "Initialize workspace structure", "mode": "paired", "glyph": "%", "color": "green", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage initialization. The agent scaffolds the Backstage directory structure in .tickets/operator/backstage/, configures app-config.yaml with project locations and guest authentication, and verifies Bun installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for workspace initialization. The agent scaffolds the workspace directory structure in .tickets/operator/workspace/, configures project locations and authentication, and verifies tool installation. It works in paired mode with the operator for configuration decisions. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -29,7 +29,7 @@ }, { "name": "branding_name", - "description": "Custom branding name for Backstage", + "description": "Custom branding name", "type": "string", "required": false, "default": "", @@ -42,7 +42,7 @@ "type": "string", "required": true, "default": "", - "placeholder": "Initialize Backstage in workspace", + "placeholder": "Initialize workspace", "max_length": 120, "display_order": 3 } @@ -52,7 +52,7 @@ "name": "scaffold", "display_name": "Scaffolding", "outputs": ["code"], - "prompt": "Create the Backstage directory structure:\n\n1. Create `.tickets/operator/backstage/` directory\n2. Generate minimal `package.json` for Bun:\n ```json\n {\n \"name\": \"backstage-local\",\n \"scripts\": {\n \"dev\": \"backstage-cli dev\",\n \"start\": \"backstage-cli start\"\n }\n }\n ```\n3. Create `packages/` directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", + "prompt": "Create the workspace directory structure:\n\n1. Create `.tickets/operator/workspace/` directory\n2. Generate workspace configuration files\n3. Create standard directory structure\n4. Set up branding directory with defaults\n\nAsk operator for branding preferences if not specified.", "allowed_tools": ["Read", "Write", "Bash"], "next_step": "configure" }, @@ -60,7 +60,7 @@ "name": "configure", "display_name": "Configuring", "outputs": ["code"], - "prompt": "Configure Backstage for local development:\n\n1. Generate `app-config.yaml` with:\n - Guest authentication (no login required)\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", + "prompt": "Configure workspace for local development:\n\n1. Generate workspace configuration with:\n - File-based catalog locations\n - Local database (SQLite)\n2. Scan workspace for existing catalog-info.yaml files\n3. Add all discovered locations to config\n4. Configure branding if branding_name is set\n\nReview configuration with operator before proceeding.", "allowed_tools": ["Read", "Write", "Glob"], "review_type": "plan", "on_reject": { @@ -73,7 +73,7 @@ "name": "verify", "display_name": "Verifying", "outputs": ["report"], - "prompt": "Verify the Backstage setup:\n\n1. Check Bun is installed: `bun --version`\n2. Install dependencies: `bun install`\n3. Verify configuration is valid\n4. Document setup in `.tickets/operator/backstage/README.md`\n5. Provide instructions for starting: `bun run dev`\n\nReport any issues that need manual resolution.", + "prompt": "Verify the workspace setup:\n\n1. Verify configuration is valid\n2. Check all referenced projects exist\n3. Document setup in `.tickets/operator/workspace/README.md`\n\nReport any issues that need manual resolution.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/operator/INIT.md b/src/collections/operator/INIT.md index f6fa5ed9..05ef2b5d 100644 --- a/src/collections/operator/INIT.md +++ b/src/collections/operator/INIT.md @@ -7,7 +7,7 @@ id: {{ id }} created: {{ created_datetime }} --- -# Backstage Init: {{ summary }} +# Workspace Init: {{ summary }} ## Workspace {{ workspace }} diff --git a/src/collections/operator/PROJECT-INIT.json b/src/collections/operator/PROJECT-INIT.json index 01129d8c..66c72848 100644 --- a/src/collections/operator/PROJECT-INIT.json +++ b/src/collections/operator/PROJECT-INIT.json @@ -34,7 +34,7 @@ "name": "scan_structure", "display_name": "Scanning Structure", "outputs": ["report"], - "prompt": "Scan the project structure to understand what's needed:\n\n1. Check for existing configuration files\n2. Identify project type and language\n3. Find any missing required files:\n - CLAUDE.md (agent context)\n - catalog-info.yaml (Backstage catalog)\n - .tickets/ directory structure\n4. Document current state and gaps", + "prompt": "Scan the project structure to understand what's needed:\n\n1. Check for existing configuration files\n2. Identify project type and language\n3. Find any missing required files:\n - CLAUDE.md (agent context)\n - catalog-info.yaml (project catalog)\n - .tickets/ directory structure\n4. Document current state and gaps", "allowed_tools": ["Read", "Glob", "Grep"], "next_step": "apply_conventions" }, diff --git a/src/collections/operator/SYNC.json b/src/collections/operator/SYNC.json index 996d791a..519cd3b5 100644 --- a/src/collections/operator/SYNC.json +++ b/src/collections/operator/SYNC.json @@ -2,12 +2,12 @@ "$schema": "../../schemas/issuetype_schema.json", "key": "SYNC", "name": "Catalog Sync", - "description": "Refresh Backstage catalog entries from projects", + "description": "Refresh catalog entries from projects", "mode": "autonomous", "glyph": "@", "color": "blue", "project_required": false, - "agent_prompt": "Review this workspace to create an agent for Backstage catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the Backstage schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", + "agent_prompt": "Review this workspace to create an agent for catalog synchronization. The agent discovers all projects with catalog-info.yaml files, validates them against the catalog schema, checks for inconsistencies, and updates catalog entries as needed. It can run across the entire workspace or target specific projects. Output ONLY the agent system prompt.", "fields": [ { "name": "id", @@ -43,7 +43,7 @@ "name": "scan", "display_name": "Scanning", "outputs": ["report"], - "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in Backstage)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", + "prompt": "Discover all catalog entries in the workspace:\n\n1. Find all catalog-info.yaml files in projects\n2. Parse each file and validate structure\n3. Build a dependency graph from relations\n4. Identify:\n - New entries (not in catalog)\n - Changed entries (modified since last sync)\n - Removed entries (catalog-info.yaml deleted)\n5. Document findings in `.tickets/syncs/{{ id }}.md`", "allowed_tools": ["Read", "Glob", "Grep", "Write"], "next_step": "validate" }, @@ -59,7 +59,7 @@ "name": "update", "display_name": "Updating", "outputs": ["report"], - "prompt": "Update the Backstage catalog:\n\n1. If Backstage server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", + "prompt": "Update the project catalog:\n\n1. If catalog server is running, trigger catalog refresh\n2. If file-based, update the locations config\n3. Log all changes made\n4. Verify catalog is consistent after update\n\nComplete the sync report in `.tickets/syncs/{{ id }}.md`.", "allowed_tools": ["Read", "Write", "Bash"], "review_type": "plan", "on_reject": { diff --git a/src/collections/operator/collection.toml b/src/collections/operator/collection.toml index 3c52e1c3..61dcc99f 100644 --- a/src/collections/operator/collection.toml +++ b/src/collections/operator/collection.toml @@ -1,5 +1,5 @@ name = "operator" -description = "Operator Backstage tasks: ASSESS, SYNC, INIT" +description = "Operator automation tasks: ASSESS, SYNC, INIT" # Issue types in this collection (display order) types = ["ASSESS", "SYNC", "INIT"] diff --git a/src/config.rs b/src/config.rs index 968d6ed8..dd2a577a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,3 @@ -#[path = "config/backstage_config.rs"] -pub mod backstage_config; #[path = "config/git_config.rs"] pub mod git_config; #[path = "config/kanban.rs"] @@ -11,7 +9,6 @@ pub mod notifications_config; #[path = "config/sessions.rs"] pub mod sessions; -pub use backstage_config::*; pub use git_config::*; pub use kanban::*; pub use llm_tools::*; @@ -50,8 +47,6 @@ pub struct Config { #[serde(default)] pub llm_tools: LlmToolsConfig, #[serde(default)] - pub backstage: BackstageConfig, - #[serde(default)] pub rest_api: RestApiConfig, #[serde(default)] pub git: GitConfig, @@ -71,6 +66,12 @@ pub struct Config { /// Relay MCP injection configuration #[serde(default)] pub relay: RelayConfig, + /// Model Context Protocol (MCP) server configuration + #[serde(default)] + pub mcp: McpConfig, + /// Agent Client Protocol (ACP) agent configuration + #[serde(default)] + pub acp: AcpConfig, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -78,6 +79,10 @@ pub struct Config { pub struct AgentsConfig { pub max_parallel: usize, pub cores_reserved: usize, + /// Maximum concurrent agents per project/repo (default: 1). + /// Requires `git.use_worktrees` = true when > 1 to avoid conflicts. + #[serde(default = "default_max_agents_per_repo")] + pub max_agents_per_repo: usize, pub health_check_interval: u64, /// Timeout in seconds for each agent generation (default: 300 = 5 min) #[serde(default = "default_generation_timeout")] @@ -93,6 +98,10 @@ pub struct AgentsConfig { pub silence_threshold: u64, } +fn default_max_agents_per_repo() -> usize { + 1 +} + fn default_generation_timeout() -> u64 { 300 // 5 minutes } @@ -290,6 +299,118 @@ impl Default for RestApiConfig { } } +/// Model Context Protocol (MCP) server configuration +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export)] +pub struct McpConfig { + /// Whether to mount MCP HTTP/SSE endpoints on the REST API server. + /// Toggling requires an API restart (no hot-swap of the axum router). + #[serde(default = "default_true")] + pub http_enabled: bool, + /// Whether the descriptor endpoint advertises the `operator mcp` stdio + /// command. Set to false on multi-tenant/remote deployments where clients + /// shouldn't spawn local subprocesses. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Whether to expose ticket-mutating tools (claim, complete, return-to-queue, + /// create) over MCP. Defaults to `false` because any MCP client can call them. + #[serde(default)] + pub expose_ticket_write_tools: bool, + /// External MCP servers to inject into spawned agent sessions. + /// Each entry produces a separate `--mcp-config` file alongside the + /// relay config when launching Claude Code agents. + #[serde(default)] + pub external_servers: Vec, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + http_enabled: true, + stdio_advertised: true, + expose_ticket_write_tools: false, + external_servers: Vec::new(), + } + } +} + +/// An external MCP server to inject into spawned agent sessions. +/// +/// Values in `command`, `args`, and `env` support `${VAR}` interpolation, +/// expanded at spawn time from the operator process environment. +/// +/// When `discover_from` is set, operator reads an MCP server spec from that +/// JSON sidecar file at spawn time. The sidecar must contain a top-level +/// `mcpServer` object with `command`, `args`, and `env` fields. If the file +/// is absent and `command` is empty, the server is silently skipped. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct ExternalMcpServer { + /// Server name used as the key in the `mcpServers` JSON object + /// (e.g., "kanbots"). Must be unique across all external servers. + pub name: String, + /// Command to execute. Supports `${VAR}` interpolation. + #[serde(default)] + pub command: String, + /// Command arguments. Each element supports `${VAR}` interpolation. + #[serde(default)] + pub args: Vec, + /// Environment variables passed to the MCP server process. + /// Values support `${VAR}` interpolation. + #[serde(default)] + pub env: std::collections::HashMap, + /// Whether this server is enabled. Allows disabling without removing config. + #[serde(default = "default_true")] + pub enabled: bool, + /// Path to a JSON sidecar discovery file. Relative paths resolve from + /// the project directory. The sidecar must contain `{ "mcpServer": { ... } }`. + /// When the file exists, its `mcpServer` spec is used verbatim (overriding + /// `command`/`args`/`env`). When absent and `command` is empty, the server + /// is silently skipped. + #[serde(default)] + pub discover_from: Option, +} + +/// Agent Client Protocol (ACP) agent configuration. +/// +/// Operator runs as an ACP agent over stdio when editors (Zed, `JetBrains`, +/// Emacs `agent-shell`, Kiro, etc.) spawn `operator acp`. Each ACP session +/// maps to an in-progress ACP ticket and a delegator subprocess. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export)] +pub struct AcpConfig { + /// Whether the dashboard advertises the `operator acp` stdio entrypoint + /// (and editor-config snippet actions). Set to false on machines that + /// shouldn't be used as ACP agents. + #[serde(default = "default_true")] + pub stdio_advertised: bool, + /// Name of the delegator (from `[[delegators]]`) to use for ACP prompts. + /// If unset or not found, falls back to the operator's default delegator + /// resolution. + #[serde(default)] + pub default_delegator: Option, + /// Maximum number of concurrent ACP sessions. New `session/new` requests + /// beyond this limit are rejected with a JSON-RPC error. + #[serde(default = "default_acp_max_sessions")] + pub max_concurrent_sessions: usize, +} + +impl Default for AcpConfig { + fn default() -> Self { + Self { + stdio_advertised: true, + default_delegator: None, + max_concurrent_sessions: default_acp_max_sessions(), + } + } +} + +fn default_acp_max_sessions() -> usize { + 8 +} + /// Predefined issue type collections #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -527,7 +648,7 @@ impl Config { ); let config = builder.build().context("Failed to load configuration")?; - config.try_deserialize().map_err(|e| { + let cfg: Self = config.try_deserialize().map_err(|e| { let mut sources = vec![]; let operator_config = Self::operator_config_path(); if operator_config.exists() { @@ -550,7 +671,17 @@ impl Config { anyhow::anyhow!( "Failed to deserialize configuration: {e}\n\nConfig files loaded:\n{sources_str}\n\nCheck these files for missing or invalid fields." ) - }) + })?; + + if cfg.agents.max_agents_per_repo > 1 && !cfg.git.use_worktrees { + tracing::warn!( + max_agents_per_repo = cfg.agents.max_agents_per_repo, + "max_agents_per_repo > 1 without git.use_worktrees = true; \ + multiple agents on the same repo without worktrees will cause git conflicts" + ); + } + + Ok(cfg) } /// Save config to .tickets/operator/config.toml @@ -578,6 +709,10 @@ impl Config { self.agents.max_parallel.min(core_based_max).max(1) } + pub fn effective_max_agents_per_repo(&self) -> usize { + self.agents.max_agents_per_repo.max(1) + } + /// Get absolute path to tickets directory pub fn tickets_path(&self) -> PathBuf { let path = PathBuf::from(&self.paths.tickets); @@ -609,7 +744,6 @@ impl Config { } /// Get absolute path to worktrees directory - #[allow(dead_code)] // Will be used when WorktreeManager is wired into launcher pub fn worktrees_path(&self) -> PathBuf { let path = PathBuf::from(&self.paths.worktrees); if path.is_absolute() { @@ -634,17 +768,6 @@ impl Config { self.tickets_path().join("operator").join("tmux-status.sh") } - /// Get absolute path to Backstage installation directory - pub fn backstage_path(&self) -> PathBuf { - self.state_path().join(&self.backstage.subpath) - } - - /// Get absolute path to Backstage branding directory - #[allow(dead_code)] // For future branding customization support - pub fn backstage_branding_path(&self) -> PathBuf { - self.backstage_path().join(&self.backstage.branding_subpath) - } - /// Get priority index for a ticket type (lower = higher priority) pub fn priority_index(&self, ticket_type: &str) -> usize { self.queue @@ -664,7 +787,6 @@ impl Config { /// Returns projects found by scanning for .git directories and LLM marker files. /// Each project includes git repo info (remote URL, default branch, GitHub info) /// and a list of available LLM tools. - #[allow(dead_code)] // For future integration pub fn discover_projects_full(&self) -> Vec { crate::projects::discover_projects_with_git(&self.projects_path()) } @@ -677,6 +799,7 @@ impl Default for Config { agents: AgentsConfig { max_parallel: 5, cores_reserved: 1, + max_agents_per_repo: 1, health_check_interval: 30, generation_timeout_secs: 300, // 5 minutes sync_interval: 60, // 1 minute @@ -720,7 +843,6 @@ impl Default for Config { tmux: TmuxConfig::default(), sessions: SessionsConfig::default(), llm_tools: LlmToolsConfig::default(), - backstage: BackstageConfig::default(), rest_api: RestApiConfig::default(), git: GitConfig::default(), kanban: KanbanConfig::default(), @@ -728,6 +850,8 @@ impl Default for Config { delegators: Vec::new(), model_servers: Vec::new(), relay: RelayConfig::default(), + mcp: McpConfig::default(), + acp: AcpConfig::default(), } } } diff --git a/src/config/backstage_config.rs b/src/config/backstage_config.rs deleted file mode 100644 index a39aa98f..00000000 --- a/src/config/backstage_config.rs +++ /dev/null @@ -1,164 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -/// Backstage integration configuration -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct BackstageConfig { - /// Whether Backstage integration is enabled - #[serde(default = "default_backstage_enabled")] - pub enabled: bool, - /// Whether to show Backstage in the Connections status section - #[serde(default)] - pub display: bool, - /// Port for the Backstage server - #[serde(default = "default_backstage_port")] - pub port: u16, - /// Auto-start Backstage server when TUI launches - #[serde(default)] - pub auto_start: bool, - /// Subdirectory within `state_path` for Backstage installation - #[serde(default = "default_backstage_subpath")] - pub subpath: String, - /// Subdirectory within backstage path for branding customization - #[serde(default = "default_branding_subpath")] - pub branding_subpath: String, - /// Base URL for downloading backstage-server binary - #[serde(default = "default_backstage_release_url")] - pub release_url: String, - /// Optional local path to backstage-server binary - /// If set, this is used instead of downloading from `release_url` - #[serde(default)] - pub local_binary_path: Option, - /// Branding and theming configuration - #[serde(default)] - pub branding: BrandingConfig, -} - -/// Branding configuration for Backstage portal -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct BrandingConfig { - /// App title shown in header - #[serde(default = "default_app_title")] - pub app_title: String, - /// Organization name - #[serde(default = "default_org_name")] - pub org_name: String, - /// Path to logo SVG (relative to branding path) - #[serde(default)] - pub logo_path: Option, - /// Theme colors (uses Operator defaults if not set) - #[serde(default)] - pub colors: ThemeColors, -} - -fn default_app_title() -> String { - "Operator Portal".to_string() -} - -fn default_org_name() -> String { - "Operator".to_string() -} - -impl Default for BrandingConfig { - fn default() -> Self { - Self { - app_title: default_app_title(), - org_name: default_org_name(), - logo_path: Some("logo.svg".to_string()), - colors: ThemeColors::default(), - } - } -} - -/// Theme color configuration for Backstage -/// Default colors match Operator's tmux theme -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] -#[ts(export)] -pub struct ThemeColors { - /// Primary/accent color (default: salmon #cc6c55) - #[serde(default = "default_color_primary")] - pub primary: String, - /// Secondary color (default: dark teal #114145) - #[serde(default = "default_color_secondary")] - pub secondary: String, - /// Accent/highlight color (default: cream #f4dbb7) - #[serde(default = "default_color_accent")] - pub accent: String, - /// Warning/error color (default: coral #d46048) - #[serde(default = "default_color_warning")] - pub warning: String, - /// Muted text color (default: darker salmon #8a4a3a) - #[serde(default = "default_color_muted")] - pub muted: String, -} - -fn default_color_primary() -> String { - "#cc6c55".to_string() // salmon -} - -fn default_color_secondary() -> String { - "#114145".to_string() // dark teal -} - -fn default_color_accent() -> String { - "#f4dbb7".to_string() // cream -} - -fn default_color_warning() -> String { - "#d46048".to_string() // coral -} - -fn default_color_muted() -> String { - "#8a4a3a".to_string() // darker salmon -} - -impl Default for ThemeColors { - fn default() -> Self { - Self { - primary: default_color_primary(), - secondary: default_color_secondary(), - accent: default_color_accent(), - warning: default_color_warning(), - muted: default_color_muted(), - } - } -} - -fn default_backstage_enabled() -> bool { - true -} - -fn default_backstage_port() -> u16 { - 7007 -} - -fn default_backstage_subpath() -> String { - "backstage".to_string() -} - -fn default_branding_subpath() -> String { - "branding".to_string() -} - -fn default_backstage_release_url() -> String { - "https://github.com/untra/operator/releases/latest/download".to_string() -} - -impl Default for BackstageConfig { - fn default() -> Self { - Self { - enabled: default_backstage_enabled(), - display: false, - port: default_backstage_port(), - auto_start: false, - subpath: default_backstage_subpath(), - branding_subpath: default_branding_subpath(), - release_url: default_backstage_release_url(), - local_binary_path: None, - branding: BrandingConfig::default(), - } - } -} diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index 5c784718..45f09328 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -221,6 +221,28 @@ fn test_effective_max_agents_reserves_cores() { assert!(effective <= cpu_count.saturating_sub(config.agents.cores_reserved)); } +// --- effective_max_agents_per_repo tests --- + +#[test] +fn test_effective_max_agents_per_repo_default() { + let config = Config::default(); + assert_eq!(config.effective_max_agents_per_repo(), 1); +} + +#[test] +fn test_effective_max_agents_per_repo_clamps_zero() { + let mut config = Config::default(); + config.agents.max_agents_per_repo = 0; + assert_eq!(config.effective_max_agents_per_repo(), 1); +} + +#[test] +fn test_effective_max_agents_per_repo_custom() { + let mut config = Config::default(); + config.agents.max_agents_per_repo = 3; + assert_eq!(config.effective_max_agents_per_repo(), 3); +} + // --- Path resolution tests --- #[test] @@ -532,6 +554,119 @@ fn test_relay_config_default_auto_inject_is_false() { assert!(!config.relay.auto_inject_mcp); } +// --- ExternalMcpServer tests --- + +#[test] +fn test_mcp_external_servers_defaults_to_empty() { + let config = McpConfig::default(); + assert!(config.external_servers.is_empty()); +} + +#[test] +fn test_mcp_config_without_external_servers_still_parses() { + let toml_str = r" + http_enabled = true + stdio_advertised = false + expose_ticket_write_tools = true + "; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert!(config.http_enabled); + assert!(!config.stdio_advertised); + assert!(config.expose_ticket_write_tools); + assert!(config.external_servers.is_empty()); +} + +#[test] +fn test_external_mcp_server_static_config_roundtrip() { + let toml_str = r#" + [[external_servers]] + name = "my-tools" + command = "/usr/local/bin/my-mcp-server" + args = ["--stdio"] + env = { API_KEY = "${MY_TOOLS_API_KEY}" } + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.external_servers.len(), 1); + let server = &config.external_servers[0]; + assert_eq!(server.name, "my-tools"); + assert_eq!(server.command, "/usr/local/bin/my-mcp-server"); + assert_eq!(server.args, vec!["--stdio"]); + assert_eq!(server.env.get("API_KEY").unwrap(), "${MY_TOOLS_API_KEY}"); + assert!(server.enabled); + assert!(server.discover_from.is_none()); +} + +#[test] +fn test_external_mcp_server_sidecar_config_roundtrip() { + let toml_str = r#" + [[external_servers]] + name = "kanbots" + command = "" + discover_from = ".kanbots/active-session.json" + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.external_servers.len(), 1); + let server = &config.external_servers[0]; + assert_eq!(server.name, "kanbots"); + assert_eq!(server.command, ""); + assert_eq!( + server.discover_from.as_deref(), + Some(".kanbots/active-session.json") + ); + assert!(server.enabled); +} + +#[test] +fn test_external_mcp_server_disabled() { + let toml_str = r#" + [[external_servers]] + name = "disabled-server" + command = "some-binary" + enabled = false + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.external_servers[0].enabled); +} + +#[test] +fn test_external_mcp_server_multiple_servers() { + let toml_str = r#" + [[external_servers]] + name = "kanbots" + command = "" + discover_from = ".kanbots/active-session.json" + + [[external_servers]] + name = "my-tools" + command = "/usr/local/bin/my-mcp" + args = ["--port", "9090"] + "#; + let config: McpConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.external_servers.len(), 2); + assert_eq!(config.external_servers[0].name, "kanbots"); + assert_eq!(config.external_servers[1].name, "my-tools"); +} + +#[test] +fn test_external_mcp_server_json_serde_roundtrip() { + let server = ExternalMcpServer { + name: "test".to_string(), + command: "/bin/test".to_string(), + args: vec!["--flag".to_string()], + env: std::collections::HashMap::from([("KEY".to_string(), "val".to_string())]), + enabled: true, + discover_from: Some("/tmp/sidecar.json".to_string()), + }; + let json = serde_json::to_string(&server).unwrap(); + let parsed: ExternalMcpServer = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.name, "test"); + assert_eq!(parsed.command, "/bin/test"); + assert_eq!(parsed.args, vec!["--flag"]); + assert_eq!(parsed.env.get("KEY").unwrap(), "val"); + assert!(parsed.enabled); + assert_eq!(parsed.discover_from.as_deref(), Some("/tmp/sidecar.json")); +} + #[test] fn test_delegator_launch_config_operator_relay_defaults_to_none() { let toml_str = r#" diff --git a/src/config/kanban.rs b/src/config/kanban.rs index da6827a2..d35b4a0f 100644 --- a/src/config/kanban.rs +++ b/src/config/kanban.rs @@ -232,7 +232,7 @@ impl KanbanConfig { /// /// Delegates to the provider-specific upsert method based on the /// `WorkspaceExtra` variant in the validated workspace. - #[allow(dead_code)] // Will be used by onboarding service in Phase 1b + #[allow(dead_code)] // Used in tests pub fn upsert_project( &mut self, workspace: &crate::api::providers::kanban::ValidatedWorkspace, diff --git a/src/config/sessions.rs b/src/config/sessions.rs index f88fe79f..32243336 100644 --- a/src/config/sessions.rs +++ b/src/config/sessions.rs @@ -28,6 +28,26 @@ impl SessionWrapperType { SessionWrapperType::Zellij => "zellij", } } + + /// Whether the operator process is currently running *inside* this wrapper's + /// session context, detected from process-global environment markers. + /// + /// Reports the control wrapper of the API: launched tickets are coordinated + /// through this wrapper, so "active" means operator can actually drive it. + /// tmux sets `TMUX`, cmux sets `CMUX_WORKSPACE_ID`, zellij sets `ZELLIJ`, and + /// VS Code's integrated terminal sets `TERM_PROGRAM=vscode`. These are the + /// same env names checked by the wrapper detection in `status_panel` and + /// `agents::{cmux,zellij}` — reuse, don't invent new ones. + pub fn is_active_context(&self) -> bool { + match self { + SessionWrapperType::Tmux => std::env::var("TMUX").is_ok(), + SessionWrapperType::Vscode => { + std::env::var("TERM_PROGRAM").is_ok_and(|v| v == "vscode") + } + SessionWrapperType::Cmux => std::env::var("CMUX_WORKSPACE_ID").is_ok(), + SessionWrapperType::Zellij => std::env::var("ZELLIJ").is_ok(), + } + } } impl std::fmt::Display for SessionWrapperType { diff --git a/src/docs_gen/config.rs b/src/docs_gen/config.rs index 80b0ed21..fa0c2648 100644 --- a/src/docs_gen/config.rs +++ b/src/docs_gen/config.rs @@ -69,11 +69,6 @@ const CONFIG_SECTIONS: &[ConfigSection] = &[ schema_name: "TmuxConfig", description: "Tmux integration settings", }, - ConfigSection { - name: "backstage", - schema_name: "BackstageConfig", - description: "Backstage server integration", - }, ConfigSection { name: "llm_tools", schema_name: "LlmToolsConfig", @@ -338,14 +333,6 @@ impl ConfigDocGenerator { "to_file" => Some(config.logging.to_file.to_string()), _ => None, }, - "backstage" => match field { - "enabled" => Some(config.backstage.enabled.to_string()), - "port" => Some(config.backstage.port.to_string()), - "auto_start" => Some(config.backstage.auto_start.to_string()), - "subpath" => Some(config.backstage.subpath), - "branding_subpath" => Some(config.backstage.branding_subpath), - _ => None, - }, _ => None, }; @@ -381,7 +368,7 @@ impl ConfigDocGenerator { output.push_str("**Examples**:\n"); output.push_str("- `OPERATOR_AGENTS__MAX_PARALLEL=2`\n"); output.push_str("- `OPERATOR_LOGGING__LEVEL=debug`\n"); - output.push_str("- `OPERATOR_BACKSTAGE__PORT=8080`\n\n"); + output.push_str("- `OPERATOR_TMUX__ENABLED=true`\n\n"); output } @@ -411,8 +398,6 @@ mod tests { assert!(result.contains("## `[notifications]`")); assert!(result.contains("## `[queue]`")); assert!(result.contains("## `[paths]`")); - assert!(result.contains("## `[backstage]`")); - // Should have example config assert!(result.contains("## Example Configuration")); assert!(result.contains("```toml")); diff --git a/src/docs_gen/project_analysis_schema.rs b/src/docs_gen/project_analysis_schema.rs index 3cddd1e7..4dde98d2 100644 --- a/src/docs_gen/project_analysis_schema.rs +++ b/src/docs_gen/project_analysis_schema.rs @@ -4,7 +4,7 @@ //! via schemars, making Rust the single source of truth for structured output. use super::DocGenerator; -use crate::backstage::analyzer::ProjectAnalysis; +use crate::taxonomy::analyzer::ProjectAnalysis; use anyhow::Result; use schemars::schema_for; @@ -17,7 +17,7 @@ impl DocGenerator for ProjectAnalysisSchemaDocGenerator { } fn source(&self) -> &'static str { - "src/backstage/analyzer.rs (ProjectAnalysis)" + "src/taxonomy/analyzer.rs (ProjectAnalysis)" } fn output_path(&self) -> &'static str { @@ -43,7 +43,7 @@ impl DocGenerator for ProjectAnalysisSchemaDocGenerator { obj.insert( "$comment".to_string(), serde_json::Value::String( - "AUTO-GENERATED FROM src/backstage/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema".to_string(), + "AUTO-GENERATED FROM src/taxonomy/analyzer.rs - DO NOT EDIT. Regenerate with: cargo run -- docs --only project-analysis-schema".to_string(), ), ); } diff --git a/src/docs_gen/taxonomy.rs b/src/docs_gen/taxonomy.rs index f98f5c3d..995d1ea6 100644 --- a/src/docs_gen/taxonomy.rs +++ b/src/docs_gen/taxonomy.rs @@ -2,7 +2,7 @@ use super::markdown::{bold, bullet_list, heading, inline_code, table}; use super::{format_header, DocGenerator}; -use crate::backstage::taxonomy::{KindTier, Taxonomy}; +use crate::taxonomy::{KindTier, Taxonomy}; use anyhow::Result; /// Generates taxonomy documentation from taxonomy.toml @@ -14,11 +14,11 @@ impl DocGenerator for TaxonomyDocGenerator { } fn source(&self) -> &'static str { - "src/backstage/taxonomy.toml" + "src/taxonomy/taxonomy.toml" } fn output_path(&self) -> &'static str { - "backstage/taxonomy.md" + "taxonomy/index.md" } fn generate(&self) -> Result { @@ -33,7 +33,7 @@ impl DocGenerator for TaxonomyDocGenerator { taxonomy.tiers.len() )); output.push_str( - "Each Kind represents a category of project that can be cataloged in Backstage. ", + "Each Kind represents a category of project that can be classified by Operator. ", ); output.push_str("The taxonomy is used by the `ASSESS` issue type to classify projects and generate `catalog-info.yaml` files.\n\n"); @@ -63,8 +63,8 @@ impl DocGenerator for TaxonomyDocGenerator { // File pattern reference output.push_str(&self.generate_pattern_reference(taxonomy)); - // Backstage type mapping - output.push_str(&self.generate_backstage_mapping(taxonomy)); + // Catalog type mapping + output.push_str(&self.generate_catalog_type_mapping(taxonomy)); Ok(output) } @@ -72,7 +72,7 @@ impl DocGenerator for TaxonomyDocGenerator { impl TaxonomyDocGenerator { fn generate_summary_table(&self, taxonomy: &Taxonomy) -> String { - let headers = &["ID", "Key", "Name", "Tier", "Backstage Type"]; + let headers = &["ID", "Key", "Name", "Tier", "Catalog Type"]; let rows: Vec> = taxonomy .kinds .iter() @@ -82,7 +82,7 @@ impl TaxonomyDocGenerator { inline_code(&k.key), k.name.clone(), k.tier.clone(), - inline_code(&k.backstage_type), + inline_code(&k.catalog_type), ] }) .collect(); @@ -136,8 +136,8 @@ impl TaxonomyDocGenerator { format!("{}: {}", bold("Primary Output"), &kind.output), format!( "{}: {}", - bold("Backstage Type"), - inline_code(&kind.backstage_type) + bold("Catalog Type"), + inline_code(&kind.catalog_type) ), ]; output.push_str(&bullet_list(&details)); @@ -181,17 +181,17 @@ impl TaxonomyDocGenerator { output } - fn generate_backstage_mapping(&self, taxonomy: &Taxonomy) -> String { - let mut output = heading(2, "Backstage Type Mapping"); - output.push_str("Each Kind maps to a Backstage catalog type:\n\n"); + fn generate_catalog_type_mapping(&self, taxonomy: &Taxonomy) -> String { + let mut output = heading(2, "Catalog Type Mapping"); + output.push_str("Each Kind maps to a catalog type:\n\n"); - let headers = &["Backstage Type", "Kinds"]; + let headers = &["Catalog Type", "Kinds"]; let mut type_map: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new(); for kind in &taxonomy.kinds { type_map - .entry(&kind.backstage_type) + .entry(&kind.catalog_type) .or_default() .push(&kind.key); } @@ -210,7 +210,7 @@ impl TaxonomyDocGenerator { }) .collect(); - // Sort by backstage type + // Sort by catalog type rows.sort_by(|a, b| a[0].cmp(&b[0])); output.push_str(&table(headers, &rows)); diff --git a/src/env_vars.rs b/src/env_vars.rs index ef6aee6b..45e92567 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -44,8 +44,6 @@ pub enum EnvVarCategory { Launch, /// Tmux integration Tmux, - /// Backstage server settings - Backstage, /// LLM tool allowlist/denylist LlmTools, /// Logging configuration @@ -64,7 +62,6 @@ impl EnvVarCategory { EnvVarCategory::Ui => "UI", EnvVarCategory::Launch => "Launch", EnvVarCategory::Tmux => "Tmux", - EnvVarCategory::Backstage => "Backstage", EnvVarCategory::LlmTools => "LLM Tools", EnvVarCategory::Logging => "Logging", } @@ -81,7 +78,6 @@ impl EnvVarCategory { EnvVarCategory::Ui, EnvVarCategory::Launch, EnvVarCategory::Tmux, - EnvVarCategory::Backstage, EnvVarCategory::LlmTools, EnvVarCategory::Logging, ] @@ -306,23 +302,6 @@ pub static ENV_VARS: &[EnvVar] = &[ default: Some("operator"), example: Some("agent"), }, - // === Backstage === - EnvVar { - name: "OPERATOR_BACKSTAGE__PORT", - description: "Port for the Backstage web server", - category: EnvVarCategory::Backstage, - required: false, - default: Some("3000"), - example: Some("8080"), - }, - EnvVar { - name: "OPERATOR_BACKSTAGE__AUTO_START", - description: "Automatically start Backstage server with TUI", - category: EnvVarCategory::Backstage, - required: false, - default: Some("false"), - example: Some("true"), - }, // === LLM Tools === EnvVar { name: "OPERATOR_LLM_TOOLS__ENABLED", @@ -442,7 +421,6 @@ mod tests { assert_eq!(EnvVarCategory::Ui.display_name(), "UI"); assert_eq!(EnvVarCategory::Launch.display_name(), "Launch"); assert_eq!(EnvVarCategory::Tmux.display_name(), "Tmux"); - assert_eq!(EnvVarCategory::Backstage.display_name(), "Backstage"); assert_eq!(EnvVarCategory::LlmTools.display_name(), "LLM Tools"); assert_eq!(EnvVarCategory::Logging.display_name(), "Logging"); } @@ -450,8 +428,8 @@ mod tests { #[test] fn test_all_categories_in_order() { let all = EnvVarCategory::all(); - assert_eq!(all.len(), 11); + assert_eq!(all.len(), 10); assert_eq!(all[0], EnvVarCategory::Authentication); - assert_eq!(all[10], EnvVarCategory::Logging); + assert_eq!(all[9], EnvVarCategory::Logging); } } diff --git a/src/integrations/inventory.rs b/src/integrations/inventory.rs new file mode 100644 index 00000000..ca410907 --- /dev/null +++ b/src/integrations/inventory.rs @@ -0,0 +1,165 @@ +//! Capability inventory for surface parity testing. +//! +//! Defines every operator capability and which surfaces expose it: +//! slash commands (Zed), MCP tools, REST routes, and TUI keybindings. + +/// A single operator capability and the surfaces where it appears. +#[derive(Debug, Clone)] +pub struct Capability { + /// Human-readable capability name + pub name: &'static str, + /// Zed slash command key (e.g. "op-pause"), or None if not exposed + pub slash_command: Option<&'static str>, + /// MCP tool name (e.g. "`operator_pause_queue`"), or None if not exposed + pub mcp_tool: Option<&'static str>, + /// REST endpoint in "METHOD /path" form (axum-style params), or None + pub rest_endpoint: Option<&'static str>, + /// TUI keybinding description substring to match, or None + pub tui_action: Option<&'static str>, +} + +/// Returns the full list of operator capabilities with their surface mappings. +/// +/// Each entry declares which surfaces expose that capability. A `None` value +/// means the capability is intentionally absent from that surface. +pub fn all_capabilities() -> Vec { + vec![ + Capability { + name: "Status", + slash_command: Some("op-status"), + mcp_tool: Some("operator_status"), + rest_endpoint: Some("GET /api/v1/status"), + tui_action: None, // status panel is a view, not a keybinding action + }, + Capability { + name: "List Queue", + slash_command: Some("op-queue"), + mcp_tool: Some("operator_list_tickets"), + rest_endpoint: Some("GET /api/v1/queue/kanban"), + tui_action: Some("Focus Queue panel"), + }, + Capability { + name: "Launch Ticket", + slash_command: Some("op-launch"), + mcp_tool: Some("operator_launch_ticket"), + rest_endpoint: Some("POST /api/v1/tickets/:id/launch"), + tui_action: Some("Launch selected ticket"), + }, + Capability { + name: "Active Agents", + slash_command: Some("op-active"), + mcp_tool: None, + rest_endpoint: Some("GET /api/v1/agents/active"), + tui_action: Some("Focus Agents panel"), + }, + Capability { + name: "Completed Tickets", + slash_command: Some("op-completed"), + mcp_tool: Some("operator_list_tickets"), + rest_endpoint: None, + tui_action: None, + }, + Capability { + name: "Ticket Details", + slash_command: Some("op-ticket"), + mcp_tool: None, + rest_endpoint: Some("GET /api/v1/tickets/:id"), + tui_action: None, + }, + Capability { + name: "Pause Queue", + slash_command: Some("op-pause"), + mcp_tool: Some("operator_pause_queue"), + rest_endpoint: Some("POST /api/v1/queue/pause"), + tui_action: Some("Pause queue"), + }, + Capability { + name: "Resume Queue", + slash_command: Some("op-resume"), + mcp_tool: Some("operator_resume_queue"), + rest_endpoint: Some("POST /api/v1/queue/resume"), + tui_action: Some("Resume queue"), + }, + Capability { + name: "Sync Kanban", + slash_command: Some("op-sync"), + mcp_tool: Some("operator_sync_kanban"), + rest_endpoint: Some("POST /api/v1/queue/sync"), + tui_action: Some("Sync kanban"), + }, + Capability { + name: "Approve Review", + slash_command: Some("op-approve"), + mcp_tool: Some("operator_approve_agent"), + rest_endpoint: Some("POST /api/v1/agents/:agent_id/approve"), + tui_action: Some("Approve review"), + }, + Capability { + name: "Reject Review", + slash_command: Some("op-reject"), + mcp_tool: Some("operator_reject_agent"), + rest_endpoint: Some("POST /api/v1/agents/:agent_id/reject"), + tui_action: Some("Reject review"), + }, + Capability { + name: "Setup Agent", + slash_command: Some("op-setup-agent"), + mcp_tool: None, + rest_endpoint: None, + tui_action: None, // Zed-only capability + }, + Capability { + name: "Setup", + slash_command: Some("op-setup"), + mcp_tool: None, + rest_endpoint: None, + tui_action: None, // Zed-only diagnostic command + }, + Capability { + name: "Help", + slash_command: Some("op-help"), + mcp_tool: None, + rest_endpoint: None, + tui_action: None, // Zed-only help listing + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_capabilities_non_empty() { + let caps = all_capabilities(); + assert!(!caps.is_empty(), "Capability list should not be empty"); + } + + #[test] + fn test_all_capabilities_have_names() { + for cap in all_capabilities() { + assert!(!cap.name.is_empty(), "Every capability must have a name"); + } + } + + #[test] + fn test_all_capabilities_have_at_least_one_surface() { + for cap in all_capabilities() { + let has_any = cap.slash_command.is_some() + || cap.mcp_tool.is_some() + || cap.rest_endpoint.is_some() + || cap.tui_action.is_some(); + assert!( + has_any, + "Capability '{}' must appear on at least one surface", + cap.name + ); + } + } + + #[test] + fn test_capability_count() { + let caps = all_capabilities(); + assert_eq!(caps.len(), 14, "Expected 14 capabilities in the inventory"); + } +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs new file mode 100644 index 00000000..0164a92c --- /dev/null +++ b/src/integrations/mod.rs @@ -0,0 +1,9 @@ +//! Integration surface inventory. +//! +//! Re-exports the capability inventory used by surface parity tests +//! to ensure slash commands, MCP tools, REST routes, and TUI actions +//! stay aligned. + +pub mod inventory; + +pub use inventory::{all_capabilities, Capability}; diff --git a/src/issuetypes/collection.rs b/src/issuetypes/collection.rs index 2798870c..6dc1f3df 100644 --- a/src/issuetypes/collection.rs +++ b/src/issuetypes/collection.rs @@ -98,10 +98,10 @@ pub enum BuiltinPreset { DevKanban, /// DevOps Kanban: TASK, SPIKE, INV, FEAT, FIX DevopsKanban, - /// Operator: ASSESS, SYNC, INIT (Backstage operations) + /// Operator: ASSESS, SYNC, INIT (automation operations) Operator, - /// Backstage Full: DevOps + Operator types - BackstageFull, + /// Full: DevOps + Operator types + Full, } impl BuiltinPreset { @@ -112,7 +112,7 @@ impl BuiltinPreset { BuiltinPreset::DevKanban, BuiltinPreset::DevopsKanban, BuiltinPreset::Operator, - BuiltinPreset::BackstageFull, + BuiltinPreset::Full, ] } @@ -123,7 +123,7 @@ impl BuiltinPreset { BuiltinPreset::DevKanban => "dev_kanban", BuiltinPreset::DevopsKanban => "devops_kanban", BuiltinPreset::Operator => "operator", - BuiltinPreset::BackstageFull => "backstage_full", + BuiltinPreset::Full => "full", } } @@ -133,8 +133,8 @@ impl BuiltinPreset { BuiltinPreset::Simple => "Simple workflow with TASK only", BuiltinPreset::DevKanban => "Developer kanban with TASK, FEAT, FIX", BuiltinPreset::DevopsKanban => "DevOps kanban with TASK, SPIKE, INV, FEAT, FIX", - BuiltinPreset::Operator => "Operator Backstage tasks: ASSESS, SYNC, INIT", - BuiltinPreset::BackstageFull => "Full workflow plus Backstage: all types combined", + BuiltinPreset::Operator => "Operator automation tasks: ASSESS, SYNC, INIT", + BuiltinPreset::Full => "Full workflow: all types combined", } } @@ -152,8 +152,8 @@ impl BuiltinPreset { } BuiltinPreset::Operator => IssueTypeCollection::new("operator", self.description()) .with_types(["ASSESS", "SYNC", "INIT"]), - BuiltinPreset::BackstageFull => { - IssueTypeCollection::new("backstage_full", self.description()).with_types([ + BuiltinPreset::Full => { + IssueTypeCollection::new("full", self.description()).with_types([ "TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT", ]) } @@ -167,7 +167,7 @@ impl BuiltinPreset { "dev_kanban" | "devkanban" => Some(BuiltinPreset::DevKanban), "devops_kanban" | "devopskanban" => Some(BuiltinPreset::DevopsKanban), "operator" => Some(BuiltinPreset::Operator), - "backstage_full" | "backstagefull" => Some(BuiltinPreset::BackstageFull), + "full" => Some(BuiltinPreset::Full), _ => None, } } @@ -267,10 +267,7 @@ mod tests { BuiltinPreset::from_name("operator"), Some(BuiltinPreset::Operator) ); - assert_eq!( - BuiltinPreset::from_name("backstage_full"), - Some(BuiltinPreset::BackstageFull) - ); + assert_eq!(BuiltinPreset::from_name("full"), Some(BuiltinPreset::Full)); assert_eq!(BuiltinPreset::from_name("unknown"), None); } @@ -282,9 +279,9 @@ mod tests { } #[test] - fn test_builtin_backstage_full() { - let collection = BuiltinPreset::BackstageFull.into_collection(); - assert_eq!(collection.name, "backstage_full"); + fn test_builtin_full() { + let collection = BuiltinPreset::Full.into_collection(); + assert_eq!(collection.name, "full"); assert_eq!( collection.types, vec!["TASK", "FEAT", "FIX", "SPIKE", "INV", "ASSESS", "SYNC", "INIT"] diff --git a/src/issuetypes/schema.rs b/src/issuetypes/schema.rs index 4d3900c5..3eeb5e89 100644 --- a/src/issuetypes/schema.rs +++ b/src/issuetypes/schema.rs @@ -179,6 +179,7 @@ impl IssueType { multi_model_config: None, multi_prompt_config: None, matrixed_config: None, + pipeline_config: None, }], agent_prompt: None, agent: None, @@ -356,6 +357,7 @@ mod tests { multi_model_config: None, multi_prompt_config: None, matrixed_config: None, + pipeline_config: None, }], agent_prompt: None, agent: None, diff --git a/src/lib.rs b/src/lib.rs index 32cc310d..d77ec8d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,6 @@ pub mod state; pub mod types; // Internal modules required by public modules -mod backstage; mod collections; mod issuetypes; mod llm; @@ -28,14 +27,27 @@ mod projects; mod services; mod startup; mod steps; +#[allow(dead_code)] +pub mod taxonomy; mod templates; pub mod version; +// Integration surface inventory (capability parity across surfaces) +pub mod integrations; + // MCP server bridge pub mod mcp; +// ACP agent bridge (Agent Client Protocol — editor-hosted sessions over stdio) +pub mod acp; + // Re-export env_vars for potential external use pub mod env_vars; // Relay hub and channel client pub mod relay; + +// Workflow export (ticket + issuetype -> Claude dynamic workflow .js). +// Declared here (in addition to the bin) so the REST layer, which compiles in +// both the lib and bin crates, can reach it. +pub mod workflow_gen; diff --git a/src/llm/detection.rs b/src/llm/detection.rs index 79cd1eff..42579c71 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -11,7 +11,7 @@ use crate::config::{DetectedTool, LlmProvider, LlmToolsConfig, ToolCapabilities} use super::tool_config::{load_all_tool_configs, ToolConfig}; /// Detect all available LLM CLI tools and build the config -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary, not reachable from lib.rs pub fn detect_all_tools() -> LlmToolsConfig { let tool_configs = load_all_tool_configs(); let mut detected = Vec::new(); diff --git a/src/main.rs b/src/main.rs index dc17b0a0..1333d319 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; mod api; mod app; -mod backstage; mod collections; mod config; mod editors; @@ -18,9 +17,12 @@ mod projects; mod services; mod state; mod steps; +#[allow(dead_code)] +mod taxonomy; mod templates; mod types; +mod acp; mod agents; mod docs_gen; pub mod env_vars; @@ -33,6 +35,7 @@ mod setup; mod startup; mod ui; mod version; +mod workflow_gen; use agents::tmux::{SystemTmuxClient, TmuxClient, TmuxError}; use app::App; @@ -126,6 +129,10 @@ pub struct Cli { /// Start with web view enabled #[arg(short = 'w', long)] web: bool, + + /// Open the embedded web UI in a browser on launch + #[arg(long)] + ui: bool, } #[derive(Subcommand)] @@ -227,8 +234,25 @@ enum Commands { /// Port to listen on (default: 7008) #[arg(short, long)] port: Option, + + /// Open the web UI in browser after server starts + #[arg(long)] + open: bool, }, + /// Run as an MCP stdio server (for use by Claude Code, Cursor, Zed, `JetBrains`, etc.). + /// + /// Reads line-delimited JSON-RPC from stdin and writes responses to stdout. + /// Log output goes to stderr. Intended to be spawned by an MCP-capable client. + Mcp, + + /// Run as an ACP agent over stdio (for use by Zed, `JetBrains`, Emacs `agent-shell`, etc.). + /// + /// Implements the Agent Client Protocol. Reads line-delimited JSON-RPC + /// from stdin and writes responses/notifications to stdout. Log output + /// goes to stderr. Intended to be spawned by an ACP-capable editor. + Acp, + /// Initialize operator workspace (non-interactive by default) Setup { /// Launch TUI setup wizard instead of non-interactive setup @@ -239,10 +263,6 @@ enum Commands { #[arg(short = 'C', long, default_value = "simple")] collection: String, - /// Enable backstage configuration - #[arg(long)] - backstage: bool, - /// Overwrite existing files #[arg(short, long)] force: bool, @@ -263,6 +283,25 @@ enum Commands { #[arg(long)] skip_llm_detection: bool, }, + + /// Convert between operator issuetypes and other orchestration formats + Workflow { + #[command(subcommand)] + action: WorkflowAction, + }, +} + +#[derive(Subcommand)] +enum WorkflowAction { + /// Export a ticket, rendered against its issuetype, to a Claude Code dynamic workflow (.js) + 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) + #[arg(short, long)] + out: Option, + }, } #[tokio::main] @@ -278,6 +317,28 @@ async fn main() -> Result<()> { // Initialize logging (file-based for TUI, stderr for CLI) let logging_handle = logging::init_logging(&config, is_tui_mode, cli.debug)?; + // Inject the status-section provider into the REST layer. The section logic + // lives in `ui` (which `rest` can't depend on — see rest::dto::sections), so + // the binary registers it here, before any server starts. Covers all serving + // paths (TUI app, `operator rest`, embedded UI) since they share one process. + rest::dto::register_section_provider(std::sync::Arc::new(|config, registry, live| { + let issue_types = registry + .all_types() + .map(|it| ui::status_panel::IssueTypeInfo { + key: it.key.clone(), + name: it.name.clone(), + mode: if it.is_autonomous() { + "autonomous".to_string() + } else { + "paired".to_string() + }, + }) + .collect(); + let mut snapshot = ui::status_panel::StatusSnapshot::from_config(config, issue_types); + snapshot.apply_api_connection(live); + ui::status_panel::build_section_dtos(&snapshot) + })); + match cli.command { Some(Commands::Queue { all }) => { cmd_queue(&config, all).await?; @@ -329,13 +390,18 @@ async fn main() -> Result<()> { Some(Commands::Docs { output, only }) => { cmd_docs(&config, output, only)?; } - Some(Commands::Api { port }) => { - cmd_api(&config, port).await?; + Some(Commands::Api { port, open }) => { + cmd_api(&config, port, open).await?; + } + Some(Commands::Mcp) => { + cmd_mcp(&config).await?; + } + Some(Commands::Acp) => { + cmd_acp(&config).await?; } Some(Commands::Setup { interactive, collection, - backstage, force, working_dir, kanban_provider, @@ -346,7 +412,6 @@ async fn main() -> Result<()> { config, interactive, collection, - backstage, force, working_dir, kanban_provider, @@ -354,24 +419,32 @@ async fn main() -> Result<()> { skip_llm_detection, )?; } + Some(Commands::Workflow { action }) => { + cmd_workflow(&config, action)?; + } None => { // No subcommand = launch TUI dashboard #[allow(clippy::large_futures)] // TUI state is inherently large - run_tui(config, logging_handle.log_file_path, cli.web).await?; + run_tui(config, logging_handle.log_file_path, cli.web, cli.ui).await?; } } Ok(()) } -async fn run_tui(config: Config, log_file_path: Option, start_web: bool) -> Result<()> { +async fn run_tui( + config: Config, + log_file_path: Option, + start_web: bool, + open_ui: bool, +) -> Result<()> { // Install panic hook before any terminal operations // This ensures terminal is restored even on panic crate::ui::install_panic_hook(); // Note: tmux availability is now checked in the setup wizard (TmuxOnboarding step) // when the user selects tmux as their session wrapper - let mut app = App::new(config, start_web).await?; + let mut app = App::new(config, start_web, open_ui).await?; let result = app.run().await; // Print log file path on exit if logs were written @@ -420,7 +493,8 @@ async fn cmd_queue(config: &Config, all: bool) -> Result<()> { /// Ad-hoc launch overrides parsed from CLI flags. /// -/// v1: flags parse and validate; env-var injection for `model_server` ships in v2. +/// Flags are validated up front, then resolved through `resolve_launch_options`, +/// which injects the chosen `model_server`'s env vars into the spawned agent. #[derive(Debug, Default)] struct LaunchOverrides { delegator: Option, @@ -458,10 +532,20 @@ async fn cmd_launch( ); } - // TODO(model-servers-v2): thread `overrides` through resolve_launch_options() so the chosen - // model_server's env vars (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, etc.) are exported before the - // agent CLI spawns. v1 parses and validates; resolution path is unchanged. - let _ = &overrides; + // Resolve launch options from the CLI overrides (a named delegator, or the + // ad-hoc --llm-tool/--model/--model-server trio). The chosen model server's + // env vars (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, …) are threaded into + // LaunchOptions.provider.env and exported before the agent CLI spawns. + let launch_options = crate::agents::delegator_resolution::resolve_launch_options( + config, + overrides.delegator.as_deref(), + overrides.llm_tool.as_deref(), + overrides.model.as_deref(), + overrides.model_server.as_deref(), + false, + None, + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; // Check tmux availability before launching if let Err(err) = check_tmux_available() { @@ -518,7 +602,9 @@ async fn cmd_launch( // Launch agent let launcher = agents::Launcher::new(config)?; - launcher.launch(&ticket).await?; + launcher + .launch_with_options(&ticket, launch_options) + .await?; println!("Launched agent for {}-{}", ticket.ticket_type, ticket.id); @@ -669,6 +755,48 @@ async fn cmd_create( Ok(()) } +fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { + match action { + WorkflowAction::Export { ticket, out } => { + // 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. + let resolved = { + let path = std::path::Path::new(&ticket); + if path.is_file() { + queue::Ticket::from_file(path)? + } else { + let queue = queue::Queue::new(config)?; + queue + .find_ticket(&ticket)? + .ok_or_else(|| anyhow::anyhow!("Ticket not found: {ticket}"))? + } + }; + + // 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)?; + + match out.as_deref() { + Some(p) if p == std::path::Path::new("-") => { + print!("{}", exported.contents); + } + Some(p) => { + std::fs::write(p, &exported.contents)?; + println!("Wrote workflow to {}", p.display()); + } + None => { + let default = PathBuf::from(&exported.suggested_filename); + std::fs::write(&default, &exported.contents)?; + println!("Wrote workflow to {}", default.display()); + } + } + } + } + Ok(()) +} + 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, @@ -775,7 +903,7 @@ fn cmd_docs(_config: &Config, output: Option, only: Option) -> R Ok(()) } -async fn cmd_api(config: &Config, port: Option) -> Result<()> { +async fn cmd_api(config: &Config, port: Option, open: bool) -> Result<()> { let port = port.unwrap_or(config.rest_api.port); println!("Starting REST API server..."); @@ -790,18 +918,55 @@ async fn cmd_api(config: &Config, port: Option) -> Result<()> { println!(" GET /api/v1/collections List collections"); println!(); + if open { + let url = format!("http://localhost:{port}/"); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let opener = if cfg!(target_os = "macos") { + "open" + } else if cfg!(target_os = "windows") { + "cmd" + } else { + "xdg-open" + }; + if cfg!(target_os = "windows") { + let _ = std::process::Command::new(opener) + .args(["/C", "start", &url]) + .spawn(); + } else { + let _ = std::process::Command::new(opener).arg(&url).spawn(); + } + }); + } + let state = rest::ApiState::new(config.clone(), config.tickets_path()); rest::serve(state, port).await?; Ok(()) } +async fn cmd_mcp(config: &Config) -> Result<()> { + let state = rest::ApiState::new(config.clone(), config.tickets_path()); + tracing::info!("Starting MCP stdio server"); + mcp::stdio::run(state, tokio::io::stdin(), tokio::io::stdout()).await?; + tracing::info!("MCP stdio server stopped (stdin closed)"); + Ok(()) +} + +async fn cmd_acp(config: &Config) -> Result<()> { + tracing::info!("Starting ACP stdio agent"); + acp::run_stdio(config.clone()) + .await + .map_err(|e| anyhow::anyhow!("ACP transport error: {e:?}"))?; + tracing::info!("ACP agent stopped (stdin closed)"); + Ok(()) +} + #[allow(clippy::too_many_arguments)] fn cmd_setup( mut config: Config, interactive: bool, collection: String, - backstage: bool, force: bool, working_dir: Option, kanban_provider: Option, @@ -850,7 +1015,6 @@ fn cmd_setup( let options = SetupOptions { preset, - backstage_enabled: backstage, force, working_dir, kanban_provider, @@ -872,14 +1036,6 @@ fn cmd_setup( println!("Initializing operator workspace..."); println!(" Collection: {:?}", options.preset); - println!( - " Backstage: {}", - if options.backstage_enabled { - "enabled" - } else { - "disabled" - } - ); println!(" Force: {}", options.force); if let Some(ref dir) = options.working_dir { println!(" Working Dir: {}", dir.display()); diff --git a/src/mcp/client_configs.rs b/src/mcp/client_configs.rs new file mode 100644 index 00000000..41ce66dc --- /dev/null +++ b/src/mcp/client_configs.rs @@ -0,0 +1,133 @@ +//! Generates copy-paste MCP client configuration snippets pointing at this +//! operator binary. +//! +//! Each `*_snippet(cwd)` returns a `serde_json::Value` shaped the way the +//! target client's config file expects. The dashboard writes one of these to +//! `/operator/mcp/.json` and opens it in the user's editor; +//! the user pastes the contents into their actual client config. + +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +/// Path to the currently-running operator binary. Falls back to bare +/// "operator" if `current_exe` is unavailable (e.g. in some test contexts). +pub fn current_exe() -> PathBuf { + std::env::current_exe().unwrap_or_else(|_| PathBuf::from("operator")) +} + +/// Shape used by Claude Code (`~/.claude.json`), Claude Desktop, and Cursor +/// (`~/.cursor/mcp.json`). All three accept the same `mcpServers` block. +fn mcp_servers_shape(cwd: &Path) -> Value { + json!({ + "mcpServers": { + "operator": { + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +pub fn claude_code_snippet(cwd: &Path) -> Value { + mcp_servers_shape(cwd) +} + +pub fn claude_desktop_snippet(cwd: &Path) -> Value { + mcp_servers_shape(cwd) +} + +/// Cursor's `~/.cursor/mcp.json` uses the same `mcpServers` shape as Claude. +pub fn cursor_snippet(cwd: &Path) -> Value { + mcp_servers_shape(cwd) +} + +/// VS Code (1.94+) per-workspace `.vscode/mcp.json` uses a `servers` block +/// with an explicit `type: "stdio"` discriminator. +pub fn vscode_snippet(cwd: &Path) -> Value { + json!({ + "servers": { + "operator": { + "type": "stdio", + "command": current_exe().to_string_lossy(), + "args": ["mcp"], + "cwd": cwd.to_string_lossy(), + } + } + }) +} + +/// Zed user settings under `context_servers`. +pub fn zed_snippet(cwd: &Path) -> Value { + json!({ + "context_servers": { + "operator": { + "command": { + "path": current_exe().to_string_lossy(), + "args": ["mcp"], + "env": {} + }, + "settings": { "cwd": cwd.to_string_lossy() } + } + } + }) +} + +/// Dispatch by client name. Returns `None` for unknown clients. +pub fn snippet_for(client: &str, cwd: &Path) -> Option { + match client { + "claude-code" => Some(claude_code_snippet(cwd)), + "claude-desktop" => Some(claude_desktop_snippet(cwd)), + "cursor" => Some(cursor_snippet(cwd)), + "vscode" => Some(vscode_snippet(cwd)), + "zed" => Some(zed_snippet(cwd)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claude_code_snippet_shape() { + let cfg = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["mcpServers"]["operator"]["args"][0], "mcp"); + assert_eq!(cfg["mcpServers"]["operator"]["cwd"], "/work"); + } + + #[test] + fn test_cursor_snippet_matches_claude_code() { + let cursor = cursor_snippet(&PathBuf::from("/work")); + let claude = claude_code_snippet(&PathBuf::from("/work")); + assert_eq!(cursor, claude); + } + + #[test] + fn test_vscode_snippet_uses_servers_with_type() { + let cfg = vscode_snippet(&PathBuf::from("/work")); + assert_eq!(cfg["servers"]["operator"]["type"], "stdio"); + assert_eq!(cfg["servers"]["operator"]["args"][0], "mcp"); + } + + #[test] + fn test_zed_snippet_uses_context_servers() { + let cfg = zed_snippet(&PathBuf::from("/work")); + assert!(cfg["context_servers"]["operator"]["command"]["path"].is_string()); + } + + #[test] + fn test_snippet_for_unknown_client_is_none() { + assert!(snippet_for("notepad++", &PathBuf::from("/w")).is_none()); + } + + #[test] + fn test_snippet_for_dispatches_correctly() { + let cwd = PathBuf::from("/w"); + assert!(snippet_for("claude-code", &cwd).is_some()); + assert!(snippet_for("claude-desktop", &cwd).is_some()); + assert!(snippet_for("cursor", &cwd).is_some()); + assert!(snippet_for("vscode", &cwd).is_some()); + assert!(snippet_for("zed", &cwd).is_some()); + } +} diff --git a/src/mcp/descriptor.rs b/src/mcp/descriptor.rs index 6d71d5bd..aab1d3c4 100644 --- a/src/mcp/descriptor.rs +++ b/src/mcp/descriptor.rs @@ -1,15 +1,34 @@ //! MCP descriptor endpoint for client discovery. //! -//! Returns metadata needed to build a VS Code MCP deep link, -//! including server name, transport URL, and version. +//! Returns metadata needed to register operator with an MCP-capable client. +//! Includes both the SSE transport URL (for network clients like the +//! vscode-extension) and, optionally, the stdio entrypoint command so +//! clients can spawn `operator mcp` as a subprocess instead. -use axum::extract::Host; +use axum::extract::{Host, State}; use axum::Json; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; +use crate::rest::state::ApiState; + +/// Stdio entrypoint advertised in the descriptor when +/// `[mcp].stdio_advertised = true`. Clients use this to spawn operator +/// as an MCP subprocess instead of (or alongside) the SSE transport. +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct StdioCommand { + /// Absolute path to the operator binary (the same binary serving this descriptor) + pub command: String, + /// Args to pass: typically `["mcp"]` + pub args: Vec, + /// Working directory the client should set when spawning. Defaults to the + /// operator process's current working directory. + pub cwd: String, +} + /// MCP server descriptor for client discovery #[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -27,14 +46,19 @@ pub struct McpDescriptorResponse { /// URL of the OpenAPI spec for reference #[serde(skip_serializing_if = "Option::is_none")] pub openapi_url: Option, + /// Stdio transport entrypoint. Present when `[mcp].stdio_advertised = true`. + /// Clients may spawn this as a subprocess instead of using `transport_url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdio: Option, } /// MCP descriptor endpoint /// -/// Returns metadata for building a VS Code MCP deep link. -/// The transport URL is derived from the request Host header -/// so it reflects the actual running port. +/// Returns metadata for registering operator with an MCP-capable client. +/// The transport URL is derived from the request Host header so it reflects +/// the actual running port; the stdio entrypoint reflects this binary's path. #[utoipa::path( + operation_id = "mcp_descriptor", get, path = "/api/v1/mcp/descriptor", tag = "MCP", @@ -42,9 +66,30 @@ pub struct McpDescriptorResponse { (status = 200, description = "MCP server descriptor", body = McpDescriptorResponse) ) )] -pub async fn descriptor(Host(host): Host) -> Json { +pub async fn descriptor( + State(state): State, + Host(host): Host, +) -> Json { let base = format!("http://{host}"); + let stdio = if state.config.mcp.stdio_advertised { + let command = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| "operator".to_string()); + let cwd = std::env::current_dir() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_default(); + Some(StdioCommand { + command, + args: vec!["mcp".to_string()], + cwd, + }) + } else { + None + }; + Json(McpDescriptorResponse { server_name: "operator".to_string(), server_id: "operator-mcp".to_string(), @@ -52,16 +97,26 @@ pub async fn descriptor(Host(host): Host) -> Json { transport_url: format!("{base}/api/v1/mcp/sse"), label: "Operator MCP Server".to_string(), openapi_url: Some(format!("{base}/api-docs/openapi.json")), + stdio, }) } #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn state_with_stdio(advertised: bool) -> ApiState { + let mut config = Config::default(); + config.mcp.stdio_advertised = advertised; + ApiState::new(config, PathBuf::from("/tmp/test")) + } #[tokio::test] async fn test_descriptor_response() { - let resp = descriptor(Host("localhost:7008".to_string())).await; + let state = state_with_stdio(true); + let resp = descriptor(State(state), Host("localhost:7008".to_string())).await; assert_eq!(resp.server_name, "operator"); assert_eq!(resp.server_id, "operator-mcp"); @@ -76,7 +131,8 @@ mod tests { #[tokio::test] async fn test_descriptor_custom_port() { - let resp = descriptor(Host("localhost:9999".to_string())).await; + let state = state_with_stdio(true); + let resp = descriptor(State(state), Host("localhost:9999".to_string())).await; assert_eq!(resp.transport_url, "http://localhost:9999/api/v1/mcp/sse"); assert_eq!( @@ -84,4 +140,24 @@ mod tests { Some("http://localhost:9999/api-docs/openapi.json".to_string()) ); } + + #[tokio::test] + async fn test_descriptor_stdio_present_when_advertised() { + let state = state_with_stdio(true); + let resp = descriptor(State(state), Host("localhost:7008".to_string())).await; + + let stdio = resp.stdio.as_ref().expect("stdio should be present"); + assert_eq!(stdio.args, vec!["mcp".to_string()]); + assert!( + !stdio.command.is_empty(), + "command path should be populated from current_exe" + ); + } + + #[tokio::test] + async fn test_descriptor_stdio_absent_when_disabled() { + let state = state_with_stdio(false); + let resp = descriptor(State(state), Host("localhost:7008".to_string())).await; + assert!(resp.stdio.is_none()); + } } diff --git a/src/mcp/handler.rs b/src/mcp/handler.rs new file mode 100644 index 00000000..4dc7a595 --- /dev/null +++ b/src/mcp/handler.rs @@ -0,0 +1,316 @@ +//! Transport-agnostic JSON-RPC handler for MCP. +//! +//! Both the HTTP/SSE transport (`transport.rs`) and the stdio transport +//! (`stdio.rs`) dispatch through `handle_jsonrpc`. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::mcp::tools; +use crate::rest::state::ApiState; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + #[allow(dead_code)] + pub jsonrpc: String, + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, +} + +pub async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { + let id = request.id.clone().unwrap_or(Value::Null); + + match request.method.as_str() { + "initialize" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "resources": { "subscribe": false, "listChanged": false } + }, + "serverInfo": { + "name": "operator", + "version": env!("CARGO_PKG_VERSION") + } + })), + error: None, + }, + + "notifications/initialized" => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({})), + error: None, + }, + + "tools/list" => { + let tool_defs = tools::all_tool_definitions(); + let tools_json: Vec = tool_defs + .into_iter() + .map(|t| { + json!({ + "name": t.name, + "description": t.description, + "inputSchema": t.input_schema + }) + }) + .collect(); + + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "tools": tools_json })), + error: None, + } + } + + "tools/call" => { + let tool_name = request + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = request + .params + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + + match tools::execute_tool(tool_name, arguments, state).await { + Ok(result) => { + let text = serde_json::to_string_pretty(&result).unwrap_or_default(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "content": [{ + "type": "text", + "text": text + }] + })), + error: None, + } + } + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32000, + message: e, + }), + }, + } + } + + "resources/list" => { + let resources = crate::mcp::resources::list_resources(state) + .await + .unwrap_or_default(); + JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ "resources": resources })), + error: None, + } + } + + "resources/read" => { + let uri = request + .params + .get("uri") + .and_then(|v| v.as_str()) + .unwrap_or(""); + match crate::mcp::resources::read_resource(uri, state).await { + Ok(contents) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: Some(json!({ + "contents": [{ + "uri": uri, + "mimeType": "text/markdown", + "text": contents + }] + })), + error: None, + }, + Err(e) => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32000, + message: e, + }), + }, + } + } + + _ => JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not found: {}", request.method), + }), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn test_state() -> ApiState { + let config = Config::default(); + ApiState::new(config, PathBuf::from("/tmp/test")) + } + + #[tokio::test] + async fn test_handle_initialize() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(1)), + method: "initialize".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.id, json!(1)); + assert!(response.error.is_none()); + + let result = response.result.unwrap(); + assert_eq!(result["protocolVersion"], "2024-11-05"); + assert!(result["capabilities"]["tools"].is_object()); + assert!(result["capabilities"]["resources"].is_object()); + assert_eq!(result["serverInfo"]["name"], "operator"); + } + + #[tokio::test] + async fn test_handle_tools_list() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(2)), + method: "tools/list".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_none()); + let result = response.result.unwrap(); + let tools_arr = result["tools"].as_array().unwrap(); + assert_eq!(tools_arr.len(), 18); + + let first = &tools_arr[0]; + assert!(first.get("name").is_some()); + assert!(first.get("description").is_some()); + assert!(first.get("inputSchema").is_some()); + } + + #[tokio::test] + async fn test_handle_tools_call_health() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(3)), + method: "tools/call".to_string(), + params: json!({ + "name": "operator_health", + "arguments": {} + }), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_none()); + let result = response.result.unwrap(); + let content = result["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "text"); + + let text = content[0]["text"].as_str().unwrap(); + let health: Value = serde_json::from_str(text).unwrap(); + assert_eq!(health["status"], "ok"); + } + + #[tokio::test] + async fn test_handle_tools_call_unknown() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(4)), + method: "tools/call".to_string(), + params: json!({ + "name": "nonexistent", + "arguments": {} + }), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_some()); + assert!(response.error.unwrap().message.contains("Unknown tool")); + } + + #[tokio::test] + async fn test_handle_unknown_method() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(5)), + method: "unknown/method".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_some()); + let err = response.error.unwrap(); + assert_eq!(err.code, -32601); + assert!(err.message.contains("Method not found")); + } + + #[tokio::test] + async fn test_handle_notifications_initialized() { + let state = test_state(); + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: Some(json!(6)), + method: "notifications/initialized".to_string(), + params: json!({}), + }; + + let response = handle_jsonrpc(&request, &state).await; + + assert!(response.error.is_none()); + assert!(response.result.is_some()); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 4a8e5cc9..342f2015 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -4,6 +4,11 @@ //! read-only MCP tools. Includes a descriptor endpoint for client discovery, //! tool definitions, and an SSE transport for JSON-RPC communication. +pub mod client_configs; pub mod descriptor; +pub mod handler; +pub mod resources; +pub mod stdio; +pub mod tickets; pub mod tools; pub mod transport; diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs new file mode 100644 index 00000000..5c8f7b01 --- /dev/null +++ b/src/mcp/resources.rs @@ -0,0 +1,103 @@ +//! MCP resources — exposes tickets as URI-addressable resources. +//! +//! Each ticket is reachable at `operator://tickets/{status}/{id}` where status +//! is one of `queue`, `in-progress`, `completed`. Resource reads return the +//! raw markdown body. + +use serde_json::{json, Value}; + +use crate::queue::Queue; +use crate::rest::state::ApiState; + +pub async fn list_resources(state: &ApiState) -> Result, String> { + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let mut all = Vec::new(); + for (status, list) in [ + ("queue", queue.list_queue()), + ("in-progress", queue.list_in_progress()), + ("completed", queue.list_completed()), + ] { + for t in list.map_err(|e| e.to_string())? { + all.push(json!({ + "uri": format!("operator://tickets/{status}/{}", t.id), + "name": t.filename, + "mimeType": "text/markdown", + "description": t.summary, + })); + } + } + Ok(all) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn read_resource(uri: &str, state: &ApiState) -> Result { + let prefix = "operator://tickets/"; + let rest = uri + .strip_prefix(prefix) + .ok_or_else(|| format!("Unknown URI scheme: {uri}"))?; + let (status, id) = rest + .split_once('/') + .ok_or_else(|| format!("Malformed URI: {uri}"))?; + + let config = (*state.config).clone(); + let status = status.to_string(); + let id = id.to_string(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + let ticket = list + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("Ticket {id} not found"))?; + std::fs::read_to_string(&ticket.filepath).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.keep(); + let mut config = Config::default(); + config.paths.tickets = path.to_string_lossy().into_owned(); + ApiState::new(config, path) + } + + #[tokio::test] + async fn test_list_resources_empty() { + let state = test_state(); + let resources = list_resources(&state).await.unwrap(); + assert!(resources.is_empty()); + } + + #[tokio::test] + async fn test_read_resource_unknown_scheme() { + let state = test_state(); + let err = read_resource("file:///tmp/x", &state).await.unwrap_err(); + assert!(err.contains("Unknown URI scheme")); + } + + #[tokio::test] + async fn test_read_resource_malformed() { + let state = test_state(); + let err = read_resource("operator://tickets/queue", &state) + .await + .unwrap_err(); + assert!(err.contains("Malformed URI")); + } +} diff --git a/src/mcp/stdio.rs b/src/mcp/stdio.rs new file mode 100644 index 00000000..b80d4ce9 --- /dev/null +++ b/src/mcp/stdio.rs @@ -0,0 +1,93 @@ +//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout. +//! +//! Each line on stdin is one JSON-RPC request. Each response is one JSON +//! object written to stdout terminated by `\n`. Logs and diagnostics go to +//! stderr (via `tracing`). This is the transport MCP clients use when they +//! spawn `operator mcp` as a subprocess. + +use std::io; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; +use crate::rest::state::ApiState; + +/// Run the stdio MCP loop until stdin closes. +/// +/// `reader`/`writer` are generic for testability; production callers pass +/// `tokio::io::stdin()` and `tokio::io::stdout()`. +pub async fn run(state: ApiState, reader: R, mut writer: W) -> io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut lines = BufReader::new(reader).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let request: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, line = %line, "Malformed JSON-RPC request"); + continue; + } + }; + let response = handle_jsonrpc(&request, &state).await; + let json = serde_json::to_string(&response).unwrap_or_else(|_| { + r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"serialization failed"}}"# + .to_string() + }); + writer.write_all(json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + fn test_state() -> ApiState { + let temp = tempfile::TempDir::new().unwrap(); + // Leak the tempdir so the path survives for the duration of the test; + // ApiState may keep references to files inside it. + let path = temp.keep(); + ApiState::new(Config::default(), path) + } + + #[tokio::test] + async fn test_stdio_roundtrip_initialize() { + let state = test_state(); + let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + + let response_str = std::str::from_utf8(&output).unwrap(); + let response: serde_json::Value = serde_json::from_str(response_str.trim()).unwrap(); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["result"]["serverInfo"]["name"], "operator"); + } + + #[tokio::test] + async fn test_stdio_ignores_blank_lines() { + let state = test_state(); + let input = b"\n\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + assert!(output.is_empty()); + } + + #[tokio::test] + async fn test_stdio_malformed_line_is_skipped() { + let state = test_state(); + let input = + b"not json\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + let mut output: Vec = Vec::new(); + run(state, &input[..], &mut output).await.unwrap(); + let response_str = std::str::from_utf8(&output).unwrap(); + assert_eq!(response_str.matches('\n').count(), 1); + } +} diff --git a/src/mcp/tickets.rs b/src/mcp/tickets.rs new file mode 100644 index 00000000..2fe79848 --- /dev/null +++ b/src/mcp/tickets.rs @@ -0,0 +1,313 @@ +//! Ticket-queue MCP tools. +//! +//! Reads/writes via `crate::queue::Queue` which uses blocking `std::fs`, +//! so all calls are wrapped in `tokio::task::spawn_blocking`. + +use serde_json::{json, Value}; + +use crate::queue::{Queue, Ticket}; +use crate::rest::state::ApiState; + +fn ticket_to_json(t: &Ticket) -> Value { + json!({ + "id": t.id, + "filename": t.filename, + "project": t.project, + "ticket_type": t.ticket_type, + "summary": t.summary, + "priority": t.priority, + "status": t.status, + "branch": t.branch, + "external_id": t.external_id, + "external_url": t.external_url, + "external_provider": t.external_provider, + }) +} + +pub async fn list_tickets(args: Value, state: &ApiState) -> Result { + let status = args + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("queue") + .to_string(); + let config = (*state.config).clone(); + let tickets = tokio::task::spawn_blocking(move || -> Result, String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + match status.as_str() { + "queue" => queue.list_queue().map_err(|e| e.to_string()), + "in-progress" => queue.list_in_progress().map_err(|e| e.to_string()), + "completed" => queue.list_completed().map_err(|e| e.to_string()), + other => Err(format!("Unknown ticket status: {other}")), + } + }) + .await + .map_err(|e| e.to_string())??; + + let json_tickets: Vec = tickets.iter().map(ticket_to_json).collect(); + Ok(json!({ "tickets": json_tickets, "count": json_tickets.len() })) +} + +async fn find_ticket(state: &ApiState, id: &str, in_status: &str) -> Result { + let id = id.to_string(); + let in_status = in_status.to_string(); + let config = (*state.config).clone(); + tokio::task::spawn_blocking(move || -> Result { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + let list = match in_status.as_str() { + "queue" => queue.list_queue(), + "in-progress" => queue.list_in_progress(), + "completed" => queue.list_completed(), + other => return Err(format!("Unknown status: {other}")), + } + .map_err(|e| e.to_string())?; + list.into_iter() + .find(|t| t.id == id) + .ok_or_else(|| format!("Ticket {id} not found in {in_status}")) + }) + .await + .map_err(|e| e.to_string())? +} + +pub async fn claim_ticket(args: Value, state: &ApiState) -> Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "queue").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.claim_ticket(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "in-progress" })) +} + +pub async fn complete_ticket(args: Value, state: &ApiState) -> Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "in-progress").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.complete_ticket(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "completed" })) +} + +pub async fn return_to_queue(args: Value, state: &ApiState) -> Result { + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: id")?; + let ticket = find_ticket(state, id, "in-progress").await?; + let config = (*state.config).clone(); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let queue = Queue::new(&config).map_err(|e| e.to_string())?; + queue.return_to_queue(&ticket).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + Ok(json!({ "id": id_str, "moved_to": "queue" })) +} + +pub async fn create_ticket(args: Value, state: &ApiState) -> Result { + use crate::queue::creator::TicketCreator; + use crate::templates::TemplateType; + use std::collections::HashMap; + + let template_str = args + .get("template") + .and_then(|v| v.as_str()) + .ok_or("Missing required arg: template")?; + let template_type = TemplateType::from_key(template_str) + .ok_or_else(|| format!("Unknown template type: {template_str}"))?; + + let mut values: HashMap = HashMap::new(); + if let Some(obj) = args.get("values").and_then(|v| v.as_object()) { + for (k, v) in obj { + if let Some(s) = v.as_str() { + values.insert(k.clone(), s.to_string()); + } + } + } + + let config = (*state.config).clone(); + let path = tokio::task::spawn_blocking(move || -> Result { + let creator = TicketCreator::new(&config); + creator + .create_ticket_headless(template_type, &values) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(json!({ + "path": path.to_string_lossy(), + "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn test_state_with_config(mut config: Config) -> (ApiState, PathBuf) { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.keep(); + // Point the config's tickets path at the tempdir so Queue::new resolves there. + config.paths.tickets = path.to_string_lossy().into_owned(); + let state = ApiState::new(config, path.clone()); + (state, path) + } + + fn test_state() -> ApiState { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + test_state_with_config(config).0 + } + + /// Write a fake ticket file into `/queue/` and create the + /// `in-progress` and `completed` sibling dirs (`Queue::{claim,complete,return}` + /// use `fs::rename` which requires the target dir to exist). Returns the id. + fn seed_queue_ticket(tickets_path: &std::path::Path, id: &str) -> String { + let queue_dir = tickets_path.join("queue"); + std::fs::create_dir_all(&queue_dir).unwrap(); + std::fs::create_dir_all(tickets_path.join("in-progress")).unwrap(); + std::fs::create_dir_all(tickets_path.join("completed")).unwrap(); + let filename = format!("20260516-1200-FEAT-demo-test-{id}.md"); + let body = format!( + "---\nid: {id}\npriority: P2-medium\nstatus: queued\n---\n\n# Task: Test {id}\n" + ); + std::fs::write(queue_dir.join(&filename), body).unwrap(); + id.to_string() + } + + #[tokio::test] + async fn test_list_tickets_empty_queue() { + let state = test_state(); + let result = list_tickets(json!({}), &state).await.unwrap(); + assert_eq!(result["count"], 0); + } + + #[tokio::test] + async fn test_list_tickets_unknown_status_errors() { + let state = test_state(); + let err = list_tickets(json!({ "status": "bogus" }), &state) + .await + .unwrap_err(); + assert!(err.contains("Unknown ticket status")); + } + + #[tokio::test] + async fn test_claim_ticket_moves_file() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + let id = seed_queue_ticket(&tickets_path, "FEAT-1234"); + + let result = claim_ticket(json!({ "id": id }), &state).await.unwrap(); + assert_eq!(result["moved_to"], "in-progress"); + + let in_progress = tickets_path.join("in-progress"); + let entries: Vec<_> = std::fs::read_dir(&in_progress).unwrap().collect(); + assert_eq!(entries.len(), 1); + + let queue_dir = tickets_path.join("queue"); + let entries: Vec<_> = std::fs::read_dir(&queue_dir).unwrap().collect(); + assert_eq!(entries.len(), 0); + } + + #[tokio::test] + async fn test_complete_then_return_ticket() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + let id = seed_queue_ticket(&tickets_path, "FEAT-5678"); + + claim_ticket(json!({ "id": &id }), &state).await.unwrap(); + let res = complete_ticket(json!({ "id": &id }), &state).await.unwrap(); + assert_eq!(res["moved_to"], "completed"); + let completed = tickets_path.join("completed"); + assert_eq!(std::fs::read_dir(&completed).unwrap().count(), 1); + } + + #[tokio::test] + async fn test_return_to_queue_moves_back() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + let id = seed_queue_ticket(&tickets_path, "FEAT-9999"); + + claim_ticket(json!({ "id": &id }), &state).await.unwrap(); + let res = return_to_queue(json!({ "id": &id }), &state).await.unwrap(); + assert_eq!(res["moved_to"], "queue"); + let queue_dir = tickets_path.join("queue"); + assert_eq!(std::fs::read_dir(&queue_dir).unwrap().count(), 1); + } + + #[tokio::test] + async fn test_create_ticket_writes_file_to_queue() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, tickets_path) = test_state_with_config(config); + + let result = create_ticket( + json!({ + "template": "FEAT", + "values": { "project": "demo", "summary": "from mcp test" } + }), + &state, + ) + .await + .unwrap(); + let filename = result["filename"].as_str().unwrap(); + assert!( + filename.contains("FEAT-demo"), + "filename should contain FEAT-demo, got: {filename}" + ); + + let queue_dir = tickets_path.join("queue"); + let entries: Vec<_> = std::fs::read_dir(&queue_dir).unwrap().collect(); + assert_eq!(entries.len(), 1); + } + + #[tokio::test] + async fn test_create_ticket_unknown_template_errors() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let (state, _) = test_state_with_config(config); + + let err = create_ticket(json!({ "template": "nope" }), &state) + .await + .unwrap_err(); + assert!(err.contains("Unknown template type")); + } + + #[tokio::test] + async fn test_claim_ticket_gate_blocks_when_disabled() { + // Default config has expose_ticket_write_tools = false. + let config = Config::default(); + let (state, tickets_path) = test_state_with_config(config); + seed_queue_ticket(&tickets_path, "FEAT-0001"); + + let err = crate::mcp::tools::execute_tool( + "operator_claim_ticket", + json!({ "id": "FEAT-0001" }), + &state, + ) + .await + .unwrap_err(); + assert!(err.contains("disabled in config")); + } +} diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index e81ffe28..522efd73 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1,12 +1,15 @@ //! MCP tool definitions and execution. //! -//! Defines read-only tools that wrap existing REST API route handlers. +//! Defines read-only and write tools that wrap existing REST API route handlers. //! Each tool calls the handler directly (no internal HTTP round-trip). +//! Write tools are gated behind `[mcp].expose_ticket_write_tools`. use axum::extract::{Path, State}; +use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use crate::rest::dto::{LaunchTicketRequest, RejectReviewRequest}; use crate::rest::routes; use crate::rest::state::ApiState; @@ -95,9 +98,170 @@ pub fn all_tool_definitions() -> Vec { "required": [] }), }, + McpToolDefinition { + name: "operator_list_tickets".to_string(), + description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["queue", "in-progress", "completed"], + "default": "queue", + "description": "Which directory to list (defaults to queue)" + } + }, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_claim_ticket".to_string(), + description: "Move a ticket from queue to in-progress. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Ticket id (e.g. FEAT-1234)" } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_complete_ticket".to_string(), + description: "Move a ticket from in-progress to completed. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Ticket id" } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_return_to_queue".to_string(), + description: "Move a ticket from in-progress back to queue (un-claim). Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Ticket id" } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_create_ticket".to_string(), + description: "Create a new ticket from a template (feature, fix, spike, investigation, task) and write it to the queue. Returns the filename. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Template type key (feature, fix, spike, investigation, task)" + }, + "values": { + "type": "object", + "description": "Handlebars values for the template (project, summary, id, etc.)" + } + }, + "required": ["template"] + }), + }, + McpToolDefinition { + name: "operator_launch_ticket".to_string(), + description: "Launch/start a ticket by claiming it and preparing an agent. Returns agent details and launch command. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Ticket ID to launch (e.g. FEAT-1234)" + }, + "model": { + "type": "string", + "description": "Model to use (default: sonnet)", + "default": "sonnet" + }, + "wrapper": { + "type": "string", + "description": "Session wrapper type: vscode, tmux, cmux, terminal (default: terminal)", + "default": "terminal" + } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_pause_queue".to_string(), + description: "Pause queue processing, stopping automatic ticket launches. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_resume_queue".to_string(), + description: "Resume queue processing, re-enabling automatic ticket launches. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_sync_kanban".to_string(), + description: "Sync kanban collections from external providers (Jira, Linear, etc.) and create local tickets. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + }, + McpToolDefinition { + name: "operator_approve_agent".to_string(), + description: "Approve an agent's pending review, signaling it to continue. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Agent ID to approve" + } + }, + "required": ["id"] + }), + }, + McpToolDefinition { + name: "operator_reject_agent".to_string(), + description: "Reject an agent's pending review with a reason. The agent will re-do the work based on the feedback. Disabled unless [mcp].expose_ticket_write_tools = true.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Agent ID to reject" + }, + "reason": { + "type": "string", + "description": "Reason for rejection (feedback for the agent)" + } + }, + "required": ["id", "reason"] + }), + }, ] } +fn require_write_tools(state: &ApiState) -> Result<(), String> { + if state.config.mcp.expose_ticket_write_tools { + Ok(()) + } else { + Err( + "Ticket write tools disabled in config ([mcp].expose_ticket_write_tools = true to enable)" + .to_string(), + ) + } +} + /// Execute an MCP tool by name with the given arguments pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result { match name { @@ -146,6 +310,119 @@ pub async fn execute_tool(name: &str, args: Value, state: &ApiState) -> Result crate::mcp::tickets::list_tickets(args, state).await, + "operator_claim_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::claim_ticket(args, state).await + } + "operator_complete_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::complete_ticket(args, state).await + } + "operator_return_to_queue" => { + require_write_tools(state)?; + crate::mcp::tickets::return_to_queue(args, state).await + } + "operator_create_ticket" => { + require_write_tools(state)?; + crate::mcp::tickets::create_ticket(args, state).await + } + "operator_launch_ticket" => { + require_write_tools(state)?; + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: id".to_string())?; + let model = args + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("sonnet"); + let wrapper = args + .get("wrapper") + .and_then(|v| v.as_str()) + .unwrap_or("terminal"); + let request = LaunchTicketRequest { + delegator: None, + provider: None, + model: Some(model.to_string()), + model_server: None, + yolo_mode: false, + wrapper: Some(wrapper.to_string()), + retry_reason: None, + resume_session_id: None, + }; + let result = routes::launch::launch_ticket( + State(state.clone()), + Path(id.to_string()), + Json(request), + ) + .await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_pause_queue" => { + require_write_tools(state)?; + let result = routes::queue::pause(State(state.clone())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_resume_queue" => { + require_write_tools(state)?; + let result = routes::queue::resume(State(state.clone())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_sync_kanban" => { + require_write_tools(state)?; + let result = routes::queue::sync(State(state.clone())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_approve_agent" => { + require_write_tools(state)?; + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: id".to_string())?; + let result = + routes::agents::approve_review(State(state.clone()), Path(id.to_string())).await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } + "operator_reject_agent" => { + require_write_tools(state)?; + let id = args + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: id".to_string())?; + let reason = args + .get("reason") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Missing required parameter: reason".to_string())?; + let request = RejectReviewRequest { + reason: reason.to_string(), + }; + let result = routes::agents::reject_review( + State(state.clone()), + Path(id.to_string()), + Json(request), + ) + .await; + match result { + Ok(resp) => serde_json::to_value(&*resp).map_err(|e| e.to_string()), + Err(e) => Err(format!("{e:?}")), + } + } _ => Err(format!("Unknown tool: {name}")), } } @@ -159,7 +436,7 @@ mod tests { #[test] fn test_all_tool_definitions_count() { let tools = all_tool_definitions(); - assert_eq!(tools.len(), 7); + assert_eq!(tools.len(), 18); } #[test] @@ -174,6 +451,13 @@ mod tests { assert!(names.contains(&"operator_list_collections")); assert!(names.contains(&"operator_get_collection")); assert!(names.contains(&"operator_list_skills")); + assert!(names.contains(&"operator_list_tickets")); + assert!(names.contains(&"operator_launch_ticket")); + assert!(names.contains(&"operator_pause_queue")); + assert!(names.contains(&"operator_resume_queue")); + assert!(names.contains(&"operator_sync_kanban")); + assert!(names.contains(&"operator_approve_agent")); + assert!(names.contains(&"operator_reject_agent")); } #[test] @@ -269,4 +553,84 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("Unknown tool")); } + + // --- Write tool gate tests (write tools disabled by default) --- + + #[tokio::test] + async fn test_execute_launch_ticket_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_launch_ticket", json!({"id": "FEAT-1"}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_pause_queue_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_pause_queue", json!({}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_resume_queue_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_resume_queue", json!({}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_sync_kanban_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_sync_kanban", json!({}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_approve_agent_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_approve_agent", json!({"id": "agent-1"}), &state).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_reject_agent_disabled() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool( + "operator_reject_agent", + json!({"id": "agent-1", "reason": "bad"}), + &state, + ) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Ticket write tools disabled")); + } + + #[tokio::test] + async fn test_execute_launch_ticket_requires_id() { + let mut config = Config::default(); + config.mcp.expose_ticket_write_tools = true; + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let result = execute_tool("operator_launch_ticket", json!({}), &state).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Missing required parameter: id")); + } } diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index 50614b07..b9f5bb1f 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -12,44 +12,15 @@ use axum::extract::{Host, Query, State}; use axum::response::sse::{Event, Sse}; use axum::response::IntoResponse; use axum::Json; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde::Deserialize; +use serde_json::json; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::StreamExt as _; -use crate::mcp::tools; +use crate::mcp::handler::{handle_jsonrpc, JsonRpcRequest}; use crate::rest::state::ApiState; -/// JSON-RPC request structure -#[derive(Debug, Deserialize)] -pub struct JsonRpcRequest { - #[allow(dead_code)] - jsonrpc: String, - id: Option, - method: String, - #[serde(default)] - params: Value, -} - -/// JSON-RPC response structure -#[derive(Debug, Serialize)] -struct JsonRpcResponse { - jsonrpc: String, - id: Value, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -/// JSON-RPC error -#[derive(Debug, Serialize)] -struct JsonRpcError { - code: i64, - message: String, -} - /// Query parameters for the message endpoint #[derive(Debug, Deserialize)] pub struct MessageQuery { @@ -92,7 +63,7 @@ pub async fn sse_handler( // Clean up session after 1 hour or when stream ends tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(3600)).await; + tokio::time::sleep(Duration::from_hours(1)).await; sessions_cleanup.lock().await.remove(&session_id_cleanup); }); @@ -131,241 +102,3 @@ pub async fn message_handler( (axum::http::StatusCode::ACCEPTED, Json(json!({}))) } - -/// Handle a JSON-RPC request and return the response -async fn handle_jsonrpc(request: &JsonRpcRequest, state: &ApiState) -> JsonRpcResponse { - let id = request.id.clone().unwrap_or(Value::Null); - - match request.method.as_str() { - "initialize" => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "operator", - "version": env!("CARGO_PKG_VERSION") - } - })), - error: None, - }, - - "notifications/initialized" => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({})), - error: None, - }, - - "tools/list" => { - let tool_defs = tools::all_tool_definitions(); - let tools_json: Vec = tool_defs - .into_iter() - .map(|t| { - json!({ - "name": t.name, - "description": t.description, - "inputSchema": t.input_schema - }) - }) - .collect(); - - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ "tools": tools_json })), - error: None, - } - } - - "tools/call" => { - let tool_name = request - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let arguments = request - .params - .get("arguments") - .cloned() - .unwrap_or_else(|| json!({})); - - match tools::execute_tool(tool_name, arguments, state).await { - Ok(result) => { - let text = serde_json::to_string_pretty(&result).unwrap_or_default(); - JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: Some(json!({ - "content": [{ - "type": "text", - "text": text - }] - })), - error: None, - } - } - Err(e) => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { - code: -32000, - message: e, - }), - }, - } - } - - _ => JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id, - result: None, - error: Some(JsonRpcError { - code: -32601, - message: format!("Method not found: {}", request.method), - }), - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use std::path::PathBuf; - - fn test_state() -> ApiState { - let config = Config::default(); - ApiState::new(config, PathBuf::from("/tmp/test")) - } - - #[tokio::test] - async fn test_handle_initialize() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(1)), - method: "initialize".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert_eq!(response.jsonrpc, "2.0"); - assert_eq!(response.id, json!(1)); - assert!(response.error.is_none()); - - let result = response.result.unwrap(); - assert_eq!(result["protocolVersion"], "2024-11-05"); - assert!(result["capabilities"]["tools"].is_object()); - assert_eq!(result["serverInfo"]["name"], "operator"); - } - - #[tokio::test] - async fn test_handle_tools_list() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(2)), - method: "tools/list".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_none()); - let result = response.result.unwrap(); - let tools_arr = result["tools"].as_array().unwrap(); - assert_eq!(tools_arr.len(), 7); - - // Verify first tool has expected shape - let first = &tools_arr[0]; - assert!(first.get("name").is_some()); - assert!(first.get("description").is_some()); - assert!(first.get("inputSchema").is_some()); - } - - #[tokio::test] - async fn test_handle_tools_call_health() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(3)), - method: "tools/call".to_string(), - params: json!({ - "name": "operator_health", - "arguments": {} - }), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_none()); - let result = response.result.unwrap(); - let content = result["content"].as_array().unwrap(); - assert_eq!(content.len(), 1); - assert_eq!(content[0]["type"], "text"); - - // Parse the text content to verify it's valid health JSON - let text = content[0]["text"].as_str().unwrap(); - let health: Value = serde_json::from_str(text).unwrap(); - assert_eq!(health["status"], "ok"); - } - - #[tokio::test] - async fn test_handle_tools_call_unknown() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(4)), - method: "tools/call".to_string(), - params: json!({ - "name": "nonexistent", - "arguments": {} - }), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_some()); - assert!(response.error.unwrap().message.contains("Unknown tool")); - } - - #[tokio::test] - async fn test_handle_unknown_method() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(5)), - method: "unknown/method".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_some()); - let err = response.error.unwrap(); - assert_eq!(err.code, -32601); - assert!(err.message.contains("Method not found")); - } - - #[tokio::test] - async fn test_handle_notifications_initialized() { - let state = test_state(); - let request = JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: Some(json!(6)), - method: "notifications/initialized".to_string(), - params: json!({}), - }; - - let response = handle_jsonrpc(&request, &state).await; - - assert!(response.error.is_none()); - assert!(response.result.is_some()); - } -} diff --git a/src/notifications/os_integration.rs b/src/notifications/os_integration.rs index e86b408c..1f52bcb6 100644 --- a/src/notifications/os_integration.rs +++ b/src/notifications/os_integration.rs @@ -12,13 +12,14 @@ use crate::config::OsNotificationConfig; /// Sends notifications using the platform's native notification system: /// - macOS: Uses `mac-notification-sys` /// - Linux: Uses `notify-rust` (freedesktop notifications) -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary, not reachable from lib.rs pub struct OsIntegration { enabled: bool, sound: bool, subscribed_events: Vec, } +#[allow(dead_code)] impl OsIntegration { /// Create a new OS integration from config. pub fn new(config: &OsNotificationConfig) -> Self { @@ -30,7 +31,6 @@ impl OsIntegration { } /// Create a disabled OS integration. - #[allow(dead_code)] pub fn disabled() -> Self { Self { enabled: false, diff --git a/src/notifications/service.rs b/src/notifications/service.rs index f7e3c80a..39778f15 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -14,14 +14,15 @@ use crate::config::Config; /// /// Receives events and dispatches them to all enabled integrations /// that handle the given event type. +#[allow(dead_code)] // Used via binary, not reachable from lib.rs pub struct NotificationService { integrations: Vec>, enabled: bool, } +#[allow(dead_code)] impl NotificationService { /// Create a new notification service from config. - #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub fn from_config(config: &Config) -> Result { let mut integrations: Vec> = Vec::new(); @@ -64,7 +65,6 @@ impl NotificationService { } /// Create a disabled notification service (for testing). - #[allow(dead_code)] pub fn disabled() -> Self { Self { integrations: Vec::new(), @@ -73,13 +73,11 @@ impl NotificationService { } /// Check if notifications are globally enabled. - #[allow(dead_code)] pub fn is_enabled(&self) -> bool { self.enabled } /// Get the number of registered integrations. - #[allow(dead_code)] pub fn integration_count(&self) -> usize { self.integrations.len() } @@ -88,7 +86,6 @@ impl NotificationService { /// /// This is fire-and-forget - each integration is spawned as a separate task /// and errors are logged but not propagated. - #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub async fn notify(&self, event: NotificationEvent) { if !self.enabled { return; @@ -118,7 +115,6 @@ impl NotificationService { /// /// This is useful for contexts where async is not available. /// Only dispatches to OS integration (webhooks require async). - #[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate pub fn notify_sync(&self, event: NotificationEvent) { if !self.enabled { return; diff --git a/src/queue/creator.rs b/src/queue/creator.rs index 59965495..6a625bab 100644 --- a/src/queue/creator.rs +++ b/src/queue/creator.rs @@ -26,22 +26,17 @@ impl TicketCreator { } } - /// Create a new ticket from template with pre-filled values and open in editor. - /// - /// Returns the path to the created ticket file. - /// `editor_cmd` is the resolved editor command (e.g. from `EditorConfig::file_editor()`). - pub fn create_ticket_with_values( + /// Create a new ticket from template with pre-filled values, without + /// launching an editor. Used by MCP and other headless callers. + pub fn create_ticket_headless( &self, template_type: TemplateType, values: &HashMap, - editor_cmd: &str, ) -> Result { - // Generate filename with timestamp let now = Utc::now(); let timestamp = now.format("%Y%m%d-%H%M").to_string(); let type_str = template_type.as_str(); - // Get project from values or use "global" let project = values .get("project") .filter(|p| !p.is_empty()) @@ -51,19 +46,27 @@ impl TicketCreator { let filename = format!("{timestamp}-{type_str}-{project}-new-ticket.md"); let filepath = self.queue_path.join(&filename); - // Render template with handlebar values let template = template_type.template_content(); let content = render_template(template, values)?; - // Ensure queue directory exists fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; - - // Write to file fs::write(&filepath, &content).context("Failed to write ticket file")?; - // Open in editor - self.open_in_editor(&filepath, editor_cmd)?; + Ok(filepath) + } + /// Create a new ticket from template with pre-filled values and open in editor. + /// + /// Returns the path to the created ticket file. + /// `editor_cmd` is the resolved editor command (e.g. from `EditorConfig::file_editor()`). + pub fn create_ticket_with_values( + &self, + template_type: TemplateType, + values: &HashMap, + editor_cmd: &str, + ) -> Result { + let filepath = self.create_ticket_headless(template_type, values)?; + self.open_in_editor(&filepath, editor_cmd)?; Ok(filepath) } diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index a88a6ed9..46ea8055 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -152,6 +152,61 @@ pub struct ActiveAgentsResponse { pub count: usize, } +// ============================================================================= +// Agent Detail DTOs +// ============================================================================= + +/// Full details for a single agent +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AgentDetailResponse { + /// Agent ID (UUID) + pub id: String, + /// Associated ticket ID (e.g., "FEAT-042") + pub ticket_id: String, + /// Ticket type: FEAT, FIX, INV, SPIKE + pub ticket_type: String, + /// Project being worked on + pub project: String, + /// Agent status: running, `awaiting_input`, completing, orphaned + pub status: String, + /// When the agent started (ISO 8601) + pub started_at: String, + /// Last activity timestamp (ISO 8601) + pub last_activity: String, + /// Current workflow step + #[serde(skip_serializing_if = "Option::is_none")] + pub current_step: Option, + /// LLM tool used (e.g., "claude", "gemini", "codex") + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_tool: Option, + /// LLM model alias (e.g., "opus", "sonnet", "gpt-4o") + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_model: Option, + /// Launch mode: "default", "yolo", "docker", "docker-yolo" + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_mode: Option, + /// PR URL if created during "pr" step + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + /// Last known PR status ("open", "approved", "`changes_requested`", "merged", "closed") + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_status: Option, + /// Which session wrapper is in use: "tmux", "vscode", "cmux", or "zellij" + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrapper: Option, + /// Review state for `awaiting_input` agents + #[serde(skip_serializing_if = "Option::is_none")] + pub review_state: Option, + /// Completed steps for this ticket + pub completed_steps: Vec, + /// Path to the git worktree for this ticket + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, + /// Whether this is a paired (interactive) agent + pub paired: bool, +} + // ============================================================================= // Ticket Launch DTOs // ============================================================================= @@ -169,6 +224,10 @@ pub struct LaunchTicketRequest { /// Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator #[serde(default)] pub model: Option, + /// Ad-hoc model server to target (e.g. "ollama-local") — legacy fallback when + /// no delegator. Injects the server's base URL / API key env at spawn. + #[serde(default)] + pub model_server: Option, /// Run in YOLO mode (auto-accept all prompts) #[serde(default)] pub yolo_mode: bool, @@ -408,3 +467,79 @@ pub struct RejectReviewRequest { /// Reason for rejection (feedback for the agent) pub reason: String, } + +// ============================================================================= +// Ticket Detail DTOs +// ============================================================================= + +/// Full ticket details including content and metadata +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct TicketDetailResponse { + /// Ticket ID (e.g., "FEAT-7598") + pub id: String, + /// Ticket summary/title + pub summary: String, + /// Ticket type: FEAT, FIX, INV, SPIKE + pub ticket_type: String, + /// Project name + pub project: String, + /// Current status: queued, running, awaiting, completed + pub status: String, + /// Current step name + pub step: String, + /// Human-readable step name + #[serde(skip_serializing_if = "Option::is_none")] + pub step_display_name: Option, + /// Priority: P0-critical, P1-high, P2-medium, P3-low + pub priority: String, + /// Timestamp (YYYYMMDD-HHMM format) + pub timestamp: String, + /// Full markdown content of the ticket + pub content: String, + /// Ticket filename + pub filename: String, + /// Full filesystem path + pub filepath: String, + /// Session IDs per step (`step_name` -> `session_uuid`) + pub sessions: std::collections::HashMap, + /// Delegator used per step (`step_name` -> `delegator_name`) + pub step_delegators: std::collections::HashMap, + /// Path to git worktree (if created) + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, + /// Git branch name + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// External issue ID from kanban provider + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// URL to the issue in the external provider + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option, + /// Provider name (e.g., "jira", "linear") + #[serde(skip_serializing_if = "Option::is_none")] + pub external_provider: Option, +} + +/// Request to update a ticket's status +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateTicketStatusRequest { + /// Target status: queued, running, awaiting, done + pub status: String, +} + +/// Response from updating a ticket's status +#[derive(Debug, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateTicketStatusResponse { + /// Ticket ID + pub id: String, + /// Previous status before the update + pub previous_status: String, + /// New status after the update + pub status: String, + /// Human-readable message + pub message: String, +} diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index 3b2bf98f..bdcee100 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -275,6 +275,72 @@ pub struct CreateModelServerRequest { pub display_name: Option, } +/// Request to update an existing user-declared model server. +/// +/// All fields except `kind` are replaced; `name` is taken from the path. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct UpdateModelServerRequest { + /// Kind: "ollama", "openai-compat", "anthropic-api", "openai-api", "google-api", "lmstudio" + pub kind: String, + /// Base URL of the inference endpoint + #[serde(default)] + pub base_url: Option, + /// Name of an env var providing the API key + #[serde(default)] + pub api_key_env: Option, + /// Additional environment variables + #[serde(default)] + pub extra_env: std::collections::HashMap, + /// Optional display name for UI + #[serde(default)] + pub display_name: Option, +} + +/// A model-server kind from the shared catalog (single source of truth). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServerKindEntry { + /// Stable slug stored as `ModelServer.kind` (e.g. "ollama") + pub slug: String, + /// Human-friendly display name + pub display_name: String, + /// One-line connect blurb + pub description: String, + /// Help/credential setup page + pub setup_url: String, + /// Codicon hint + pub icon: String, + /// Whether this is an implicit vendor builtin (always present, not deletable) + pub is_builtin: bool, +} + +/// A single model offered by a server (from a live probe). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelEntry { + /// Model id passed to `--model` + pub id: String, + /// Display name when the server provides one + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +/// Response listing the models a server offers, plus reachability. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ModelServerModelsResponse { + /// Server name probed + pub server: String, + /// Whether the endpoint was reachable + pub reachable: bool, + /// Models offered (empty when unreachable) + pub models: Vec, + /// Error message when unreachable + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + // ============================================================================= // LLM Tools DTOs // ============================================================================= diff --git a/src/rest/dto/issue_types.rs b/src/rest/dto/issue_types.rs index 07a7d69c..8074c92d 100644 --- a/src/rest/dto/issue_types.rs +++ b/src/rest/dto/issue_types.rs @@ -407,6 +407,7 @@ impl From for StepSchema { multi_model_config: None, multi_prompt_config: None, matrixed_config: None, + pipeline_config: None, } } } diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index 25b38828..401b3492 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -61,6 +61,34 @@ pub struct SyncKanbanIssueTypesResponse { pub types: Vec, } +// ============================================================================= +// Kanban Provider Catalog DTO +// ============================================================================= + +/// One supported kanban provider, as advertised by +/// `GET /api/v1/kanban/providers`. +/// +/// This is the single source of truth (derived from +/// `KanbanProviderType::ALL`) that the web `/#/kanban` list view and the VS +/// Code onboarding picker both render against, so the available options can't +/// drift between surfaces. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct KanbanProviderCatalogEntry { + /// Stable lowercase slug ("jira" | "linear" | "github"). + pub slug: String, + /// Human-readable name (e.g. "Jira Cloud", "GitHub Projects"). + pub display_name: String, + /// One-line connect description shown next to the provider. + pub description: String, + /// Credential/token page opened when the user chooses to configure it. + pub setup_url: String, + /// VS Code codicon hint (rendered as `$(icon)` in the picker). + pub icon: String, + /// Whether at least one instance of this provider is already configured. + pub configured: bool, +} + // ============================================================================= // Kanban Onboarding DTOs // ============================================================================= diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index 8e4b6a36..a597718d 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -10,11 +10,15 @@ pub mod agents; pub mod configuration; pub mod issue_types; pub mod kanban; +pub mod sections; +pub mod workflow; pub use agents::*; pub use configuration::*; pub use issue_types::*; pub use kanban::*; +pub use sections::*; +pub use workflow::*; #[cfg(test)] mod tests { diff --git a/src/rest/dto/sections.rs b/src/rest/dto/sections.rs new file mode 100644 index 00000000..c1ecfaa7 --- /dev/null +++ b/src/rest/dto/sections.rs @@ -0,0 +1,111 @@ +//! Status section DTOs for `GET /api/v1/sections`. +//! +//! These mirror the canonical status sections shared with the TUI and the VS +//! Code extension. The section *logic* (health rules, child rows) lives in the +//! TUI layer (`crate::ui`), which the shared `rest` module cannot depend on: +//! `rest` compiles into the lib crate, and the lib crate has no `ui` module. +//! +//! To keep that boundary while still running the real section logic, the binary +//! injects a provider via [`register_section_provider`] at startup. In lib-only +//! and test contexts no provider is registered, so the endpoint returns an empty +//! list — the section logic is exercised by the ui-side builder's own tests. + +use std::sync::{Arc, OnceLock}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::config::Config; +use crate::issuetypes::IssueTypeRegistry; + +/// A browser-openable action on a status section row (e.g. "Open Web UI", +/// "Swagger"). Only URL-style actions surface to the web UI; TUI-only actions +/// (toggles, env edits) are omitted. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct RowActionDto { + /// Display label for the action button/link. + pub label: String, + /// Browser URL the action opens. + pub url: String, +} + +/// A child row within a status section. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SectionRowDto { + /// Stable, section-scoped row id. Clients use it as a tree key and to route + /// row-specific commands without matching on the (mutable) display label. + /// Dynamic rows carry their entity key (issue-type key, project name); + /// static rows carry a fixed slug (e.g. "git-token"). + pub id: String, + /// Nesting depth within the section (1 = direct child, 2 = grandchild). + /// Lets clients rebuild the tree (e.g. LLM tools → model aliases). + pub depth: u16, + pub label: String, + pub description: String, + /// Icon hint (e.g. "check", "warning", "tool", "folder"). + pub icon: String, + /// Health: "green" | "yellow" | "red" | "gray". + pub health: String, + /// Browser-openable actions for this row (links shown in the web UI). + pub actions: Vec, +} + +/// A status section with its health and child rows. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct SectionDto { + /// Stable section id (e.g. "config", "connections", "kanban"). + pub id: String, + pub label: String, + /// Health: "green" | "yellow" | "red" | "gray". + pub health: String, + pub description: String, + /// Section ids that must be Green before this section is usable. + pub prerequisites: Vec, + /// Whether all prerequisites are met. Sections are always returned (the web + /// UI styles unmet ones as locked) rather than hidden by progressive disclosure. + pub met: bool, + pub children: Vec, +} + +/// Live connection facts the config-only snapshot can't know on its own. +/// +/// The `/api/v1/sections` handler is, by definition, proof the API (and embedded +/// Web UI) are up; it passes these runtime facts to the provider so the +/// connections section reflects reality rather than the config defaults. Internal +/// provider input only — never serialized over the wire. +#[derive(Debug, Clone)] +pub struct LiveConnectionStatus { + /// Whether the REST API is currently serving. + pub api_running: bool, + /// Port the API is bound to. + pub port: u16, + /// Whether the MCP HTTP transport is mounted. + pub mcp_http_enabled: bool, + /// Count of active MCP sessions. + pub mcp_active_sessions: usize, +} + +/// Builds the canonical status sections from config + the live issue-type +/// registry + live connection facts. Defined in the binary (the section logic is +/// ui-layer); see module docs for why this is injected rather than called directly. +pub type SectionProvider = Arc< + dyn Fn(&Config, &IssueTypeRegistry, &LiveConnectionStatus) -> Vec + Send + Sync, +>; + +static SECTION_PROVIDER: OnceLock = OnceLock::new(); + +/// Register the process-wide section provider. Called once by the binary at +/// startup, before any server starts. Subsequent calls are ignored. +pub fn register_section_provider(provider: SectionProvider) { + let _ = SECTION_PROVIDER.set(provider); +} + +/// Returns the registered provider, if any. +pub fn section_provider() -> Option { + SECTION_PROVIDER.get().cloned() +} diff --git a/src/rest/dto/workflow.rs b/src/rest/dto/workflow.rs new file mode 100644 index 00000000..bea00f16 --- /dev/null +++ b/src/rest/dto/workflow.rs @@ -0,0 +1,56 @@ +//! DTOs for the workflow-export endpoint. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::workflow_gen::ExportedWorkflow; + +/// Response for exporting a ticket to a Claude dynamic workflow (`.js`). +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WorkflowExportResponse { + /// The ticket the workflow was generated from. + pub ticket_id: String, + /// The issue type key that supplied the step structure. + pub issuetype_key: String, + /// Suggested filename for saving the workflow (`.workflow.js`). + pub suggested_filename: String, + /// The generated `.js` workflow source. + pub contents: String, +} + +impl From for WorkflowExportResponse { + fn from(e: ExportedWorkflow) -> Self { + Self { + ticket_id: e.ticket_id, + issuetype_key: e.issuetype_key, + suggested_filename: e.suggested_filename, + contents: e.contents, + } + } +} + +/// Response for a *preview* workflow generated from an issue type alone (no +/// concrete ticket). Used by the UI to visualize an issue type's workflow shape. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct WorkflowPreviewResponse { + /// The issue type key the preview was generated from. + pub issuetype_key: String, + /// Suggested filename for saving the preview (`.preview.workflow.js`). + pub suggested_filename: String, + /// The generated `.js` workflow source (placeholder ticket values). + pub contents: String, +} + +impl From for WorkflowPreviewResponse { + fn from(e: ExportedWorkflow) -> Self { + Self { + issuetype_key: e.issuetype_key, + suggested_filename: e.suggested_filename, + contents: e.contents, + } + } +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index a45b48ec..19a1ca06 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -7,13 +7,15 @@ use std::net::SocketAddr; use anyhow::Result; use axum::{ - routing::{delete, get, post, put}, + routing::{get, post}, Router, }; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::Level; use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; use utoipa_swagger_ui::SwaggerUi; pub mod dto; @@ -22,6 +24,30 @@ pub mod openapi; pub mod routes; pub mod server; pub mod state; +#[cfg(feature = "embed-ui")] +pub mod web_ui; + +/// Shim exposing the same `EmbeddedUiState` API when the SPA isn't compiled +/// in. Callers can treat the two modules identically without `#[cfg]` blocks. +/// +/// `Ready` and `Placeholder` are never constructed in this configuration — +/// `embedded_ui_state()` always returns `Missing` when `embed-ui` is off — +/// but they must exist so call-site `match` arms remain exhaustive across +/// both feature configurations. +#[cfg(not(feature = "embed-ui"))] +pub mod web_ui { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[allow(dead_code)] + pub enum EmbeddedUiState { + Ready, + Placeholder, + Missing, + } + + pub fn embedded_ui_state() -> EmbeddedUiState { + EmbeddedUiState::Missing + } +} pub use openapi::ApiDoc; pub use server::{ApiSessionInfo, RestApiServer, RestApiStatus}; @@ -31,155 +57,153 @@ pub use state::ApiState; #[allow(dead_code)] pub const DEFAULT_PORT: u16 = 7008; -/// Build the API router with all routes -pub fn build_router(state: ApiState) -> Router { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - Router::new() +/// Build the documented API surface as a `utoipa_axum::OpenApiRouter`. +/// +/// Every always-on route is mounted here via `routes!`, so mounting a route +/// *is* registering it in the OpenAPI spec — the router and the spec cannot +/// drift. Handlers sharing a path (different HTTP methods) are grouped in a +/// single `routes!` call. Config-gated routes (MCP `sse`/`message`) are NOT +/// documented and are added separately in [`build_router`]. +/// +/// The base `OpenApi` (info, components/schemas, tags) comes from the +/// [`ApiDoc`] derive; paths and their referenced schemas are collected from the +/// mounted handlers. +fn documented_router() -> OpenApiRouter { + OpenApiRouter::with_openapi(ApiDoc::openapi()) // Health endpoints - .route("/api/v1/health", get(routes::health::health)) - .route("/api/v1/status", get(routes::health::status)) + .routes(routes!(routes::health::health)) + .routes(routes!(routes::health::status)) + // Canonical status sections (shared with the TUI / VS Code extension) + .routes(routes!(routes::sections::list)) // Issue type endpoints - .route("/api/v1/issuetypes", get(routes::issuetypes::list)) - .route("/api/v1/issuetypes", post(routes::issuetypes::create)) - .route("/api/v1/issuetypes/:key", get(routes::issuetypes::get_one)) - .route("/api/v1/issuetypes/:key", put(routes::issuetypes::update)) - .route( - "/api/v1/issuetypes/:key", - delete(routes::issuetypes::delete), - ) + .routes(routes!( + routes::issuetypes::list, + routes::issuetypes::create + )) + .routes(routes!( + routes::issuetypes::get_one, + routes::issuetypes::update, + routes::issuetypes::delete + )) // Step endpoints - .route("/api/v1/issuetypes/:key/steps", get(routes::steps::list)) - .route( - "/api/v1/issuetypes/:key/steps/:step_name", - get(routes::steps::get_one), - ) - .route( - "/api/v1/issuetypes/:key/steps/:step_name", - put(routes::steps::update), - ) + .routes(routes!(routes::steps::list)) + .routes(routes!(routes::steps::get_one, routes::steps::update)) // Collection endpoints - .route("/api/v1/collections", get(routes::collections::list)) - .route( - "/api/v1/collections/active", - get(routes::collections::get_active), - ) - .route( - "/api/v1/collections/:name", - get(routes::collections::get_one), - ) - .route( - "/api/v1/collections/:name/activate", - put(routes::collections::activate), - ) + .routes(routes!(routes::collections::list)) + .routes(routes!(routes::collections::get_active)) + .routes(routes!(routes::collections::get_one)) + .routes(routes!(routes::collections::activate)) // Queue endpoints - .route("/api/v1/queue/kanban", get(routes::queue::kanban)) - .route("/api/v1/queue/status", get(routes::queue::status)) - .route("/api/v1/queue/pause", post(routes::queue::pause)) - .route("/api/v1/queue/resume", post(routes::queue::resume)) - .route("/api/v1/queue/sync", post(routes::queue::sync)) - .route( - "/api/v1/queue/sync/:provider/:project_key", - post(routes::queue::sync_collection), - ) + .routes(routes!(routes::queue::kanban)) + .routes(routes!(routes::queue::status)) + .routes(routes!(routes::queue::pause)) + .routes(routes!(routes::queue::resume)) + .routes(routes!(routes::queue::sync)) + .routes(routes!(routes::queue::sync_collection)) // Agent endpoints - .route("/api/v1/agents/active", get(routes::agents::active)) - .route( - "/api/v1/agents/:agent_id/approve", - post(routes::agents::approve_review), - ) - .route( - "/api/v1/agents/:agent_id/reject", - post(routes::agents::reject_review), - ) + .routes(routes!(routes::agents::active)) + .routes(routes!(routes::agents::get_detail)) + .routes(routes!(routes::agents::approve_review)) + .routes(routes!(routes::agents::reject_review)) + .routes(routes!(routes::agents::focus_session)) // Project endpoints - .route("/api/v1/projects", get(routes::projects::list)) - .route( - "/api/v1/projects/:name/assess", - post(routes::projects::assess), - ) + .routes(routes!(routes::projects::list)) + .routes(routes!(routes::projects::assess)) + // Ticket endpoints + .routes(routes!(routes::tickets::get_one)) + .routes(routes!(routes::tickets::update_status)) // Launch endpoints - .route( - "/api/v1/tickets/:id/launch", - post(routes::launch::launch_ticket), - ) + .routes(routes!(routes::launch::launch_ticket)) + // Workflow export endpoint + .routes(routes!(routes::workflow::export)) + // Workflow preview endpoint (issue type -> graph, no ticket) + .routes(routes!(routes::workflow::preview)) // Step completion endpoint (for opr8r wrapper) - .route( - "/api/v1/tickets/:id/steps/:step/complete", - post(routes::launch::complete_step), - ) + .routes(routes!(routes::launch::complete_step)) // Kanban provider endpoints - .route( - "/api/v1/kanban/:provider/:project_key/issuetypes", - get(routes::kanban::external_issue_types), - ) - .route( - "/api/v1/kanban/:provider/:project_key/issuetypes/sync", - post(routes::kanban::sync_issue_types), - ) + .routes(routes!(routes::kanban::provider_catalog)) + .routes(routes!(routes::kanban::external_issue_types)) + .routes(routes!(routes::kanban::sync_issue_types)) // Kanban onboarding endpoints (validate, list projects, write config, set env) - .route( - "/api/v1/kanban/validate", - post(routes::kanban_onboarding::validate_credentials), - ) - .route( - "/api/v1/kanban/projects", - post(routes::kanban_onboarding::list_projects), - ) - .route( - "/api/v1/kanban/config", - put(routes::kanban_onboarding::write_config), - ) - .route( - "/api/v1/kanban/session-env", - post(routes::kanban_onboarding::set_session_env), - ) + .routes(routes!(routes::kanban_onboarding::validate_credentials)) + .routes(routes!(routes::kanban_onboarding::list_projects)) + .routes(routes!(routes::kanban_onboarding::write_config)) + .routes(routes!(routes::kanban_onboarding::set_session_env)) // Skills endpoint - .route("/api/v1/skills", get(routes::skills::list)) + .routes(routes!(routes::skills::list)) // LLM tools endpoints - .route("/api/v1/llm-tools", get(routes::llm_tools::list)) - .route( - "/api/v1/llm-tools/default", - get(routes::llm_tools::get_default).put(routes::llm_tools::set_default), - ) - // Delegator endpoints - .route("/api/v1/delegators", get(routes::delegators::list)) - .route("/api/v1/delegators", post(routes::delegators::create)) - // from-tool must be registered before :name to avoid path capture - .route( - "/api/v1/delegators/from-tool", - post(routes::delegators::create_from_tool), - ) - .route("/api/v1/delegators/:name", get(routes::delegators::get_one)) - .route("/api/v1/delegators/:name", put(routes::delegators::update)) - .route( - "/api/v1/delegators/:name", - delete(routes::delegators::delete), - ) + .routes(routes!(routes::llm_tools::list)) + .routes(routes!( + routes::llm_tools::get_default, + routes::llm_tools::set_default + )) + // Delegator endpoints. `from-tool` is a distinct static path; axum 0.7 + // prefers static segments over `{name}`, so ordering is not required for + // correctness, but the routes stay grouped by path for clarity. + .routes(routes!( + routes::delegators::list, + routes::delegators::create + )) + .routes(routes!(routes::delegators::create_from_tool)) + .routes(routes!( + routes::delegators::get_one, + routes::delegators::update, + routes::delegators::delete + )) + // Configuration endpoints + .routes(routes!( + routes::configuration::get_config, + routes::configuration::update_config + )) // Model server endpoints - .route("/api/v1/model-servers", get(routes::model_servers::list)) - .route("/api/v1/model-servers", post(routes::model_servers::create)) - .route( - "/api/v1/model-servers/:name", - get(routes::model_servers::get_one), - ) - .route( - "/api/v1/model-servers/:name", - delete(routes::model_servers::delete), - ) - // MCP endpoints - .route( - "/api/v1/mcp/descriptor", - get(crate::mcp::descriptor::descriptor), - ) - .route("/api/v1/mcp/sse", get(crate::mcp::transport::sse_handler)) - .route( - "/api/v1/mcp/message", - post(crate::mcp::transport::message_handler), - ) + .routes(routes!( + routes::model_servers::list, + routes::model_servers::create + )) + .routes(routes!(routes::model_servers::kinds)) + .routes(routes!(routes::model_servers::models)) + .routes(routes!( + routes::model_servers::get_one, + routes::model_servers::update, + routes::model_servers::delete + )) + // MCP descriptor — always mounted so non-HTTP MCP clients can still + // discover the stdio entrypoint. + .routes(routes!(crate::mcp::descriptor::descriptor)) +} + +/// The canonical OpenAPI spec for the documented API surface. +/// +/// 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`). +pub fn openapi_spec() -> utoipa::openapi::OpenApi { + documented_router().split_for_parts().1 +} + +/// Build the API router with all routes +pub fn build_router(state: ApiState) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let mcp_enabled = state.config.mcp.http_enabled; + + 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). + if mcp_enabled { + router = router + .route("/api/v1/mcp/sse", get(crate::mcp::transport::sse_handler)) + .route( + "/api/v1/mcp/message", + post(crate::mcp::transport::message_handler), + ); + } + + let router = router .layer( TraceLayer::new_for_http() .on_request(DefaultOnRequest::new().level(Level::INFO)) @@ -187,7 +211,12 @@ pub fn build_router(state: ApiState) -> Router { ) .layer(cors) .with_state(state) - .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api)); + + #[cfg(feature = "embed-ui")] + let router = router.fallback(web_ui::spa_handler); + + router } /// Start the REST API server (standalone mode with session file and logging) diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index 80ff4385..be0d33ff 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -4,12 +4,23 @@ use utoipa::OpenApi; use crate::mcp::descriptor::McpDescriptorResponse; use crate::rest::dto::{ + ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, AssessTicketResponse, CollectionResponse, CreateDelegatorFromToolRequest, CreateDelegatorRequest, CreateFieldRequest, CreateIssueTypeRequest, CreateModelServerRequest, CreateStepRequest, DefaultLlmResponse, - DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, FieldResponse, HealthResponse, - IssueTypeResponse, IssueTypeSummary, LaunchTicketRequest, LaunchTicketResponse, - ModelServerResponse, ModelServersResponse, SetDefaultLlmRequest, SkillEntry, SkillsResponse, - StatusResponse, StepResponse, UpdateIssueTypeRequest, UpdateStepRequest, + DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, ExternalIssueTypeSummary, + FieldResponse, HealthResponse, IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, + KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, + LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, + ListKanbanProjectsResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, + ModelServerResponse, ModelServersResponse, NextStepInfo, OperatorOutput, ProjectSummary, + QueueByType, QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ReviewResponse, + SectionDto, SectionRowDto, SetDefaultLlmRequest, SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, StatusResponse, StepCompleteRequest, + StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, + UpdateIssueTypeRequest, UpdateModelServerRequest, UpdateStepRequest, UpdateTicketStatusRequest, + UpdateTicketStatusResponse, ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, WorkflowExportResponse, WriteKanbanConfigRequest, + WriteKanbanConfigResponse, }; use crate::rest::error::ErrorResponse; @@ -26,53 +37,17 @@ use crate::rest::error::ErrorResponse; url = "https://github.com/untra/operator" ) ), - paths( - // Health endpoints - crate::rest::routes::health::health, - crate::rest::routes::health::status, - // Issue type endpoints - crate::rest::routes::issuetypes::list, - crate::rest::routes::issuetypes::get_one, - crate::rest::routes::issuetypes::create, - crate::rest::routes::issuetypes::update, - crate::rest::routes::issuetypes::delete, - // Step endpoints - crate::rest::routes::steps::list, - crate::rest::routes::steps::get_one, - crate::rest::routes::steps::update, - // Collection endpoints - crate::rest::routes::collections::list, - crate::rest::routes::collections::get_active, - crate::rest::routes::collections::get_one, - crate::rest::routes::collections::activate, - // Launch endpoints - crate::rest::routes::launch::launch_ticket, - // Skills endpoints - crate::rest::routes::skills::list, - // Delegator endpoints - crate::rest::routes::delegators::list, - crate::rest::routes::delegators::get_one, - crate::rest::routes::delegators::create, - crate::rest::routes::delegators::create_from_tool, - crate::rest::routes::delegators::update, - crate::rest::routes::delegators::delete, - // LLM tools endpoints - crate::rest::routes::llm_tools::list, - crate::rest::routes::llm_tools::get_default, - crate::rest::routes::llm_tools::set_default, - // Model server endpoints - crate::rest::routes::model_servers::list, - crate::rest::routes::model_servers::get_one, - crate::rest::routes::model_servers::create, - crate::rest::routes::model_servers::delete, - // MCP endpoints - crate::mcp::descriptor::descriptor, - ), + // NOTE: `paths(...)` is intentionally omitted. Routes self-register in the + // OpenAPI spec when mounted via `utoipa_axum::routes!` in + // `crate::rest::build_router` — mounting a route *is* documenting it, so the + // two can no longer drift. See `crate::rest::openapi_spec`. components( schemas( // Response types HealthResponse, StatusResponse, + SectionDto, + SectionRowDto, IssueTypeResponse, IssueTypeSummary, FieldResponse, @@ -100,43 +75,99 @@ use crate::rest::error::ErrorResponse; ModelServerResponse, ModelServersResponse, CreateModelServerRequest, + UpdateModelServerRequest, + ModelServerKindEntry, + ModelEntry, + ModelServerModelsResponse, // LLM tools types SetDefaultLlmRequest, DefaultLlmResponse, + // Ticket types + TicketDetailResponse, + UpdateTicketStatusRequest, + UpdateTicketStatusResponse, + // Workflow export types + WorkflowExportResponse, // MCP types McpDescriptorResponse, + // Queue types + KanbanBoardResponse, + KanbanTicketCard, + QueueStatusResponse, + QueueByType, + QueueControlResponse, + KanbanSyncResponse, + // Agent types + ActiveAgentsResponse, + ActiveAgentResponse, + AgentDetailResponse, + ReviewResponse, + RejectReviewRequest, + OperatorOutput, + // Project types + ProjectSummary, + AssessTicketResponse, + // Launch step-completion types + StepCompleteRequest, + StepCompleteResponse, + NextStepInfo, + // Kanban provider types + ExternalIssueTypeSummary, + KanbanIssueTypeResponse, + SyncKanbanIssueTypesResponse, + KanbanProviderCatalogEntry, + // Kanban onboarding types + ValidateKanbanCredentialsRequest, + ValidateKanbanCredentialsResponse, + ListKanbanProjectsRequest, + ListKanbanProjectsResponse, + WriteKanbanConfigRequest, + WriteKanbanConfigResponse, + SetKanbanSessionEnvRequest, + SetKanbanSessionEnvResponse, ) ), tags( (name = "Health", description = "Health check and status endpoints"), + (name = "Status", description = "Canonical status sections (TUI / VS Code parity)"), (name = "Issue Types", description = "Issue type CRUD operations"), (name = "Steps", description = "Step management within issue types"), (name = "Collections", description = "Issue type collection management"), + (name = "Tickets", description = "Ticket CRUD and status management"), (name = "Launch", description = "Ticket launch operations"), + (name = "Workflow", description = "Export tickets to Claude dynamic workflows"), (name = "Skills", description = "Skill discovery across LLM tools"), (name = "Delegators", description = "Agent delegator CRUD operations"), (name = "ModelServers", description = "Model server (ollama, openai-compat, etc.) CRUD operations"), (name = "MCP", description = "Model Context Protocol integration"), + (name = "Queue", description = "Ticket queue board, status, and control"), + (name = "Agents", description = "Active agent tracking and review actions"), + (name = "Projects", description = "Project discovery and ticket assessment"), + (name = "Configuration", description = "Operator configuration read/write"), + (name = "Kanban", description = "Kanban provider issue types and onboarding"), ) )] pub struct ApiDoc; impl ApiDoc { - /// Generate the OpenAPI specification as a JSON string + /// Generate the OpenAPI specification as a JSON string. /// - /// The version is automatically derived from Cargo.toml to stay in sync. + /// 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. pub fn json() -> Result { - let mut spec = Self::openapi(); + let mut spec = crate::rest::openapi_spec(); spec.info.version = env!("CARGO_PKG_VERSION").to_string(); serde_json::to_string_pretty(&spec) } - /// Generate the OpenAPI specification as a YAML string + /// Generate the OpenAPI specification as a YAML string. /// /// The version is automatically derived from Cargo.toml to stay in sync. #[allow(dead_code)] pub fn yaml() -> Result { - let mut spec = Self::openapi(); + let mut spec = crate::rest::openapi_spec(); spec.info.version = env!("CARGO_PKG_VERSION").to_string(); serde_yaml::to_string(&spec) } @@ -163,6 +194,65 @@ mod tests { assert!(spec.contains("\"Collections\"")); } + #[test] + fn test_openapi_operation_ids_are_unique() { + // Structural guard: utoipa derives operationId from the bare fn name, so + // collisions (multiple `list` / `get_one` / `create`) silently produce + // an invalid spec that breaks downstream codegen. Every `#[utoipa::path]` + // sets an explicit `module_fn` operation_id; this asserts they stay + // globally unique as routes are added. + let spec: serde_json::Value = + serde_json::from_str(&ApiDoc::json().expect("generate spec")).expect("parse spec"); + + let mut ids: Vec = Vec::new(); + let paths = spec["paths"].as_object().expect("paths object"); + for (path, item) in paths { + let methods = item.as_object().expect("path item object"); + for (method, op) in methods { + let oid = op + .get("operationId") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| { + panic!("{} {path} is missing an operationId", method.to_uppercase()) + }); + ids.push(oid.to_string()); + } + } + + let mut seen = std::collections::HashSet::new(); + let mut dups: Vec<&String> = ids.iter().filter(|id| !seen.insert(*id)).collect(); + dups.sort(); + dups.dedup(); + assert!( + dups.is_empty(), + "duplicate operationId(s) in OpenAPI spec: {dups:?}" + ); + assert!( + !ids.is_empty(), + "expected at least one documented operation" + ); + } + + #[test] + fn test_openapi_includes_previously_undocumented_routes() { + // Regression guard for the drift this migration fixed: these routes are + // mounted by `build_router` and must appear in the generated spec. + let spec = ApiDoc::json().expect("generate spec"); + for path in [ + "/api/v1/queue/kanban", + "/api/v1/agents/active", + "/api/v1/projects", + "/api/v1/configuration", + "/api/v1/kanban/validate", + "/api/v1/tickets/{id}/steps/{step}/complete", + ] { + assert!( + spec.contains(path), + "spec should document the mounted route {path}" + ); + } + } + #[test] fn test_openapi_version_matches_cargo() { let spec = ApiDoc::json().expect("Failed to generate OpenAPI spec"); diff --git a/src/rest/routes/agents.rs b/src/rest/routes/agents.rs index b58eb693..2e4cda53 100644 --- a/src/rest/routes/agents.rs +++ b/src/rest/routes/agents.rs @@ -7,8 +7,10 @@ use axum::{ Json, }; +use crate::agents::cmux::{CmuxClient, SystemCmuxClient}; use crate::rest::dto::{ - ActiveAgentResponse, ActiveAgentsResponse, RejectReviewRequest, ReviewResponse, + ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, RejectReviewRequest, + ReviewResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; @@ -18,6 +20,7 @@ use crate::state::State as OperatorState; /// /// Returns a list of all currently running agents with their status and details. #[utoipa::path( + operation_id = "agents_active", get, path = "/api/v1/agents/active", tag = "Agents", @@ -62,11 +65,63 @@ pub async fn active(State(state): State) -> Result, + Path(agent_id): Path, +) -> Result, ApiError> { + let operator_state = OperatorState::load(&state.config) + .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; + + let agent = operator_state + .agents + .iter() + .find(|a| a.id == agent_id) + .ok_or_else(|| ApiError::NotFound(format!("Agent '{agent_id}' not found")))?; + + Ok(Json(AgentDetailResponse { + id: agent.id.clone(), + ticket_id: agent.ticket_id.clone(), + ticket_type: agent.ticket_type.clone(), + project: agent.project.clone(), + status: agent.status.clone(), + started_at: agent.started_at.to_rfc3339(), + last_activity: agent.last_activity.to_rfc3339(), + current_step: agent.current_step.clone(), + llm_tool: agent.llm_tool.clone(), + llm_model: agent.llm_model.clone(), + launch_mode: agent.launch_mode.clone(), + pr_url: agent.pr_url.clone(), + pr_status: agent.pr_status.clone(), + session_wrapper: agent.session_wrapper.clone(), + review_state: agent.review_state.clone(), + completed_steps: agent.completed_steps.clone(), + worktree_path: agent.worktree_path.clone(), + paired: agent.paired, + })) +} + /// Approve an agent's pending review /// /// Clears the review state and signals the agent to continue. /// The agent must be in `awaiting_input` status with a pending review. #[utoipa::path( + operation_id = "agents_approve_review", post, path = "/api/v1/agents/{agent_id}/approve", tag = "Agents", @@ -124,6 +179,7 @@ pub async fn approve_review( /// Signals the agent that the review was rejected with feedback. /// The agent should re-do the work based on the rejection reason. #[utoipa::path( + operation_id = "agents_reject_review", post, path = "/api/v1/agents/{agent_id}/reject", tag = "Agents", @@ -174,6 +230,77 @@ pub async fn reject_review( })) } +/// Focus the terminal session of a running agent in its session wrapper. +/// +/// The web UI's launch panel calls this for **cmux** launches: cmux exposes no +/// browser URL scheme, so the operator control plane (which runs inside cmux) +/// shells out to `cmux focus-workspace` for the agent's saved workspace ref to +/// bring its pane to the foreground. Other wrappers are unsupported here — VS +/// Code focuses through its extension's URI handler, and tmux/zellij are +/// display-only in the UI, so it never calls this for them. +#[utoipa::path( + operation_id = "agents_focus_session", + post, + path = "/api/v1/agents/{agent_id}/focus", + tag = "Agents", + params( + ("agent_id" = String, Path, description = "The agent ID whose session to focus") + ), + responses( + (status = 200, description = "Session focused"), + (status = 400, description = "Wrapper unsupported, or no session refs to focus"), + (status = 404, description = "Agent not found") + ) +)] +pub async fn focus_session( + State(state): State, + Path(agent_id): Path, +) -> Result<(), ApiError> { + let operator_state = OperatorState::load(&state.config) + .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; + + let agent = operator_state + .agents + .iter() + .find(|a| a.id == agent_id) + .ok_or_else(|| ApiError::NotFound(format!("Agent '{agent_id}' not found")))?; + + match agent.session_wrapper.as_deref() { + Some("cmux") => { + let workspace_ref = agent.session_context_ref.clone(); + let window_ref = agent.session_window_ref.clone(); + if workspace_ref.is_none() && window_ref.is_none() { + return Err(ApiError::BadRequest(format!( + "Agent '{agent_id}' has no cmux session refs to focus" + ))); + } + let cmux_config = state.config.sessions.cmux.clone(); + // cmux focusing shells out to the cmux binary; run it off the async + // worker so a slow subprocess can't stall the runtime. + tokio::task::spawn_blocking(move || -> Result<(), crate::agents::cmux::CmuxError> { + let client = SystemCmuxClient::from_config(&cmux_config); + client.check_available()?; + // Prefer the workspace (the agent's pane); fall back to its window. + if let Some(ws) = workspace_ref.as_deref() { + client.focus_workspace(ws) + } else if let Some(win) = window_ref.as_deref() { + client.focus_window(win) + } else { + unreachable!("at least one ref present (checked above)") + } + }) + .await + .map_err(|e| ApiError::InternalError(format!("focus task failed: {e}")))? + .map_err(|e| ApiError::InternalError(format!("cmux focus failed: {e}")))?; + Ok(()) + } + other => Err(ApiError::BadRequest(format!( + "Focus is not supported for session wrapper '{}'", + other.unwrap_or("none") + ))), + } +} + /// Write a review signal file for the agent to pick up fn write_review_signal( state: &ApiState, @@ -225,4 +352,19 @@ mod tests { // In this case, State::load will create a new empty state if file doesn't exist assert!(result.is_ok() || result.is_err()); } + + #[tokio::test] + async fn test_focus_session_unknown_agent_is_404() { + // Point state at a fresh temp dir so load() returns an empty agent list + // deterministically (not the repo's own state). An unknown id → NotFound. + // The cmux success path and the unsupported-wrapper 400 branch need a + // persisted agent / live cmux, so they're covered by manual e2e. + let tmp = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = tmp.path().to_string_lossy().into_owned(); + let state = ApiState::new(config, tmp.path().to_path_buf()); + + let result = focus_session(State(state), Path("no-such-agent".to_string())).await; + assert!(matches!(result, Err(ApiError::NotFound(_)))); + } } diff --git a/src/rest/routes/collections.rs b/src/rest/routes/collections.rs index 348e36c6..2c38b374 100644 --- a/src/rest/routes/collections.rs +++ b/src/rest/routes/collections.rs @@ -11,6 +11,7 @@ use crate::rest::state::ApiState; /// List all collections #[utoipa::path( + operation_id = "collections_list", get, path = "/api/v1/collections", tag = "Collections", @@ -32,6 +33,7 @@ pub async fn list(State(state): State) -> Json /// Get the currently active collection #[utoipa::path( + operation_id = "collections_get_active", get, path = "/api/v1/collections/active", tag = "Collections", @@ -54,6 +56,7 @@ pub async fn get_active( /// Get a single collection by name #[utoipa::path( + operation_id = "collections_get_one", get, path = "/api/v1/collections/{name}", tag = "Collections", @@ -84,6 +87,7 @@ pub async fn get_one( /// Activate a collection #[utoipa::path( + operation_id = "collections_activate", put, path = "/api/v1/collections/{name}/activate", tag = "Collections", diff --git a/src/rest/routes/configuration.rs b/src/rest/routes/configuration.rs new file mode 100644 index 00000000..f9cb3123 --- /dev/null +++ b/src/rest/routes/configuration.rs @@ -0,0 +1,65 @@ +//! Configuration read/write endpoints. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; + +use crate::config::Config; +use crate::rest::state::ApiState; + +/// Get the current configuration +/// +/// Returns the full operator configuration as a JSON object. The body is left +/// opaque in the OpenAPI spec because the `Config` tree is large and no client +/// consumes its OpenAPI schema (the TS `Config` type is generated separately by +/// ts-rs). +#[utoipa::path( + get, + path = "/api/v1/configuration", + tag = "Configuration", + operation_id = "configuration_get", + responses( + (status = 200, description = "Current configuration as a JSON object", body = serde_json::Value) + ) +)] +pub async fn get_config(State(state): State) -> Json { + Json((*state.config).clone()) +} + +/// Update configuration and save to disk +#[utoipa::path( + put, + path = "/api/v1/configuration", + tag = "Configuration", + operation_id = "configuration_update", + request_body = serde_json::Value, + responses( + (status = 200, description = "Updated configuration as a JSON object", body = serde_json::Value), + (status = 500, description = "Failed to save configuration") + ) +)] +pub async fn update_config( + State(state): State, + Json(incoming): Json, +) -> Result, (StatusCode, String)> { + incoming + .save() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let _ = &state; + Ok(Json(incoming)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[tokio::test] + async fn test_get_config() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test")); + + let Json(cfg) = get_config(State(state)).await; + assert!(!cfg.projects.is_empty() || cfg.projects.is_empty()); + } +} diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 4a900c54..65b49386 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -18,6 +18,7 @@ use crate::rest::state::ApiState; /// List all configured delegators #[utoipa::path( + operation_id = "delegators_list", get, path = "/api/v1/delegators", tag = "Delegators", @@ -38,6 +39,7 @@ pub async fn list(State(state): State) -> Json { /// Get a single delegator by name #[utoipa::path( + operation_id = "delegators_get_one", get, path = "/api/v1/delegators/{name}", tag = "Delegators", @@ -65,6 +67,7 @@ pub async fn get_one( /// Create a new delegator #[utoipa::path( + operation_id = "delegators_create", post, path = "/api/v1/delegators", tag = "Delegators", @@ -108,6 +111,7 @@ pub async fn create( /// Delete a delegator by name #[utoipa::path( + operation_id = "delegators_delete", delete, path = "/api/v1/delegators/{name}", tag = "Delegators", @@ -189,6 +193,7 @@ fn delegator_to_response(d: &Delegator) -> DelegatorResponse { /// /// Pre-populates delegator fields from the detected tool, requiring minimal input. #[utoipa::path( + operation_id = "delegators_create_from_tool", post, path = "/api/v1/delegators/from-tool", tag = "Delegators", @@ -254,6 +259,7 @@ pub async fn create_from_tool( /// Update an existing delegator #[utoipa::path( + operation_id = "delegators_update", put, path = "/api/v1/delegators/{name}", tag = "Delegators", diff --git a/src/rest/routes/health.rs b/src/rest/routes/health.rs index a5ef3441..6f437ce0 100644 --- a/src/rest/routes/health.rs +++ b/src/rest/routes/health.rs @@ -7,6 +7,7 @@ use crate::rest::state::ApiState; /// Health check endpoint #[utoipa::path( + operation_id = "health_check", get, path = "/api/v1/health", tag = "Health", @@ -23,6 +24,7 @@ pub async fn health() -> Json { /// Get service status with registry info #[utoipa::path( + operation_id = "health_status", get, path = "/api/v1/status", tag = "Health", diff --git a/src/rest/routes/issuetypes.rs b/src/rest/routes/issuetypes.rs index 2e9713e5..90ff6c8b 100644 --- a/src/rest/routes/issuetypes.rs +++ b/src/rest/routes/issuetypes.rs @@ -14,6 +14,7 @@ use crate::rest::state::ApiState; /// List all issue types #[utoipa::path( + operation_id = "issuetypes_list", get, path = "/api/v1/issuetypes", tag = "Issue Types", @@ -29,6 +30,7 @@ pub async fn list(State(state): State) -> Json> /// Get a single issue type by key #[utoipa::path( + operation_id = "issuetypes_get_one", get, path = "/api/v1/issuetypes/{key}", tag = "Issue Types", @@ -54,6 +56,7 @@ pub async fn get_one( /// Create a new issue type #[utoipa::path( + operation_id = "issuetypes_create", post, path = "/api/v1/issuetypes", tag = "Issue Types", @@ -109,6 +112,7 @@ pub async fn create( /// Update an existing issue type #[utoipa::path( + operation_id = "issuetypes_update", put, path = "/api/v1/issuetypes/{key}", tag = "Issue Types", @@ -201,6 +205,7 @@ pub async fn update( /// Delete an issue type #[utoipa::path( + operation_id = "issuetypes_delete", delete, path = "/api/v1/issuetypes/{key}", tag = "Issue Types", diff --git a/src/rest/routes/kanban.rs b/src/rest/routes/kanban.rs index ec17fa67..082d068f 100644 --- a/src/rest/routes/kanban.rs +++ b/src/rest/routes/kanban.rs @@ -3,19 +3,84 @@ use axum::extract::{Path, State}; use axum::Json; -use crate::api::providers::kanban::get_provider_from_config; +use crate::api::providers::kanban::{get_provider_from_config, KanbanProviderType}; +use crate::config::kanban::KanbanConfig; use crate::config::Config; use crate::rest::dto::{ - ExternalIssueTypeSummary, KanbanIssueTypeResponse, SyncKanbanIssueTypesResponse, + ExternalIssueTypeSummary, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, + SyncKanbanIssueTypesResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; use crate::services::kanban_issuetype_service::KanbanIssueTypeService; +/// Build the provider catalog from the canonical `KanbanProviderType::ALL` +/// list, flagging each provider as `configured` when the kanban config already +/// holds at least one instance of it. +fn build_provider_catalog(kanban: &KanbanConfig) -> Vec { + KanbanProviderType::ALL + .into_iter() + .map(|p| { + let configured = match p { + KanbanProviderType::Jira => !kanban.jira.is_empty(), + KanbanProviderType::Linear => !kanban.linear.is_empty(), + KanbanProviderType::Github => !kanban.github.is_empty(), + }; + KanbanProviderCatalogEntry { + slug: p.slug().to_string(), + display_name: p.display_name().to_string(), + description: p.connect_blurb().to_string(), + setup_url: p.setup_url().to_string(), + icon: p.icon().to_string(), + configured, + } + }) + .collect() +} + +/// GET /`api/v1/kanban/providers` +/// +/// Returns the catalog of supported kanban providers (Jira, Linear, GitHub), +/// each flagged with whether it is already configured. This is the shared +/// source of truth for the web `/#/kanban` list view and the VS Code +/// onboarding picker. +#[utoipa::path( + get, + path = "/api/v1/kanban/providers", + tag = "Kanban", + operation_id = "kanban_provider_catalog", + responses( + (status = 200, description = "Supported kanban providers", body = Vec) + ) +)] +pub async fn provider_catalog( + State(state): State, +) -> Result>, ApiError> { + // Reload config from disk so freshly onboarded providers are reflected in + // the `configured` flags without requiring a server restart. + let fresh_config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + Ok(Json(build_provider_catalog(&fresh_config.kanban))) +} + /// GET /`api/v1/kanban/:provider/:project_key/issuetypes` /// /// Returns kanban issue types from the persisted catalog for a given provider/project. /// Falls back to fetching live from the provider if no catalog exists. +#[utoipa::path( + get, + path = "/api/v1/kanban/{provider}/{project_key}/issuetypes", + tag = "Kanban", + operation_id = "kanban_external_issue_types", + params( + ("provider" = String, Path, description = "Kanban provider name (e.g. jira, linear, github)"), + ("project_key" = String, Path, description = "Provider project/team key") + ), + responses( + (status = 200, description = "External issue types", body = Vec), + (status = 400, description = "Unknown provider/project"), + (status = 500, description = "Failed to read catalog or fetch from provider") + ) +)] pub async fn external_issue_types( State(state): State, Path((provider_name, project_key)): Path<(String, String)>, @@ -68,6 +133,21 @@ pub async fn external_issue_types( /// POST /`api/v1/kanban/:provider/:project_key/issuetypes/sync` /// /// Refreshes the local kanban issue type catalog from the provider. +#[utoipa::path( + post, + path = "/api/v1/kanban/{provider}/{project_key}/issuetypes/sync", + tag = "Kanban", + operation_id = "kanban_sync_issue_types", + params( + ("provider" = String, Path, description = "Kanban provider name (e.g. jira, linear, github)"), + ("project_key" = String, Path, description = "Provider project/team key") + ), + responses( + (status = 200, description = "Synced issue types", body = SyncKanbanIssueTypesResponse), + (status = 400, description = "Unknown provider/project"), + (status = 500, description = "Failed to sync from provider") + ) +)] pub async fn sync_issue_types( State(state): State, Path((provider_name, project_key)): Path<(String, String)>, @@ -142,6 +222,32 @@ mod tests { assert!(!json.contains("icon_url")); // None skipped } + #[test] + fn test_build_provider_catalog_lists_all_three_with_configured_flags() { + let mut kanban = crate::config::kanban::KanbanConfig::default(); + kanban.github.insert( + "my-org".into(), + crate::config::kanban::GithubProjectsConfig::default(), + ); + + let catalog = build_provider_catalog(&kanban); + + let slugs: Vec<&str> = catalog.iter().map(|e| e.slug.as_str()).collect(); + assert_eq!(slugs, vec!["jira", "linear", "github"]); + + let github = catalog.iter().find(|e| e.slug == "github").unwrap(); + assert!(github.configured); + assert_eq!(github.display_name, "GitHub Projects"); + assert_eq!(github.icon, "github"); + assert_eq!( + github.setup_url, + "https://github.com/settings/personal-access-tokens" + ); + + let jira = catalog.iter().find(|e| e.slug == "jira").unwrap(); + assert!(!jira.configured); + } + #[test] fn test_sync_response_serialization() { let response = SyncKanbanIssueTypesResponse { diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index 26d937d4..3771751b 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -21,6 +21,16 @@ use crate::services::kanban_onboarding; /// Validate credentials against the live provider API without persisting /// anything. Auth failures return `valid: false` with an `error` string /// rather than a 4xx/5xx status so clients can display errors inline. +#[utoipa::path( + post, + path = "/api/v1/kanban/validate", + tag = "Kanban", + operation_id = "kanban_validate_credentials", + request_body = ValidateKanbanCredentialsRequest, + responses( + (status = 200, description = "Validation result (valid flag + optional error)", body = ValidateKanbanCredentialsResponse) + ) +)] pub async fn validate_credentials( State(_state): State, Json(req): Json, @@ -33,6 +43,16 @@ pub async fn validate_credentials( /// /// List available projects/teams for the given provider using ephemeral /// credentials. No persistence side effects. +#[utoipa::path( + post, + path = "/api/v1/kanban/projects", + tag = "Kanban", + operation_id = "kanban_list_projects", + request_body = ListKanbanProjectsRequest, + responses( + (status = 200, description = "Available projects/teams for the provider", body = ListKanbanProjectsResponse) + ) +)] pub async fn list_projects( State(_state): State, Json(req): Json, @@ -45,6 +65,16 @@ pub async fn list_projects( /// /// Write or upsert a kanban provider+project section into `config.toml`. /// Does NOT receive the actual secret — only the env var name (`api_key_env`). +#[utoipa::path( + put, + path = "/api/v1/kanban/config", + tag = "Kanban", + operation_id = "kanban_write_config", + request_body = WriteKanbanConfigRequest, + responses( + (status = 200, description = "Config section written/upserted", body = WriteKanbanConfigResponse) + ) +)] pub async fn write_config( State(_state): State, Json(req): Json, @@ -59,6 +89,16 @@ pub async fn write_config( /// Set kanban env vars on the server process for the current session so /// subsequent `from_config()` calls find the API key. Returns a /// `shell_export_block` with placeholder values for the client to display. +#[utoipa::path( + post, + path = "/api/v1/kanban/session-env", + tag = "Kanban", + operation_id = "kanban_set_session_env", + request_body = SetKanbanSessionEnvRequest, + responses( + (status = 200, description = "Session env vars set; returns a shell export block", body = SetKanbanSessionEnvResponse) + ) +)] pub async fn set_session_env( State(_state): State, Json(req): Json, diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index ada940f5..96e76af0 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -137,6 +137,7 @@ fn prepared_launch_to_response(prepared: PreparedLaunch) -> LaunchTicketResponse /// Claims the ticket, sets up worktree if needed, generates the LLM command, /// and returns all details needed to execute in an external terminal (VS Code, etc.). #[utoipa::path( + operation_id = "launch_launch_ticket", post, path = "/api/v1/tickets/{id}/launch", tag = "Launch", @@ -226,6 +227,7 @@ fn build_launch_options( request.delegator.as_deref(), request.provider.as_deref(), request.model.as_deref(), + request.model_server.as_deref(), request.yolo_mode, agent_context, ) @@ -252,6 +254,7 @@ fn build_relaunch_options( /// Called by the opr8r wrapper when an LLM command completes. /// Returns next step info and whether to auto-proceed. #[utoipa::path( + operation_id = "launch_complete_step", post, path = "/api/v1/tickets/{id}/steps/{step}/complete", tag = "Launch", @@ -413,6 +416,7 @@ mod tests { delegator: None, provider: None, model: None, + model_server: None, yolo_mode: false, wrapper: None, retry_reason: None, @@ -434,6 +438,7 @@ mod tests { delegator: None, provider: None, model: None, + model_server: None, yolo_mode: true, wrapper: Some("vscode".to_string()), retry_reason: None, @@ -454,6 +459,7 @@ mod tests { delegator: None, provider: Some("unknown-provider".to_string()), model: None, + model_server: None, yolo_mode: false, wrapper: None, retry_reason: None, @@ -471,6 +477,7 @@ mod tests { delegator: None, provider: None, model: None, + model_server: None, yolo_mode: false, wrapper: None, retry_reason: Some("Previous attempt timed out".to_string()), @@ -517,6 +524,7 @@ mod tests { delegator: Some("full-delegator".to_string()), provider: None, model: None, + model_server: None, yolo_mode: false, wrapper: None, retry_reason: None, @@ -555,6 +563,7 @@ mod tests { delegator: Some("minimal".to_string()), provider: None, model: None, + model_server: None, yolo_mode: false, wrapper: None, retry_reason: None, @@ -600,6 +609,7 @@ mod tests { delegator: None, provider: None, model: None, + model_server: None, yolo_mode: false, wrapper: None, retry_reason: None, diff --git a/src/rest/routes/llm_tools.rs b/src/rest/routes/llm_tools.rs index 8de31999..bd98119f 100644 --- a/src/rest/routes/llm_tools.rs +++ b/src/rest/routes/llm_tools.rs @@ -13,6 +13,7 @@ use crate::rest::state::ApiState; /// List detected LLM tools with model aliases #[utoipa::path( + operation_id = "llm_tools_list", get, path = "/api/v1/llm-tools", tag = "LLM Tools", @@ -28,6 +29,7 @@ pub async fn list(State(state): State) -> Json { /// Get the current default LLM tool and model #[utoipa::path( + operation_id = "llm_tools_get_default", get, path = "/api/v1/llm-tools/default", tag = "LLM Tools", @@ -54,6 +56,7 @@ pub async fn get_default(State(state): State) -> Json Option<(ModelServer, bool)> { + if let Some(s) = config.model_servers.iter().find(|s| s.name == name) { + return Some((s.clone(), true)); + } + IMPLICIT_TOOL_NAMES + .iter() + .map(|t| implicit_model_server_for_tool(t)) + .find(|s| s.name == name) + .map(|s| (s, false)) +} + fn server_to_response(s: &ModelServer, user_declared: bool) -> ModelServerResponse { ModelServerResponse { name: s.name.clone(), @@ -31,6 +47,7 @@ fn server_to_response(s: &ModelServer, user_declared: bool) -> ModelServerRespon /// List all model servers (user-declared + implicit builtins) #[utoipa::path( + operation_id = "model_servers_list", get, path = "/api/v1/model-servers", tag = "ModelServers", @@ -59,6 +76,7 @@ pub async fn list(State(state): State) -> Json { /// Get a single model server by name #[utoipa::path( + operation_id = "model_servers_get_one", get, path = "/api/v1/model-servers/{name}", tag = "ModelServers", @@ -90,6 +108,7 @@ pub async fn get_one( /// Create a new model server #[utoipa::path( + operation_id = "model_servers_create", post, path = "/api/v1/model-servers", tag = "ModelServers", @@ -146,6 +165,7 @@ pub async fn create( /// /// Implicit builtin servers cannot be deleted. #[utoipa::path( + operation_id = "model_servers_delete", delete, path = "/api/v1/model-servers/{name}", tag = "ModelServers", @@ -190,6 +210,123 @@ pub async fn delete( Ok(Json(response)) } +/// Update an existing user-declared model server +#[utoipa::path( + operation_id = "model_servers_update", + put, + path = "/api/v1/model-servers/{name}", + tag = "ModelServers", + params( + ("name" = String, Path, description = "Model server name") + ), + request_body = UpdateModelServerRequest, + responses( + (status = 200, description = "Model server updated", body = ModelServerResponse), + (status = 404, description = "Model server not found"), + (status = 409, description = "Cannot update implicit builtin server") + ) +)] +pub async fn update( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result, ApiError> { + if IMPLICIT_TOOL_NAMES + .iter() + .any(|t| implicit_model_server_for_tool(t).name == name) + { + return Err(ApiError::Conflict(format!( + "'{name}' is an implicit builtin and cannot be updated" + ))); + } + + let mut config = Config::load(None).unwrap_or_else(|_| (*state.config).clone()); + let server = config + .model_servers + .iter_mut() + .find(|s| s.name == name) + .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))?; + + server.kind = req.kind; + server.base_url = req.base_url; + server.api_key_env = req.api_key_env; + server.extra_env = req.extra_env; + server.display_name = req.display_name; + let updated = server.clone(); + + config + .save() + .map_err(|e| ApiError::InternalError(format!("Failed to save config: {e}")))?; + + Ok(Json(server_to_response(&updated, true))) +} + +/// List the models a server offers, via a live probe of its inference endpoint. +/// +/// The probe doubles as a reachability check — `reachable: false` with an `error` +/// when the endpoint is unreachable or rejects the request. +#[utoipa::path( + operation_id = "model_servers_models", + get, + path = "/api/v1/model-servers/{name}/models", + tag = "ModelServers", + params( + ("name" = String, Path, description = "Model server name") + ), + responses( + (status = 200, description = "Models offered by the server", body = ModelServerModelsResponse), + (status = 404, description = "Model server not found") + ) +)] +pub async fn models( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let (server, _) = find_server(&state.config, &name) + .ok_or_else(|| ApiError::NotFound(format!("Model server '{name}' not found")))?; + + let outcome = probe_models(&server).await; + Ok(Json(ModelServerModelsResponse { + server: name, + reachable: outcome.reachable, + models: outcome + .models + .into_iter() + .map(|m| ModelEntry { + id: m.id, + display_name: m.display_name, + }) + .collect(), + error: outcome.error, + })) +} + +/// The catalog of supported model-server kinds (single source of truth). +#[utoipa::path( + operation_id = "model_servers_kinds", + get, + path = "/api/v1/model-servers/kinds", + tag = "ModelServers", + responses( + (status = 200, description = "Supported model-server kinds", body = [ModelServerKindEntry]) + ) +)] +pub async fn kinds() -> Json> { + Json( + ModelServerKind::ALL + .iter() + .map(|k| ModelServerKindEntry { + slug: k.slug().to_string(), + display_name: k.display_name().to_string(), + description: k.connect_blurb().to_string(), + setup_url: k.setup_url().to_string(), + icon: k.icon().to_string(), + is_builtin: k.is_builtin(), + }) + .collect(), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -242,6 +379,41 @@ mod tests { assert!(!server.user_declared); } + #[tokio::test] + async fn test_kinds_lists_all_catalog_entries() { + 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(&"openai-compat")); + assert!(slugs.contains(&"anthropic-api")); + // Vendor APIs are flagged builtin. + let anthropic = resp.0.iter().find(|k| k.slug == "anthropic-api").unwrap(); + assert!(anthropic.is_builtin); + let ollama = resp.0.iter().find(|k| k.slug == "ollama").unwrap(); + assert!(!ollama.is_builtin); + } + + #[tokio::test] + async fn test_models_builtin_without_base_url_is_unreachable() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-models")); + let resp = models(State(state), Path("anthropic-api".to_string())) + .await + .expect("builtin resolves"); + assert_eq!(resp.0.server, "anthropic-api"); + assert!(!resp.0.reachable); + assert!(resp.0.models.is_empty()); + assert!(resp.0.error.is_some()); + } + + #[tokio::test] + async fn test_models_404_on_unknown() { + let config = Config::default(); + let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-models-404")); + let resp = models(State(state), Path("nope".to_string())).await; + assert!(resp.is_err()); + } + #[tokio::test] async fn test_get_one_404_on_unknown() { let config = Config::default(); diff --git a/src/rest/routes/projects.rs b/src/rest/routes/projects.rs index 4dd5346a..501a6ead 100644 --- a/src/rest/routes/projects.rs +++ b/src/rest/routes/projects.rs @@ -5,15 +5,16 @@ use axum::{ Json, }; -use crate::backstage::analyzer::ProjectAnalysis; use crate::queue::creator::{render_template, TicketCreator}; use crate::rest::dto::{AssessTicketResponse, ProjectSummary}; use crate::rest::error::ApiError; use crate::rest::state::ApiState; +use crate::taxonomy::analyzer::ProjectAnalysis; use crate::templates::TemplateType; /// List all configured projects with analysis data #[utoipa::path( + operation_id = "projects_list", get, path = "/api/v1/projects", tag = "Projects", @@ -124,6 +125,7 @@ pub async fn list(State(state): State) -> Json> { /// Create an ASSESS ticket for a project #[utoipa::path( + operation_id = "projects_assess", post, path = "/api/v1/projects/{name}/assess", tag = "Projects", @@ -155,7 +157,7 @@ pub async fn assess( let mut values = creator.generate_default_values(template_type, &name); values.insert( "summary".to_string(), - format!("Assess {name} for Backstage catalog"), + format!("Assess {name} project structure"), ); // Render template content diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index 915cca2b..8ec1f024 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -39,6 +39,7 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { /// Returns tickets organized into four columns: queue, running, awaiting, done. /// Tickets are sorted by priority within each column, then by timestamp (FIFO). #[utoipa::path( + operation_id = "queue_kanban", get, path = "/api/v1/queue/kanban", tag = "Queue", @@ -140,6 +141,7 @@ pub async fn kanban(State(state): State) -> Result) -> Result) -> Result) -> Result) -> Result) + ) +)] +pub async fn list(State(state): State) -> Json> { + match section_provider() { + Some(provider) => { + let registry = state.registry.read().await; + // Serving this request proves the API (and embedded Web UI) are up, + // so report live connection facts the config-only snapshot can't know. + let live = LiveConnectionStatus { + api_running: true, + port: state.config.rest_api.port, + mcp_http_enabled: state.config.mcp.http_enabled, + mcp_active_sessions: state.mcp_sessions.lock().await.len(), + }; + Json(provider(&state.config, ®istry, &live)) + } + None => Json(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + #[tokio::test] + async fn test_sections_empty_when_no_provider_registered() { + // The binary registers the provider at startup; tests never do, so the + // endpoint returns an empty list here. The real section logic is covered + // by the ui-side `build_section_dtos` tests. + let state = ApiState::new(Config::default(), PathBuf::from("/tmp/test-sections")); + let resp = list(State(state)).await; + assert!(resp.0.is_empty()); + } +} diff --git a/src/rest/routes/skills.rs b/src/rest/routes/skills.rs index 96689744..b1ae49fb 100644 --- a/src/rest/routes/skills.rs +++ b/src/rest/routes/skills.rs @@ -10,6 +10,7 @@ use crate::rest::state::ApiState; /// List all discovered skills across LLM tools #[utoipa::path( + operation_id = "skills_list", get, path = "/api/v1/skills", tag = "Skills", diff --git a/src/rest/routes/steps.rs b/src/rest/routes/steps.rs index 20ab710b..a163d5c9 100644 --- a/src/rest/routes/steps.rs +++ b/src/rest/routes/steps.rs @@ -13,6 +13,7 @@ use crate::templates::schema::{PermissionMode, ReviewType, StepOutput}; /// List all steps for an issue type #[utoipa::path( + operation_id = "steps_list", get, path = "/api/v1/issuetypes/{key}/steps", tag = "Steps", @@ -39,6 +40,7 @@ pub async fn list( /// Get a single step by name #[utoipa::path( + operation_id = "steps_get_one", get, path = "/api/v1/issuetypes/{key}/steps/{step_name}", tag = "Steps", @@ -69,6 +71,7 @@ pub async fn get_one( /// Update a step #[utoipa::path( + operation_id = "steps_update", put, path = "/api/v1/issuetypes/{key}/steps/{step_name}", tag = "Steps", diff --git a/src/rest/routes/tickets.rs b/src/rest/routes/tickets.rs new file mode 100644 index 00000000..747917eb --- /dev/null +++ b/src/rest/routes/tickets.rs @@ -0,0 +1,206 @@ +//! Ticket CRUD endpoints for the REST API. +//! +//! Provides endpoints for fetching ticket details and updating ticket status. +//! These endpoints power the embedded web UI's kanban board and detail drawer. + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::queue::{Queue, Ticket}; +use crate::rest::dto::{ + TicketDetailResponse, UpdateTicketStatusRequest, UpdateTicketStatusResponse, +}; +use crate::rest::error::ApiError; +use crate::rest::state::ApiState; + +/// Find a ticket across all directories (queue, in-progress, completed) +pub(crate) fn find_ticket_anywhere(queue: &Queue, ticket_id: &str) -> Result { + // find_ticket searches queue + in-progress + if let Some(ticket) = queue + .find_ticket(ticket_id) + .map_err(|e| ApiError::InternalError(e.to_string()))? + { + return Ok(ticket); + } + + // Also search completed + let completed = queue + .list_completed() + .map_err(|e| ApiError::InternalError(e.to_string()))?; + completed + .into_iter() + .find(|t| t.id == ticket_id || t.filename.contains(ticket_id)) + .ok_or_else(|| ApiError::NotFound(format!("Ticket '{ticket_id}' not found"))) +} + +/// Get full ticket details by ID +/// +/// Returns complete ticket data including content, metadata, step history, +/// and session information. Searches queue, in-progress, and completed directories. +#[utoipa::path( + operation_id = "tickets_get_one", + get, + path = "/api/v1/tickets/{id}", + tag = "Tickets", + params( + ("id" = String, Path, description = "Ticket ID (e.g., FEAT-7598)") + ), + responses( + (status = 200, description = "Ticket details", body = TicketDetailResponse), + (status = 404, description = "Ticket not found") + ) +)] +pub async fn get_one( + State(state): State, + Path(ticket_id): Path, +) -> 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 step_display_name = ticket.current_step_display_name(); + + Ok(Json(TicketDetailResponse { + id: ticket.id, + summary: ticket.summary, + ticket_type: ticket.ticket_type, + project: ticket.project, + status: ticket.status, + step: ticket.step, + step_display_name: if step_display_name.is_empty() { + None + } else { + Some(step_display_name) + }, + priority: ticket.priority, + timestamp: ticket.timestamp, + content: ticket.content, + filename: ticket.filename, + filepath: ticket.filepath, + sessions: ticket.sessions, + step_delegators: ticket.step_delegators, + worktree_path: ticket.worktree_path, + branch: ticket.branch, + external_id: ticket.external_id, + external_url: ticket.external_url, + external_provider: ticket.external_provider, + })) +} + +/// Update a ticket's status +/// +/// Moves a ticket between queue directories based on the target status. +/// Valid transitions: queued, running, awaiting, done. +#[utoipa::path( + operation_id = "tickets_update_status", + put, + path = "/api/v1/tickets/{id}/status", + tag = "Tickets", + params( + ("id" = String, Path, description = "Ticket ID to update") + ), + request_body = UpdateTicketStatusRequest, + responses( + (status = 200, description = "Ticket status updated", body = UpdateTicketStatusResponse), + (status = 400, description = "Invalid status value"), + (status = 404, description = "Ticket not found") + ) +)] +pub async fn update_status( + State(state): State, + Path(ticket_id): Path, + Json(request): Json, +) -> Result, ApiError> { + let valid_statuses = ["queued", "running", "awaiting", "done"]; + if !valid_statuses.contains(&request.status.as_str()) { + return Err(ApiError::BadRequest(format!( + "Invalid status '{}'. Must be one of: {}", + request.status, + valid_statuses.join(", ") + ))); + } + + let queue = Queue::new(&state.config).map_err(|e| ApiError::InternalError(e.to_string()))?; + + let ticket = find_ticket_anywhere(&queue, &ticket_id)?; + + let previous_status = ticket.status.clone(); + let target_status = request.status.as_str(); + + // Determine target directory + let tickets_path = state.config.tickets_path(); + let dst_dir = match target_status { + "queued" => tickets_path.join("queue"), + "running" | "awaiting" => tickets_path.join("in-progress"), + "done" => tickets_path.join("completed"), + _ => unreachable!(), + }; + + let src = std::path::PathBuf::from(&ticket.filepath); + let dst = dst_dir.join(&ticket.filename); + + // Ensure target directory exists + std::fs::create_dir_all(&dst_dir) + .map_err(|e| ApiError::InternalError(format!("Failed to create directory: {e}")))?; + + // Move the file if source and destination differ + if src != dst { + std::fs::rename(&src, &dst) + .map_err(|e| ApiError::InternalError(format!("Failed to move ticket: {e}")))?; + } + + // Update the status field in the ticket file + if previous_status != target_status { + let mut moved_ticket = Ticket::from_file(&dst) + .map_err(|e| ApiError::InternalError(format!("Failed to reload ticket: {e}")))?; + moved_ticket + .update_field("status", target_status) + .map_err(|e| ApiError::InternalError(format!("Failed to update status field: {e}")))?; + } + + Ok(Json(UpdateTicketStatusResponse { + id: ticket.id, + previous_status, + status: target_status.to_string(), + message: format!("Ticket moved to '{target_status}'"), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn make_state() -> ApiState { + let config = Config::default(); + ApiState::new(config, PathBuf::from("/tmp/test-tickets")) + } + + #[test] + fn test_valid_statuses() { + let valid = ["queued", "running", "awaiting", "done"]; + for s in &valid { + assert!(valid.contains(s)); + } + assert!(!valid.contains(&"invalid")); + } + + #[tokio::test] + async fn test_get_ticket_not_found() { + let state = make_state(); + let result = get_one(State(state), Path("NONEXISTENT-999".to_string())).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_update_status_invalid() { + let state = make_state(); + let request = UpdateTicketStatusRequest { + status: "invalid".to_string(), + }; + let result = update_status(State(state), Path("FEAT-001".to_string()), Json(request)).await; + assert!(result.is_err()); + } +} diff --git a/src/rest/routes/workflow.rs b/src/rest/routes/workflow.rs new file mode 100644 index 00000000..58c765f9 --- /dev/null +++ b/src/rest/routes/workflow.rs @@ -0,0 +1,121 @@ +//! Workflow export endpoint for the REST API. +//! +//! Renders a ticket (against its issue type) into a Claude Code dynamic +//! workflow `.js`. This is the HTTP face of the same shared code path the CLI +//! and TUI use in-process (`workflow_gen::export_workflow_for_ticket`), so the +//! web UI and VS Code extension produce identical output. + +use axum::{ + extract::{Path, State}, + Json, +}; + +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; + +/// Export a ticket to a Claude dynamic workflow. +/// +/// Resolves the ticket (searching queue, in-progress, and completed), looks up +/// its issue type in the registry, and returns the rendered `.js` plus a +/// suggested filename. +#[utoipa::path( + operation_id = "workflow_export", + post, + path = "/api/v1/tickets/{id}/workflow-export", + tag = "Workflow", + params( + ("id" = String, Path, description = "Ticket ID (e.g., FEAT-7598)") + ), + responses( + (status = 200, description = "Generated workflow", body = WorkflowExportResponse), + (status = 404, description = "Ticket or issue type not found", body = crate::rest::error::ErrorResponse) + ) +)] +pub async fn export( + State(state): State, + Path(ticket_id): Path, +) -> 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()))?; + + Ok(Json(exported.into())) +} + +/// Preview the workflow for an issue type (no ticket required). +/// +/// Renders the issue type's step structure into a Claude dynamic workflow using +/// placeholder field values. Used by the UI to visualize an issue type's +/// workflow shape. +#[utoipa::path( + operation_id = "workflow_preview", + get, + path = "/api/v1/issuetypes/{key}/workflow-preview", + tag = "Workflow", + params( + ("key" = String, Path, description = "Issue type key (e.g., FEAT, FIX)") + ), + responses( + (status = 200, description = "Generated preview workflow", body = WorkflowPreviewResponse), + (status = 404, description = "Issue type not found", body = crate::rest::error::ErrorResponse) + ) +)] +pub async fn preview( + State(state): State, + Path(key): Path, +) -> 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) + .map_err(|e| ApiError::InternalError(e.to_string()))?; + + Ok(Json(exported.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use std::path::PathBuf; + + fn make_state() -> ApiState { + ApiState::new( + Config::default(), + PathBuf::from("/tmp/test-tickets-workflow"), + ) + } + + #[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; + assert!(result.is_err(), "unknown ticket should error"); + } + + #[tokio::test] + async fn preview_known_issuetype_returns_workflow() { + let state = make_state(); + // Lower-case key exercises the to_uppercase() normalization. + let result = preview(State(state), Path("feat".to_string())).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_unknown_issuetype_is_not_found() { + let state = make_state(); + let result = preview(State(state), Path("NOPE".to_string())).await; + assert!(result.is_err(), "unknown issue type should error"); + } +} diff --git a/src/rest/server.rs b/src/rest/server.rs index 18ec1de2..f0b503df 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -80,6 +80,9 @@ pub struct RestApiServer { shutdown_tx: Arc>>>, #[allow(dead_code)] task_handle: Arc>>>, + /// Live `ApiState` once `start()` has been called. Exposed so the + /// dashboard can read runtime info (e.g. active MCP SSE sessions). + api_state: Arc>>, } impl RestApiServer { @@ -93,9 +96,17 @@ impl RestApiServer { status: Arc::new(Mutex::new(RestApiStatus::Stopped)), shutdown_tx: Arc::new(Mutex::new(None)), task_handle: Arc::new(Mutex::new(None)), + api_state: Arc::new(Mutex::new(None)), } } + /// Returns a clone of the live `ApiState` if the server has been started. + /// `ApiState` is `Clone` with internal `Arc`s, so the clone shares the + /// same `mcp_sessions` map as the running server. + pub fn api_state(&self) -> Option { + self.api_state.lock().unwrap().clone() + } + /// Get current server status pub fn status(&self) -> RestApiStatus { self.status.lock().unwrap().clone() @@ -131,10 +142,13 @@ impl RestApiServer { *self.shutdown_tx.lock().unwrap() = Some(shutdown_tx); let state = ApiState::new(self.config.clone(), self.config.tickets_path()); + // Expose the live state to the dashboard before handing it to the router. + *self.api_state.lock().unwrap() = Some(state.clone()); let router = build_router(state); let port = self.port; let status = self.status.clone(); let tickets_path = self.tickets_path.clone(); + let api_state_handle = self.api_state.clone(); *status.lock().unwrap() = RestApiStatus::Starting; @@ -168,6 +182,9 @@ impl RestApiServer { } *status.lock().unwrap() = RestApiStatus::Stopped; + // Drop the api_state once the server task exits so the dashboard + // reflects "no server" rather than a stale snapshot. + *api_state_handle.lock().unwrap() = None; }); *self.task_handle.lock().unwrap() = Some(handle); @@ -236,6 +253,25 @@ mod tests { let server = RestApiServer::new(config, 7008); assert_eq!(server.status(), RestApiStatus::Stopped); assert!(!server.is_running()); + assert!( + server.api_state().is_none(), + "api_state should be unset before start()" + ); + } + + #[tokio::test] + async fn test_start_exposes_api_state() { + // Use port 0 to let the OS pick a free port (avoids collisions). + let config = Config::default(); + let server = RestApiServer::new(config, 0); + server.start().expect("start should succeed"); + // Give the spawned task a brief moment to set status to Running and + // populate api_state. (start() already sleeps 50ms internally.) + assert!( + server.api_state().is_some(), + "api_state should be populated after start()" + ); + server.stop(); } #[test] diff --git a/src/rest/state.rs b/src/rest/state.rs index ed7ba21e..5cde266e 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -9,7 +9,7 @@ use tokio::sync::{Mutex, RwLock}; use crate::api::kanban_sync::KanbanBidirectionalSync; use crate::config::Config; use crate::issuetypes::IssueTypeRegistry; -use crate::startup::templates::{ensure_schemas, init_default_templates}; +use crate::startup::templates::load_registry; /// Shared state for the REST API #[derive(Clone)] @@ -36,50 +36,9 @@ impl ApiState { /// 2. If empty, initialize default templates from embedded files /// 3. Fallback to embedded builtins if filesystem loading fails pub fn new(config: Config, tickets_path: PathBuf) -> Self { - let mut registry = IssueTypeRegistry::new(); - let templates_path = tickets_path.join("templates"); - - // Ensure schema files exist (runs every time, even if templates exist) - if let Err(e) = ensure_schemas(&tickets_path) { - tracing::warn!("Failed to ensure schema files: {}", e); - } - - // Try to load from templates directory first - match registry.load_from_templates_dir(&templates_path) { - Ok(()) if registry.type_count() > 0 => { - tracing::info!( - "Loaded {} issue types from templates directory", - registry.type_count() - ); - } - Ok(()) => { - // Templates directory empty or doesn't exist - initialize defaults - tracing::info!("Templates directory empty, initializing defaults..."); - if let Err(e) = init_default_templates(&templates_path) { - tracing::warn!("Failed to initialize default templates: {}", e); - } else { - // Try loading again after initialization - if let Err(e) = registry.load_from_templates_dir(&templates_path) { - tracing::warn!("Failed to load initialized templates: {}", e); - } - } - - // If still empty, fallback to embedded builtins - if registry.type_count() == 0 { - tracing::info!("Falling back to embedded builtin types"); - if let Err(e) = registry.load_builtins() { - tracing::warn!("Failed to load builtin issue types: {}", e); - } - } - } - Err(e) => { - tracing::warn!("Failed to load from templates directory: {}", e); - // Fallback to embedded builtins - if let Err(e) = registry.load_builtins() { - tracing::warn!("Failed to load builtin issue types: {}", e); - } - } - } + // Shared loader — keeps the API's issue-type resolution identical to the + // CLI/TUI so `workflow export` produces the same output on every surface. + let registry = load_registry(&tickets_path); let config_arc = Arc::new(config); let kanban_sync = { diff --git a/src/rest/web_ui.rs b/src/rest/web_ui.rs new file mode 100644 index 00000000..ba32ad30 --- /dev/null +++ b/src/rest/web_ui.rs @@ -0,0 +1,169 @@ +//! Embedded web UI served via rust-embed. +//! +//! Gated behind the `embed-ui` feature flag. + +use axum::http::{header, StatusCode, Uri}; +use axum::response::{Html, IntoResponse, Response}; +use rust_embed::Embed; + +#[derive(Embed)] +#[folder = "ui/dist"] +struct UiAssets; + +/// Sentinel substring written into `ui/dist/index.html` by `build.rs` when the +/// SPA hasn't been built. The runtime detector uses this to distinguish a real +/// Vite build from a placeholder so the TUI can give the user an actionable +/// message instead of opening a blank page. +pub const PLACEHOLDER_MARKER: &str = "operator:placeholder"; + +/// Whether the embedded SPA is usable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmbeddedUiState { + /// A real built SPA is embedded. + Ready, + /// The build.rs placeholder is embedded — `ui/dist` wasn't built before + /// the cargo build. + Placeholder, + /// No SPA assets at all (should be unreachable when this module compiles). + Missing, +} + +/// Inspect the embedded assets and report whether a real SPA is available. +pub fn embedded_ui_state() -> EmbeddedUiState { + let Some(file) = UiAssets::get("index.html") else { + return EmbeddedUiState::Missing; + }; + let body = std::str::from_utf8(&file.data).unwrap_or(""); + if body.contains(PLACEHOLDER_MARKER) { + EmbeddedUiState::Placeholder + } else { + EmbeddedUiState::Ready + } +} + +/// Axum fallback handler that serves embedded SPA assets. +/// +/// Priority: exact file match → index.html (SPA client-side routing). +pub async fn spa_handler(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + + if let Some(file) = UiAssets::get(path) { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime.as_ref().to_string()), + (header::CACHE_CONTROL, cache_policy(path)), + ], + file.data.into_owned(), + ) + .into_response() + } else { + match UiAssets::get("index.html") { + Some(index) => Html(String::from_utf8_lossy(&index.data).to_string()).into_response(), + None => (StatusCode::NOT_FOUND, "UI not built").into_response(), + } + } +} + +fn cache_policy(path: &str) -> String { + // Vite produces hashed filenames like `assets/index-abc123.js` + if path.starts_with("assets/") { + "public, max-age=31536000, immutable".into() + } else { + "no-cache".into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + const TEN_MB: usize = 10_485_760; + const FIFTEEN_MB: usize = 15_728_640; + + #[test] + fn test_embedded_assets_under_10mb_gzipped() { + let mut compressed_total: usize = 0; + let mut uncompressed_total: usize = 0; + + for path in UiAssets::iter() { + if let Some(file) = UiAssets::get(&path) { + uncompressed_total += file.data.len(); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&file.data).expect("gzip write"); + let compressed = encoder.finish().expect("gzip finish"); + compressed_total += compressed.len(); + } + } + + assert!( + compressed_total < TEN_MB, + "Embedded UI assets: {compressed_total}B ({:.1}MB) gzipped — exceeds 10MB budget \ + (uncompressed: {uncompressed_total}B / {:.1}MB)", + compressed_total as f64 / 1_048_576.0, + uncompressed_total as f64 / 1_048_576.0, + ); + } + + #[test] + fn test_embedded_assets_under_15mb_uncompressed() { + let total: usize = UiAssets::iter() + .filter_map(|path| UiAssets::get(&path)) + .map(|file| file.data.len()) + .sum(); + + assert!( + total < FIFTEEN_MB, + "Embedded UI assets: {total}B ({:.1}MB) uncompressed — exceeds 15MB budget", + total as f64 / 1_048_576.0, + ); + } + + #[test] + fn test_index_html_exists() { + assert!( + UiAssets::get("index.html").is_some(), + "ui/dist/index.html must exist in embedded assets" + ); + } + + #[test] + fn test_spa_fallback_returns_index_for_unknown_paths() { + let index_content = UiAssets::get("index.html").expect("index.html must exist"); + let index_str = String::from_utf8_lossy(&index_content.data); + assert!( + index_str.contains("") + || index_str.contains("") + || index_str.contains("`) — a runtime value, so the graph width is symbolic.", + "type": "object", + "properties": { + "step": { + "description": "Name of the prior step whose (array) output is iterated.", + "type": "string" + }, + "type": { + "type": "string", + "const": "from_step" + } + }, + "required": [ + "type", + "step" + ] + }, + { + "description": "A glob pattern, expanded to a literal array at export time against the\nproject root (`projects_path()/`).", + "type": "object", + "properties": { + "pattern": { + "description": "Glob pattern, relative to the project root.", + "type": "string" + }, + "type": { + "type": "string", + "const": "glob" + } + }, + "required": [ + "type", + "pattern" + ] + }, + { + "description": "A literal, author-provided list, emitted verbatim as a literal array.", + "type": "object", + "properties": { + "items": { + "description": "The items to iterate.", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "type": "string", + "const": "static" + } + }, + "required": [ + "type", + "items" + ] + }, + { + "description": "A ticket field value split into a list. Resolution is deferred — there\nis no list `FieldType` and ticket field values are not captured at\nexport time yet — so this currently emits a symbolic placeholder.", + "type": "object", + "properties": { + "name": { + "description": "Name of the ticket field to read.", + "type": "string" + }, + "type": { + "type": "string", + "const": "field" + } + }, + "required": [ + "type", + "name" + ] + } + ] + }, + "PipelineStage": { + "description": "A single stage in a pipeline — deliberately flat (not a recursive\n`StepSchema`): \"prompt + optional agent/model/schema\" only. It has no\n`next_step`/`review_type`/`on_reject`, so a stage cannot reopen the\nstep-graph linearity question.", + "type": "object", + "properties": { + "prompt": { + "description": "Handlebars prompt. The per-item value is appended as a JS binding at\nexport time (see `workflow_gen::export`), not via a Handlebars variable.", + "type": "string" + }, + "agent": { + "description": "Optional agent/delegator name (falls back to the step/issuetype agent).", + "type": [ + "string", + "null" + ], + "default": null + }, + "model": { + "description": "Optional model pin (emitted as `{ model: … }`).", + "type": [ + "string", + "null" + ], + "default": null + }, + "jsonSchema": { + "description": "Optional structured-output JSON schema (emitted as `{ schema: … }`).", + "default": null + }, + "label": { + "description": "Optional display label override (defaults to `:`).", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "prompt" + ] } }, "$id": "https://gbqr.us/operator/issuetype-template.schema.json", diff --git a/src/services/pr_monitor.rs b/src/services/pr_monitor.rs index 27485fb8..23c0cc54 100644 --- a/src/services/pr_monitor.rs +++ b/src/services/pr_monitor.rs @@ -16,7 +16,7 @@ use crate::api::{GitHubService, PrService}; use crate::types::pr::{GitProvider, PrState, RepoInfo}; /// Default poll interval (60 seconds, matching vibe-kanban) -const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(60); +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_mins(1); /// Tracked PR information #[derive(Debug, Clone)] @@ -46,11 +46,11 @@ pub enum PrStatusEvent { }, /// PR was closed without merge Closed { ticket_id: String, pr_number: i64 }, - /// PR received approval - #[allow(dead_code)] // Emitted when review detection is added + /// PR received approval (matched in `pr_workflow`, emitted when review detection is added) + #[allow(dead_code)] Approved { ticket_id: String, pr_number: i64 }, - /// PR had changes requested - #[allow(dead_code)] // Emitted when review detection is added + /// PR had changes requested (matched in `pr_workflow`, emitted when review detection is added) + #[allow(dead_code)] ChangesRequested { ticket_id: String, pr_number: i64 }, /// PR is ready to merge (approved + checks pass) ReadyToMerge { ticket_id: String, pr_number: i64 }, @@ -287,13 +287,13 @@ impl PrMonitorService { } /// Get current count of tracked PRs - #[allow(dead_code)] // Utility method for future use + #[allow(dead_code)] // Used in tests pub async fn tracked_count(&self) -> usize { self.tracked_prs.read().await.len() } /// Check if a specific PR is being tracked - #[allow(dead_code)] // Utility method for future use + #[allow(dead_code)] pub async fn is_tracking(&self, repo_info: &RepoInfo, pr_number: i64) -> bool { let key = Self::pr_key(repo_info, pr_number); self.tracked_prs.read().await.contains_key(&key) diff --git a/src/setup.rs b/src/setup.rs index 3dca7fbe..a3a453d4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -8,7 +8,6 @@ use std::fs; use std::path::PathBuf; use crate::agents::{generate_status_script, generate_tmux_conf}; -use crate::backstage::scaffold::{BackstageScaffold, ScaffoldOptions}; use crate::config::{CollectionPreset, Config}; use crate::templates::TemplateType; @@ -20,8 +19,6 @@ pub const COMMON_OPTIONAL_FIELDS: &[&str] = &["priority", "points", "user_story" pub struct SetupOptions { /// Collection preset to use pub preset: CollectionPreset, - /// Whether to enable backstage - pub backstage_enabled: bool, /// Overwrite existing files pub force: bool, /// Optional fields to include (propagated to all types) @@ -146,25 +143,12 @@ pub fn initialize_workspace(config: &mut Config, options: &SetupOptions) -> Resu config.templates.collection = issue_types; } - // Configure backstage if enabled - config.backstage.enabled = options.backstage_enabled; - // Configure git worktree preference config.git.use_worktrees = options.use_worktrees; // Generate tmux config generate_tmux_config(config)?; - // Generate backstage scaffold if enabled - if options.backstage_enabled { - let backstage_path = config.backstage_path(); - if !BackstageScaffold::exists(&backstage_path) || options.force { - let scaffold_options = ScaffoldOptions::from_config(config); - let scaffold = BackstageScaffold::new(backstage_path, scaffold_options); - scaffold.generate()?; - } - } - // Discover projects (git repos and/or LLM marker files) let discovered = crate::projects::discover_projects_with_git(&config.projects_path()); config.projects = discovered.iter().map(|p| p.name.clone()).collect(); @@ -338,7 +322,6 @@ mod tests { let options = SetupOptions::default(); // CollectionPreset defaults to DevKanban via its #[default] attribute assert_eq!(options.preset, CollectionPreset::DevKanban); - assert!(!options.backstage_enabled); assert!(!options.force); assert!(options.working_dir.is_none()); assert!(options.kanban_provider.is_none()); @@ -460,24 +443,6 @@ mod tests { assert_ne!(content, "existing content"); } - #[test] - fn test_initialize_workspace_with_backstage() { - let temp_dir = TempDir::new().unwrap(); - let tickets_path = temp_dir.path().join(".tickets"); - - let mut config = Config::default(); - config.paths.tickets = tickets_path.to_string_lossy().to_string(); - config.paths.state = tickets_path.join("operator").to_string_lossy().to_string(); - - let options = SetupOptions { - backstage_enabled: true, - ..Default::default() - }; - initialize_workspace(&mut config, &options).unwrap(); - - assert!(config.backstage.enabled); - } - #[test] fn test_initialize_workspace_discovers_projects() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/startup/mod.rs b/src/startup/mod.rs index 406ebc81..18837baf 100644 --- a/src/startup/mod.rs +++ b/src/startup/mod.rs @@ -24,7 +24,7 @@ pub mod templates; /// Information about a setup wizard step for documentation purposes. -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs #[derive(Debug, Clone)] pub struct SetupStepInfo { /// Display name of the step (e.g., "Welcome") @@ -45,7 +45,7 @@ pub struct SetupStepInfo { /// selection, and finally confirmation. /// /// When adding new steps to the setup wizard, add corresponding entries here. -#[allow(dead_code)] // Used by main.rs binary via mod, not via lib crate +#[allow(dead_code)] // Used via binary and docs_gen, not reachable from lib.rs pub static SETUP_STEPS: &[SetupStepInfo] = &[ // ── Tier 0: Config / Welcome ───────────────────────────────────────────── SetupStepInfo { diff --git a/src/startup/templates.rs b/src/startup/templates.rs index 2448e203..b45b1333 100644 --- a/src/startup/templates.rs +++ b/src/startup/templates.rs @@ -11,6 +11,57 @@ use tracing::info; use crate::collections::{ get_embedded_collection, EmbeddedCollection, EMBEDDED_COLLECTIONS, EMBEDDED_SCHEMAS, }; +use crate::issuetypes::IssueTypeRegistry; + +/// Build an `IssueTypeRegistry` for a workspace using the canonical loading +/// priority, so every surface (REST API, CLI, TUI) resolves the same issue +/// types from the same place: +/// +/// 1. Load from `.tickets/templates/` (collection-scoped structure). +/// 2. If empty, initialize default templates from embedded files, then reload. +/// 3. Fall back to embedded builtins if filesystem loading fails or yields none. +pub fn load_registry(tickets_path: &Path) -> IssueTypeRegistry { + let mut registry = IssueTypeRegistry::new(); + let templates_path = tickets_path.join("templates"); + + // Ensure schema files exist (runs every time, even if templates exist). + if let Err(e) = ensure_schemas(tickets_path) { + tracing::warn!("Failed to ensure schema files: {}", e); + } + + match registry.load_from_templates_dir(&templates_path) { + Ok(()) if registry.type_count() > 0 => { + info!( + "Loaded {} issue types from templates directory", + registry.type_count() + ); + } + Ok(()) => { + // Templates directory empty or absent — initialize defaults. + info!("Templates directory empty, initializing defaults..."); + if let Err(e) = init_default_templates(&templates_path) { + tracing::warn!("Failed to initialize default templates: {}", e); + } else if let Err(e) = registry.load_from_templates_dir(&templates_path) { + tracing::warn!("Failed to load initialized templates: {}", e); + } + + if registry.type_count() == 0 { + info!("Falling back to embedded builtin types"); + if let Err(e) = registry.load_builtins() { + tracing::warn!("Failed to load builtin issue types: {}", e); + } + } + } + Err(e) => { + tracing::warn!("Failed to load from templates directory: {}", e); + if let Err(e) = registry.load_builtins() { + tracing::warn!("Failed to load builtin issue types: {}", e); + } + } + } + + registry +} /// Initialize the templates directory with default collections /// @@ -215,16 +266,16 @@ mod tests { assert!(templates_path.join("dev_kanban").exists()); assert!(templates_path.join("devops_kanban").exists()); assert!(templates_path.join("operator").exists()); - assert!(templates_path.join("backstage_full").exists()); - - // backstage_full should have all 8 issuetypes - assert!(templates_path.join("backstage_full/TASK.json").exists()); - assert!(templates_path.join("backstage_full/FEAT.json").exists()); - assert!(templates_path.join("backstage_full/FIX.json").exists()); - assert!(templates_path.join("backstage_full/SPIKE.json").exists()); - assert!(templates_path.join("backstage_full/INV.json").exists()); - assert!(templates_path.join("backstage_full/ASSESS.json").exists()); - assert!(templates_path.join("backstage_full/SYNC.json").exists()); - assert!(templates_path.join("backstage_full/INIT.json").exists()); + assert!(templates_path.join("full").exists()); + + // full should have all 8 issuetypes + assert!(templates_path.join("full/TASK.json").exists()); + assert!(templates_path.join("full/FEAT.json").exists()); + assert!(templates_path.join("full/FIX.json").exists()); + assert!(templates_path.join("full/SPIKE.json").exists()); + assert!(templates_path.join("full/INV.json").exists()); + assert!(templates_path.join("full/ASSESS.json").exists()); + assert!(templates_path.join("full/SYNC.json").exists()); + assert!(templates_path.join("full/INIT.json").exists()); } } diff --git a/src/state.rs b/src/state.rs index fd4dba80..4588c120 100644 --- a/src/state.rs +++ b/src/state.rs @@ -334,6 +334,60 @@ impl State { Ok(id) } + /// Add an agent with a pre-allocated ID and full launch options. + /// + /// Use this when the agent ID must be known before state registration + /// (e.g., for injecting the ID into environment variables at launch time). + #[allow(clippy::too_many_arguments)] // mirrors add_agent_with_full_options + explicit id + pub fn add_agent_with_explicit_id( + &mut self, + id: String, + ticket_id: String, + ticket_type: String, + project: String, + paired: bool, + llm_tool: Option, + launch_mode: Option, + llm_model: Option, + ) -> Result { + let now = Utc::now(); + + self.agents.push(AgentState { + id: id.clone(), + ticket_id, + ticket_type, + project, + status: "running".to_string(), + started_at: now, + last_activity: now, + last_message: None, + paired, + session_name: None, + session_wrapper: None, + session_window_ref: None, + session_context_ref: None, + session_pane_ref: None, + content_hash: None, + current_step: None, + step_started_at: None, + last_content_change: Some(now), + pr_url: None, + pr_number: None, + github_repo: None, + pr_status: None, + completed_steps: Vec::new(), + llm_tool, + llm_model, + launch_mode, + review_state: None, + dev_server_pid: None, + worktree_path: None, + }); + + self.save()?; + Ok(id) + } + pub fn update_agent_status( &mut self, agent_id: &str, @@ -391,10 +445,13 @@ impl State { .collect() } - pub fn is_project_busy(&self, project: &str) -> bool { + pub fn project_agent_count(&self, project: &str) -> usize { self.agents .iter() - .any(|a| a.project == project && a.status == "running") + .filter(|a| { + a.project == project && (a.status == "running" || a.status == "awaiting_input") + }) + .count() } /// Update the terminal session name for an agent @@ -1235,7 +1292,7 @@ mod tests { } #[test] - fn test_is_project_busy_running() { + fn test_project_agent_count_running() { let temp_dir = TempDir::new().unwrap(); let config = test_config(&temp_dir); let mut state = State::load(&config).unwrap(); @@ -1249,13 +1306,12 @@ mod tests { ) .unwrap(); - // Agent starts with status "running" - assert!(state.is_project_busy("test-project")); - assert!(!state.is_project_busy("other-project")); + assert_eq!(state.project_agent_count("test-project"), 1); + assert_eq!(state.project_agent_count("other-project"), 0); } #[test] - fn test_is_project_busy_awaiting_input() { + fn test_project_agent_count_includes_awaiting_input() { let temp_dir = TempDir::new().unwrap(); let config = test_config(&temp_dir); let mut state = State::load(&config).unwrap(); @@ -1273,8 +1329,63 @@ mod tests { .update_agent_status(&id, "awaiting_input", None) .unwrap(); - // is_project_busy only checks for "running" status - assert!(!state.is_project_busy("test-project")); + assert_eq!(state.project_agent_count("test-project"), 1); + } + + #[test] + fn test_project_agent_count_multiple() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + state + .add_agent( + "FEAT-001".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "FEAT-002".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + state + .add_agent( + "FEAT-003".to_string(), + "FEAT".to_string(), + "other-project".to_string(), + false, + ) + .unwrap(); + + assert_eq!(state.project_agent_count("test-project"), 2); + assert_eq!(state.project_agent_count("other-project"), 1); + assert_eq!(state.project_agent_count("nonexistent"), 0); + } + + #[test] + fn test_project_agent_count_excludes_completed() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let id = state + .add_agent( + "FEAT-001".to_string(), + "FEAT".to_string(), + "test-project".to_string(), + false, + ) + .unwrap(); + + state.update_agent_status(&id, "completed", None).unwrap(); + + assert_eq!(state.project_agent_count("test-project"), 0); } // ─── Step Completion Tests ─────────────────────────────────────────────────── @@ -1715,4 +1826,36 @@ mod tests { assert_eq!(group.pending_launches[0].variant_key, "1"); assert_eq!(group.agent_variant_keys.get("agent-A").unwrap(), "0"); } + + #[test] + fn test_add_agent_with_explicit_id_uses_provided_id() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + let mut state = State::load(&config).unwrap(); + + let explicit_id = "my-custom-agent-id-12345".to_string(); + let result = state.add_agent_with_explicit_id( + explicit_id.clone(), + "FEAT-001".to_string(), + "FEAT".to_string(), + "testproject".to_string(), + false, + Some("claude".to_string()), + Some("default".to_string()), + Some("sonnet".to_string()), + ); + + assert!(result.is_ok()); + let returned_id = result.unwrap(); + assert_eq!(returned_id, explicit_id); + + let agent = state.agents.iter().find(|a| a.id == explicit_id); + assert!(agent.is_some()); + let agent = agent.unwrap(); + assert_eq!(agent.ticket_id, "FEAT-001"); + assert_eq!(agent.project, "testproject"); + assert_eq!(agent.llm_tool, Some("claude".to_string())); + assert_eq!(agent.llm_model, Some("sonnet".to_string())); + assert_eq!(agent.status, "running"); + } } diff --git a/src/steps/manager.rs b/src/steps/manager.rs index 579c5ea6..916a453a 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -156,16 +156,17 @@ impl StepManager { self.render_prompt(&step.prompt, ticket, pr_config) } - /// Render a prompt template with ticket data - fn render_prompt( - &self, - template: &str, + /// Build the Handlebars interpolation context for a ticket. + /// + /// This is the single source of truth for the variable surface available + /// to step prompts (`id`, `ticket_type`, `project`, `summary`, `priority`, + /// `step`, optional PR vars, and `steps..*` step outputs). Other + /// renderers (e.g. workflow export) reuse this so prompts interpolate + /// identically to how they would at launch time. + pub fn build_ticket_context( ticket: &Ticket, pr_config: Option<&PrConfig>, - ) -> Result { - let mut hbs = Handlebars::new(); - hbs.set_strict_mode(false); - + ) -> serde_json::Value { let mut data = serde_json::Map::new(); data.insert("id".to_string(), serde_json::json!(ticket.id)); data.insert( @@ -199,7 +200,22 @@ impl StepManager { data.insert("steps".to_string(), serde_json::Value::Object(steps_data)); } - hbs.render_template(template, &serde_json::Value::Object(data)) + serde_json::Value::Object(data) + } + + /// Render a prompt template with ticket data + fn render_prompt( + &self, + template: &str, + ticket: &Ticket, + pr_config: Option<&PrConfig>, + ) -> Result { + let mut hbs = Handlebars::new(); + hbs.set_strict_mode(false); + + let data = Self::build_ticket_context(ticket, pr_config); + + hbs.render_template(template, &data) .context("Failed to render step prompt") } diff --git a/src/steps/session.rs b/src/steps/session.rs index 348450d2..b9394642 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -307,6 +307,7 @@ mod tests { multi_model_config: None, multi_prompt_config: None, matrixed_config: None, + pipeline_config: None, } } diff --git a/src/backstage/analyzer.rs b/src/taxonomy/analyzer.rs similarity index 100% rename from src/backstage/analyzer.rs rename to src/taxonomy/analyzer.rs diff --git a/src/backstage/taxonomy.rs b/src/taxonomy/mod.rs similarity index 92% rename from src/backstage/taxonomy.rs rename to src/taxonomy/mod.rs index d297880d..a6e10771 100644 --- a/src/backstage/taxonomy.rs +++ b/src/taxonomy/mod.rs @@ -6,20 +6,19 @@ //! - Compile-time validation of taxonomy structure //! - Zero runtime I/O for taxonomy access //! -//! Note: Types are marked #[`allow(dead_code)`] during Milestone 1 as they will -//! be used in subsequent milestones (ASSESS issue type, project analysis, etc.) +//! Types are used by the docs generator, project analyzer, and Kind detection. + +pub mod analyzer; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// The complete project taxonomy, loaded from taxonomy.toml -#[allow(dead_code)] static TAXONOMY: std::sync::LazyLock = std::sync::LazyLock::new(|| { toml::from_str(include_str!("taxonomy.toml")).expect("taxonomy.toml must be valid TOML") }); /// Kind tier classification -#[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum KindTier { @@ -30,7 +29,6 @@ pub enum KindTier { Noncurrent, } -#[allow(dead_code)] impl KindTier { /// Returns all tiers in order pub fn all() -> &'static [KindTier] { @@ -81,7 +79,6 @@ impl std::fmt::Display for KindTier { } /// Metadata about the taxonomy -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaxonomyMeta { pub version: String, @@ -89,7 +86,6 @@ pub struct TaxonomyMeta { } /// A tier grouping for Kinds -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tier { pub id: u8, @@ -119,7 +115,6 @@ pub struct Tier { pub assess_testing: bool, } -#[allow(dead_code)] impl Tier { /// Returns the tier enum variant pub fn tier(&self) -> Option { @@ -166,7 +161,6 @@ impl Tier { } /// A project Kind definition -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Kind { pub id: u8, @@ -177,7 +171,7 @@ pub struct Kind { pub stakeholder: String, pub output: String, pub file_patterns: Vec, - pub backstage_type: String, + pub catalog_type: String, /// Icon identifier (optional, falls back to tier icon) #[serde(default)] pub icon: Option, @@ -186,7 +180,6 @@ pub struct Kind { pub display_order: Option, } -#[allow(dead_code)] impl Kind { /// Returns the tier enum variant for this Kind pub fn tier_enum(&self) -> Option { @@ -215,7 +208,6 @@ impl Kind { } /// The complete taxonomy structure -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Taxonomy { pub meta: TaxonomyMeta, @@ -223,7 +215,6 @@ pub struct Taxonomy { pub kinds: Vec, } -#[allow(dead_code)] impl Taxonomy { /// Get the global taxonomy instance (loaded at first access) pub fn load() -> &'static Taxonomy { @@ -294,7 +285,7 @@ impl Taxonomy { .filter_map(|(key, count)| self.kind_by_key(key).map(|k| (k, count))) .collect(); - results.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by count descending + results.sort_by_key(|b| std::cmp::Reverse(b.1)); // Sort by count descending results } @@ -390,7 +381,7 @@ mod tests { } #[test] - fn test_all_kinds_have_backstage_type() { + fn test_all_kinds_have_catalog_type() { let t = Taxonomy::load(); let valid_types = [ "service", @@ -405,10 +396,10 @@ mod tests { ]; for kind in &t.kinds { assert!( - valid_types.contains(&kind.backstage_type.as_str()), - "Kind {} has invalid backstage_type: {}. Valid types: {:?}", + valid_types.contains(&kind.catalog_type.as_str()), + "Kind {} has invalid catalog_type: {}. Valid types: {:?}", kind.key, - kind.backstage_type, + kind.catalog_type, valid_types ); } @@ -433,7 +424,6 @@ mod tests { #[test] fn test_kinds_by_tier() { let t = Taxonomy::load(); - // Verify each tier returns kinds and they all reference the correct tier for tier_enum in KindTier::all() { let kinds = t.kinds_by_tier(*tier_enum); assert!(!kinds.is_empty(), "{tier_enum} tier should have kinds"); @@ -467,7 +457,6 @@ mod tests { detected.is_some(), "Should detect a kind for Rust service files" ); - // microservice should match src/main.rs and Dockerfile let (kind, _) = detected.unwrap(); assert_eq!(kind.key, "microservice"); } @@ -497,7 +486,6 @@ mod tests { fn test_tier_display_order_fallback() { let t = Taxonomy::load(); for tier in &t.tiers { - // display_order should fall back to id if not set let order = tier.display_order(); assert!( order >= 1, @@ -512,7 +500,6 @@ mod tests { fn test_tier_sidebar_label_fallback() { let t = Taxonomy::load(); for tier in &t.tiers { - // sidebar_label should fall back to name if not set let label = tier.sidebar_label(); assert!( !label.is_empty(), @@ -527,7 +514,6 @@ mod tests { let t = Taxonomy::load(); let ordered = t.tiers_by_display_order(); assert!(!ordered.is_empty(), "Should have tiers"); - // Verify tiers are sorted by display_order for i in 1..ordered.len() { assert!( ordered[i - 1].display_order() <= ordered[i].display_order(), @@ -539,10 +525,8 @@ mod tests { #[test] fn test_kinds_by_tier_ordered() { let t = Taxonomy::load(); - // Test ordering for each tier for tier_enum in KindTier::all() { let kinds = t.kinds_by_tier_ordered(*tier_enum); - // Kinds should be sorted by display_order (falls back to id) for i in 1..kinds.len() { assert!( kinds[i - 1].display_order() <= kinds[i].display_order(), @@ -556,7 +540,6 @@ mod tests { fn test_kind_display_order_fallback() { let t = Taxonomy::load(); for kind in &t.kinds { - // display_order should fall back to id if not set let order = kind.display_order(); assert!( order >= 1, @@ -571,31 +554,26 @@ mod tests { fn test_tier_assessment_scope_flags() { let t = Taxonomy::load(); - // Foundation: no assessments let foundation = t.tier_def(KindTier::Foundation).unwrap(); assert!(!foundation.should_assess_frameworks()); assert!(!foundation.should_assess_databases()); assert!(!foundation.should_assess_testing()); - // Standards: frameworks and testing only let standards = t.tier_def(KindTier::Standards).unwrap(); assert!(standards.should_assess_frameworks()); assert!(!standards.should_assess_databases()); assert!(standards.should_assess_testing()); - // Engines: all assessments let engines = t.tier_def(KindTier::Engines).unwrap(); assert!(engines.should_assess_frameworks()); assert!(engines.should_assess_databases()); assert!(engines.should_assess_testing()); - // Ecosystem: all assessments let ecosystem = t.tier_def(KindTier::Ecosystem).unwrap(); assert!(ecosystem.should_assess_frameworks()); assert!(ecosystem.should_assess_databases()); assert!(ecosystem.should_assess_testing()); - // Noncurrent: no assessments let noncurrent = t.tier_def(KindTier::Noncurrent).unwrap(); assert!(!noncurrent.should_assess_frameworks()); assert!(!noncurrent.should_assess_databases()); @@ -622,7 +600,7 @@ mod tests { let kind = kind.unwrap(); assert_eq!(kind.tier, "noncurrent"); - assert_eq!(kind.backstage_type, "resource"); + assert_eq!(kind.catalog_type, "resource"); assert!(kind.matches_pattern("fixtures/users.json")); assert!(kind.matches_pattern("testdata/sample.csv")); assert!(kind.matches_pattern("db/seeds/users.sql")); diff --git a/src/backstage/taxonomy.toml b/src/taxonomy/taxonomy.toml similarity index 94% rename from src/backstage/taxonomy.toml rename to src/taxonomy/taxonomy.toml index f35b1ef3..0fdace85 100644 --- a/src/backstage/taxonomy.toml +++ b/src/taxonomy/taxonomy.toml @@ -3,7 +3,7 @@ # This file defines the 24-Kind project taxonomy used by: # - Rust code (parsed at compile time) # - Documentation generation -# - Backstage catalog schema extension +# - project classification schema extension # - Project assessment and Kind detection # # To add a new Kind: @@ -14,7 +14,7 @@ [meta] version = "1.0.0" -description = "Operator project taxonomy for Backstage catalog" +description = "Operator project taxonomy for project classification" # Tiers organize Kinds into logical groups # Each tier has an ID range for its Kinds @@ -114,7 +114,7 @@ file_patterns = [ "serverless.yml", "terraform.tfstate", ] -backstage_type = "resource" +catalog_type = "resource" [[kinds]] id = 2 @@ -135,7 +135,7 @@ file_patterns = [ ".vault/*", "vault-*.hcl", ] -backstage_type = "resource" +catalog_type = "resource" [[kinds]] id = 3 @@ -156,7 +156,7 @@ file_patterns = [ "feature-flags.json", "launchdarkly*.yaml", ] -backstage_type = "resource" +catalog_type = "resource" [[kinds]] id = 4 @@ -177,7 +177,7 @@ file_patterns = [ "CLAUDE.md", "CONTRIBUTING.md", ] -backstage_type = "system" +catalog_type = "system" # ============================================================================= # STANDARDS TIER (5-10) @@ -202,7 +202,7 @@ file_patterns = [ "figma-tokens.json", "style-dictionary.config.*", ] -backstage_type = "library" +catalog_type = "library" [[kinds]] id = 6 @@ -222,7 +222,7 @@ file_patterns = [ "setup.py", "pyproject.toml", ] -backstage_type = "library" +catalog_type = "library" [[kinds]] id = 7 @@ -246,7 +246,7 @@ file_patterns = [ "buf.yaml", "buf.gen.yaml", ] -backstage_type = "api" +catalog_type = "api" [[kinds]] id = 8 @@ -266,7 +266,7 @@ file_patterns = [ "blueprint/*", ".scaffold/*", ] -backstage_type = "template" +catalog_type = "template" [[kinds]] id = 9 @@ -288,7 +288,7 @@ file_patterns = [ "bandit.yaml", "safety/*", ] -backstage_type = "tool" +catalog_type = "tool" [[kinds]] id = 10 @@ -309,7 +309,7 @@ file_patterns = [ "attestations/*", "controls/*.yaml", ] -backstage_type = "documentation" +catalog_type = "documentation" # ============================================================================= # ENGINES TIER (11-16) @@ -338,7 +338,7 @@ file_patterns = [ "dvc.yaml", "dvc.lock", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 12 @@ -359,7 +359,7 @@ file_patterns = [ "dagster.yaml", "fivetran/*", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 13 @@ -381,7 +381,7 @@ file_patterns = [ "Dockerfile", "docker-compose.yml", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 14 @@ -403,7 +403,7 @@ file_patterns = [ "routes/*", "api/*.yaml", ] -backstage_type = "api" +catalog_type = "api" [[kinds]] id = 15 @@ -426,7 +426,7 @@ file_patterns = [ "angular.json", "expo/*", ] -backstage_type = "website" +catalog_type = "website" [[kinds]] id = 16 @@ -444,7 +444,7 @@ file_patterns = [ "retool/*", "metabase/*", ] -backstage_type = "service" +catalog_type = "service" # ============================================================================= # ECOSYSTEM TIER (17-21) @@ -471,7 +471,7 @@ file_patterns = [ "Makefile", "Taskfile.yml", ] -backstage_type = "tool" +catalog_type = "tool" [[kinds]] id = 18 @@ -493,7 +493,7 @@ file_patterns = [ "k6/*", "locust/*", ] -backstage_type = "tool" +catalog_type = "tool" [[kinds]] id = 19 @@ -515,7 +515,7 @@ file_patterns = [ "conf.py", # Sphinx "book.toml", # mdBook ] -backstage_type = "website" +catalog_type = "website" [[kinds]] id = 20 @@ -534,7 +534,7 @@ file_patterns = [ "sops/*", "procedures/*", ] -backstage_type = "documentation" +catalog_type = "documentation" [[kinds]] id = 21 @@ -553,7 +553,7 @@ file_patterns = [ "*.sh", "Justfile", ] -backstage_type = "tool" +catalog_type = "tool" # ============================================================================= # NONCURRENT TIER (22-25) @@ -576,7 +576,7 @@ file_patterns = [ "quickstart/*", "getting-started/*", ] -backstage_type = "documentation" +catalog_type = "documentation" [[kinds]] id = 23 @@ -595,7 +595,7 @@ file_patterns = [ "scratch/*", "playground/*", ] -backstage_type = "service" +catalog_type = "service" [[kinds]] id = 24 @@ -614,7 +614,7 @@ file_patterns = [ "legacy/*", "deprecated/*", ] -backstage_type = "library" +catalog_type = "library" [[kinds]] id = 25 @@ -638,4 +638,4 @@ file_patterns = [ "*.seed.sql", "db/seeds/*", ] -backstage_type = "resource" +catalog_type = "resource" diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 13b0a022..a85cd25a 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -130,7 +130,7 @@ impl TemplateType { TemplateType::Investigation => "Investigation", TemplateType::Assess => "Project Assessment", TemplateType::Sync => "Catalog Sync", - TemplateType::Init => "Backstage Init", + TemplateType::Init => "Workspace Init", } } @@ -143,38 +143,38 @@ impl TemplateType { TemplateType::Spike => "Research or exploration (paired mode)", TemplateType::Investigation => "Incident investigation (paired mode)", TemplateType::Assess => "Analyze project and generate catalog-info.yaml", - TemplateType::Sync => "Refresh Backstage catalog entries", - TemplateType::Init => "Initialize Backstage in workspace (paired mode)", + TemplateType::Sync => "Refresh catalog entries", + TemplateType::Init => "Initialize workspace (paired mode)", } } /// Returns the embedded markdown template content - /// Source of truth: `src/collections/backstage_full`/ + /// Source of truth: `src/collections/full/` pub fn template_content(&self) -> &'static str { match self { - TemplateType::Feature => include_str!("../collections/backstage_full/FEAT.md"), - TemplateType::Fix => include_str!("../collections/backstage_full/FIX.md"), - TemplateType::Task => include_str!("../collections/backstage_full/TASK.md"), - TemplateType::Spike => include_str!("../collections/backstage_full/SPIKE.md"), - TemplateType::Investigation => include_str!("../collections/backstage_full/INV.md"), - TemplateType::Assess => include_str!("../collections/backstage_full/ASSESS.md"), - TemplateType::Sync => include_str!("../collections/backstage_full/SYNC.md"), - TemplateType::Init => include_str!("../collections/backstage_full/INIT.md"), + TemplateType::Feature => include_str!("../collections/full/FEAT.md"), + TemplateType::Fix => include_str!("../collections/full/FIX.md"), + TemplateType::Task => include_str!("../collections/full/TASK.md"), + TemplateType::Spike => include_str!("../collections/full/SPIKE.md"), + TemplateType::Investigation => include_str!("../collections/full/INV.md"), + TemplateType::Assess => include_str!("../collections/full/ASSESS.md"), + TemplateType::Sync => include_str!("../collections/full/SYNC.md"), + TemplateType::Init => include_str!("../collections/full/INIT.md"), } } /// Returns the embedded JSON schema content - /// Source of truth: `src/collections/backstage_full`/ + /// Source of truth: `src/collections/full/` pub fn schema(&self) -> &'static str { match self { - TemplateType::Feature => include_str!("../collections/backstage_full/FEAT.json"), - TemplateType::Fix => include_str!("../collections/backstage_full/FIX.json"), - TemplateType::Task => include_str!("../collections/backstage_full/TASK.json"), - TemplateType::Spike => include_str!("../collections/backstage_full/SPIKE.json"), - TemplateType::Investigation => include_str!("../collections/backstage_full/INV.json"), - TemplateType::Assess => include_str!("../collections/backstage_full/ASSESS.json"), - TemplateType::Sync => include_str!("../collections/backstage_full/SYNC.json"), - TemplateType::Init => include_str!("../collections/backstage_full/INIT.json"), + TemplateType::Feature => include_str!("../collections/full/FEAT.json"), + TemplateType::Fix => include_str!("../collections/full/FIX.json"), + TemplateType::Task => include_str!("../collections/full/TASK.json"), + TemplateType::Spike => include_str!("../collections/full/SPIKE.json"), + TemplateType::Investigation => include_str!("../collections/full/INV.json"), + TemplateType::Assess => include_str!("../collections/full/ASSESS.json"), + TemplateType::Sync => include_str!("../collections/full/SYNC.json"), + TemplateType::Init => include_str!("../collections/full/INIT.json"), } } diff --git a/src/templates/schema.rs b/src/templates/schema.rs index 28334d7c..632fbef0 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -200,6 +200,9 @@ pub struct StepSchema { /// Configuration for matrixed steps (required when type=matrixed) #[serde(default)] pub matrixed_config: Option, + /// Configuration for pipeline steps (required when type=pipeline) + #[serde(default)] + pub pipeline_config: Option, } /// Status category for a step @@ -306,6 +309,8 @@ pub enum StepTypeTag { MultiPrompt, /// N x M delegators x prompt variations Matrixed, + /// Iterate a list of items through ordered stages with no barrier + Pipeline, } fn default_step_type() -> StepTypeTag { @@ -532,6 +537,81 @@ pub enum MatrixedOutputFormat { Structured, } +// ── Pipeline ──────────────────────────────────────────────────────────── + +/// Configuration for pipeline steps: iterate a list of items through ordered +/// stages with no barrier (each item flows through all stages independently). +/// +/// The step graph stays linear — a pipeline step still has exactly one +/// `next_step`. The fan-out (N items x M stages) lives entirely inside this one +/// step; iteration is an intra-step concern, never a step-to-step edge. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PipelineConfig { + /// Where the iterated items come from. + pub item_source: ItemSource, + /// Ordered mini-steps each item flows through. Must be non-empty. + pub stages: Vec, +} + +/// A single stage in a pipeline — deliberately flat (not a recursive +/// `StepSchema`): "prompt + optional agent/model/schema" only. It has no +/// `next_step`/`review_type`/`on_reject`, so a stage cannot reopen the +/// step-graph linearity question. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PipelineStage { + /// Handlebars prompt. The per-item value is appended as a JS binding at + /// export time (see `workflow_gen::export`), not via a Handlebars variable. + pub prompt: String, + /// Optional agent/delegator name (falls back to the step/issuetype agent). + #[serde(default)] + pub agent: Option, + /// Optional model pin (emitted as `{ model: … }`). + #[serde(default)] + pub model: Option, + /// Optional structured-output JSON schema (emitted as `{ schema: … }`). + #[serde(default, rename = "jsonSchema")] + pub json_schema: Option, + /// Optional display label override (defaults to `:`). + #[serde(default)] + pub label: Option, +} + +/// Where a pipeline's iterated items come from. The variant determines *when* +/// the list resolves: export-time (a literal array → static fan-out width in +/// the compiled graph) vs runtime (an identifier → symbolic width). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ItemSource { + /// The configured/relevant projects (`config.discover_projects()`), + /// resolved to a literal array at export time. The "plan work across many + /// projects" mechanism. + Projects, + /// An array produced by a prior step. Emits that step's result identifier + /// (`r_`) — a runtime value, so the graph width is symbolic. + FromStep { + /// Name of the prior step whose (array) output is iterated. + step: String, + }, + /// A glob pattern, expanded to a literal array at export time against the + /// project root (`projects_path()/`). + Glob { + /// Glob pattern, relative to the project root. + pattern: String, + }, + /// A literal, author-provided list, emitted verbatim as a literal array. + Static { + /// The items to iterate. + items: Vec, + }, + /// A ticket field value split into a list. Resolution is deferred — there + /// is no list `FieldType` and ticket field values are not captured at + /// export time yet — so this currently emits a symbolic placeholder. + Field { + /// Name of the ticket field to read. + name: String, + }, +} + impl TemplateSchema { /// Parse a template schema from JSON pub fn from_json(json: &str) -> Result { @@ -587,6 +667,20 @@ impl TemplateSchema { } } + // A pipeline from_step source must name an existing step: the + // export emits its result identifier (`r_`), which would be + // undefined at runtime otherwise. + if let Some(ref cfg) = step.pipeline_config { + if let ItemSource::FromStep { step: source } = &cfg.item_source { + if !source.is_empty() && !step_names.contains(&source.as_str()) { + errors.push(format!( + "Step '{}': pipeline item_source 'from_step' references unknown step '{}'", + step.name, source + )); + } + } + } + // Validate step type config presence and constraints step.validate_type_config(&mut errors); } @@ -761,6 +855,48 @@ impl StepSchema { )); } } + StepTypeTag::Pipeline => { + if let Some(ref cfg) = self.pipeline_config { + if cfg.stages.is_empty() { + errors.push(format!( + "Step '{}': pipeline_config.stages must be non-empty", + self.name + )); + } + match &cfg.item_source { + ItemSource::Static { items } if items.is_empty() => { + errors.push(format!( + "Step '{}': pipeline item_source 'static' must have a non-empty items list", + self.name + )); + } + ItemSource::Glob { pattern } if pattern.is_empty() => { + errors.push(format!( + "Step '{}': pipeline item_source 'glob' must have a non-empty pattern", + self.name + )); + } + ItemSource::FromStep { step } if step.is_empty() => { + errors.push(format!( + "Step '{}': pipeline item_source 'from_step' must name a step", + self.name + )); + } + ItemSource::Field { name } if name.is_empty() => { + errors.push(format!( + "Step '{}': pipeline item_source 'field' must name a field", + self.name + )); + } + _ => {} + } + } else { + errors.push(format!( + "Step '{}': type 'pipeline' requires pipeline_config", + self.name + )); + } + } } } } @@ -1837,4 +1973,147 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err()[0].contains("at least one required or optional tool")); } + + // ── Pipeline step validation ──────────────────────────────────── + + /// Wrap a single pipeline step's JSON into a full template and validate it. + fn validate_pipeline_step(step_json: &str) -> Result<(), Vec> { + let json = format!( + r#"{{ + "key": "PIPE", + "name": "Pipe", + "description": "Pipeline template", + "mode": "autonomous", + "glyph": "*", + "fields": [ + {{ "name": "id", "description": "ID", "type": "string", "required": true, "auto": "id" }} + ], + "steps": [ {step_json} ] + }}"# + ); + TemplateSchema::from_json(&json).unwrap().validate() + } + + #[test] + fn test_pipeline_valid_static_config_passes() { + let result = validate_pipeline_step( + r#"{ + "name": "triage", + "type": "pipeline", + "outputs": ["report"], + "prompt": "", + "pipeline_config": { + "item_source": { "type": "static", "items": ["a", "b"] }, + "stages": [ { "prompt": "Look at {{item}}" } ] + } + }"#, + ); + assert!(result.is_ok(), "expected valid pipeline, got {result:?}"); + } + + #[test] + fn test_pipeline_missing_config_errors() { + let result = validate_pipeline_step( + r#"{ + "name": "triage", + "type": "pipeline", + "outputs": ["report"], + "prompt": "" + }"#, + ); + let errs = result.unwrap_err(); + assert!( + errs.iter().any(|e| e.contains("requires pipeline_config")), + "got {errs:?}" + ); + } + + #[test] + fn test_pipeline_empty_stages_errors() { + let result = validate_pipeline_step( + r#"{ + "name": "triage", + "type": "pipeline", + "outputs": ["report"], + "prompt": "", + "pipeline_config": { + "item_source": { "type": "projects" }, + "stages": [] + } + }"#, + ); + let errs = result.unwrap_err(); + assert!( + errs.iter().any(|e| e.contains("stages must be non-empty")), + "got {errs:?}" + ); + } + + #[test] + fn test_pipeline_static_empty_items_errors() { + let result = validate_pipeline_step( + r#"{ + "name": "triage", + "type": "pipeline", + "outputs": ["report"], + "prompt": "", + "pipeline_config": { + "item_source": { "type": "static", "items": [] }, + "stages": [ { "prompt": "x" } ] + } + }"#, + ); + let errs = result.unwrap_err(); + assert!( + errs.iter() + .any(|e| e.contains("static") && e.contains("non-empty")), + "got {errs:?}" + ); + } + + #[test] + fn test_pipeline_from_step_unknown_step_errors() { + // Cross-referenced alongside next_step/on_reject target checks: the + // emitted r_ identifier would be undefined at runtime otherwise. + let result = validate_pipeline_step( + r#"{ + "name": "fix", + "type": "pipeline", + "outputs": ["code"], + "prompt": "", + "pipeline_config": { + "item_source": { "type": "from_step", "step": "nonexistent" }, + "stages": [ { "prompt": "x" } ] + } + }"#, + ); + let errs = result.unwrap_err(); + assert!( + errs.iter() + .any(|e| e.contains("from_step") && e.contains("nonexistent")), + "got {errs:?}" + ); + } + + #[test] + fn test_pipeline_glob_empty_pattern_errors() { + let result = validate_pipeline_step( + r#"{ + "name": "triage", + "type": "pipeline", + "outputs": ["report"], + "prompt": "", + "pipeline_config": { + "item_source": { "type": "glob", "pattern": "" }, + "stages": [ { "prompt": "x" } ] + } + }"#, + ); + let errs = result.unwrap_err(); + assert!( + errs.iter() + .any(|e| e.contains("glob") && e.contains("pattern")), + "got {errs:?}" + ); + } } diff --git a/src/templates/step_type.rs b/src/templates/step_type.rs index df382d58..8732d8f1 100644 --- a/src/templates/step_type.rs +++ b/src/templates/step_type.rs @@ -81,6 +81,10 @@ pub fn prompt_augmentation(step: &StepSchema) -> String { // Multi-agent types handled in Phase 4 StepTypeTag::MultiModel | StepTypeTag::MultiPrompt | StepTypeTag::Matrixed => String::new(), + + // Pipeline per-item/per-stage prompts are augmented at export time + // (the item/prev bindings are JS-level, not Handlebars vars). + StepTypeTag::Pipeline => String::new(), } } @@ -494,6 +498,7 @@ mod tests { multi_model_config: None, multi_prompt_config: None, matrixed_config: None, + pipeline_config: None, } } diff --git a/src/types/llm_stats.rs b/src/types/llm_stats.rs index 4dbd6043..b76c6692 100644 --- a/src/types/llm_stats.rs +++ b/src/types/llm_stats.rs @@ -198,12 +198,8 @@ impl ProjectLlmStats { /// Get average time per ticket (in seconds) pub fn avg_time_per_ticket(&self) -> Option { let total_tickets = self.total_tickets(); - if total_tickets > 0 { - let total_time: u64 = self.tool_usage.values().map(|u| u.total_time_secs).sum(); - Some(total_time / total_tickets) - } else { - None - } + let total_time: u64 = self.tool_usage.values().map(|u| u.total_time_secs).sum(); + total_time.checked_div(total_tickets) } } diff --git a/src/types/project.rs b/src/types/project.rs index 23b62a1a..71a02445 100644 --- a/src/types/project.rs +++ b/src/types/project.rs @@ -48,7 +48,7 @@ pub struct Project { #[ts(type = "string | null")] pub ai_context_path: Option, - /// Backstage taxonomy kind (tier 1-5) + /// Project taxonomy kind (tier 1-5) #[serde(default)] pub kind: Option, diff --git a/src/ui/create_dialog.rs b/src/ui/create_dialog.rs index 2df22621..389ea21e 100644 --- a/src/ui/create_dialog.rs +++ b/src/ui/create_dialog.rs @@ -221,23 +221,19 @@ impl CreateDialog { let requires_project = !self.selected_template.is_none_or(|t| t.project_optional()); match key { - KeyCode::Up | KeyCode::Char('k') => { - if !list.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i > 0 => i - 1, - _ => list.len().saturating_sub(1), - }; - self.project_state.select(Some(i)); - } + KeyCode::Up | KeyCode::Char('k') if !list.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i > 0 => i - 1, + _ => list.len().saturating_sub(1), + }; + self.project_state.select(Some(i)); } - KeyCode::Down | KeyCode::Char('j') => { - if !list.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i < list.len() - 1 => i + 1, - _ => 0, - }; - self.project_state.select(Some(i)); - } + KeyCode::Down | KeyCode::Char('j') if !list.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i < list.len() - 1 => i + 1, + _ => 0, + }; + self.project_state.select(Some(i)); } KeyCode::Enter => { // Block proceeding if projects are required but none found diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index 5646e0de..ad803683 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -10,12 +10,8 @@ use ratatui::{ use super::in_progress_panel::InProgressPanel; use super::panels::{CompletedPanel, HeaderBar, QueuePanel, StatusBar}; -use super::status_panel::{ - DelegatorInfo, KanbanProviderInfo, LlmToolInfo, StatusPanel, StatusSnapshot, - WrapperConnectionStatus, -}; -use crate::backstage::ServerStatus; -use crate::config::{Config, GitProviderConfig, SessionWrapperType}; +use super::status_panel::{IssueTypeInfo, StatusPanel, StatusSnapshot, WrapperConnectionStatus}; +use crate::config::{Config, SessionWrapperType}; use crate::editors::EditorConfig; use crate::queue::Ticket; use crate::rest::RestApiStatus; @@ -37,8 +33,6 @@ pub struct Dashboard { pub focused: FocusedPanel, pub paused: bool, pub max_agents: usize, - /// Backstage server status - pub backstage_status: ServerStatus, /// REST API server status pub rest_api_status: RestApiStatus, /// Wrapper display name for header bar @@ -55,8 +49,16 @@ pub struct Dashboard { pub wrapper_connection_status: WrapperConnectionStatus, /// Config snapshot for status panel config: Config, + /// Active issue types, loaded once from the registry (refreshed on config reload). + /// Cached because loading the registry touches the filesystem. + issue_types: Vec, /// Resolved editor environment variables pub editor_config: EditorConfig, + /// Active MCP SSE sessions, updated by the app each tick via `update_mcp_active_sessions`. + pub mcp_active_sessions: usize, + /// ACP agent advertisement + active-session count. Updated by `App` on + /// construction; refreshed by `update_acp_status` if it changes later. + pub acp_status: crate::acp::AcpAgentStatus, } impl Dashboard { @@ -70,7 +72,6 @@ impl Dashboard { paused: false, max_agents: config.effective_max_agents(), wrapper_name: config.sessions.wrapper.display_name(), - backstage_status: ServerStatus::Stopped, rest_api_status: RestApiStatus::Stopped, exit_confirmation_mode: false, update_available_version: None, @@ -78,7 +79,10 @@ impl Dashboard { status_message_at: None, wrapper_connection_status: Self::initial_wrapper_status(config), config: config.clone(), + issue_types: Self::load_issue_types(config), editor_config: EditorConfig::detect(config.sessions.wrapper), + mcp_active_sessions: 0, + acp_status: crate::acp::AcpAgentServer::from_config(config).status(), }; dashboard.compute_initial_focus(); dashboard @@ -103,14 +107,16 @@ impl Dashboard { } } - pub fn update_backstage_status(&mut self, status: ServerStatus) { - self.backstage_status = status; - } - pub fn update_rest_api_status(&mut self, status: RestApiStatus) { self.rest_api_status = status; } + /// Update the active MCP SSE session count. Called each tick by the app + /// from `rest_api_server.api_state().map(|s| s.mcp_sessions.try_lock()...)`. + pub fn update_mcp_active_sessions(&mut self, count: usize) { + self.mcp_active_sessions = count; + } + pub fn update_exit_confirmation_mode(&mut self, mode: bool) { self.exit_confirmation_mode = mode; } @@ -137,6 +143,28 @@ impl Dashboard { pub fn update_config(&mut self, config: &Config) { self.config = config.clone(); + self.issue_types = Self::load_issue_types(config); + } + + /// Load active issue types from the registry. Touches the filesystem, so the + /// result is cached on the `Dashboard` rather than recomputed each render. + fn load_issue_types(config: &Config) -> Vec { + let mut registry = crate::issuetypes::IssueTypeRegistry::new(); + // `load_all` always loads builtins first, so the list is non-empty even + // when no user types or templates are present. + let _ = registry.load_all(Path::new(&config.paths.tickets)); + registry + .all_types() + .map(|it| IssueTypeInfo { + key: it.key.clone(), + name: it.name.clone(), + mode: if it.is_autonomous() { + "autonomous".to_string() + } else { + "paired".to_string() + }, + }) + .collect() } pub fn expand_and_focus_section(&mut self, section_id: super::status_panel::SectionId) { @@ -200,120 +228,34 @@ impl Dashboard { self.wrapper_connection_status = status; } - /// Build a status snapshot from current config and runtime state + /// Build a status snapshot from current config and runtime state. + /// + /// The config-derived fields come from [`StatusSnapshot::from_config`] (shared + /// with the REST `/api/v1/sections` endpoint); this method then overrides the + /// live runtime fields the TUI tracks (API/wrapper/mcp/acp liveness, editor env). fn build_status_snapshot(&self) -> StatusSnapshot { - let config = &self.config; - - // Working directory is where the operator process runs from - let working_dir = std::env::current_dir() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(); - let config_path = Config::operator_config_path() - .to_string_lossy() - .into_owned(); - let tickets_dir = config.paths.tickets.clone(); - let tickets_dir_exists = Path::new(&tickets_dir).exists(); - - // Build kanban provider info from jira + linear configs - let mut kanban_providers: Vec = Vec::new(); - for domain in config.kanban.jira.keys() { - kanban_providers.push(KanbanProviderInfo { - provider_type: "jira".to_string(), - domain: domain.clone(), - }); - } - for slug in config.kanban.linear.keys() { - kanban_providers.push(KanbanProviderInfo { - provider_type: "linear".to_string(), - domain: slug.clone(), - }); - } - - // Build LLM tool info from detected tools - let llm_tools: Vec = config - .llm_tools - .detected - .iter() - .map(|t| LlmToolInfo { - name: t.name.clone(), - version: t.version.clone(), - model_aliases: t.model_aliases.clone(), - }) - .collect(); - - // Build delegator info - let delegators: Vec = config - .delegators - .iter() - .map(|d| DelegatorInfo { - name: d.name.clone(), - display_name: d.display_name.clone(), - llm_tool: d.llm_tool.clone(), - model: d.model.clone(), - yolo: d.launch_config.as_ref().is_some_and(|lc| lc.yolo), - model_server: d.model_server.clone(), - }) - .collect(); - - // Build model server info — implicit builtins plus any user-declared. - let mut model_servers: Vec = config - .model_servers - .iter() - .map(|s| crate::ui::status_panel::ModelServerInfo { - name: s.name.clone(), - kind: s.kind.clone(), - base_url: s.base_url.clone(), - display_name: s.display_name.clone(), - user_declared: true, - }) - .collect(); - for tool in ["claude", "codex", "gemini"] { - let implicit = crate::config::implicit_model_server_for_tool(tool); - if !model_servers.iter().any(|s| s.name == implicit.name) { - model_servers.push(crate::ui::status_panel::ModelServerInfo { - name: implicit.name, - kind: implicit.kind, - base_url: implicit.base_url, - display_name: implicit.display_name, - user_declared: false, - }); + let mut snapshot = StatusSnapshot::from_config(&self.config, self.issue_types.clone()); + + snapshot.api_status = self.rest_api_status.clone(); + snapshot.update_available_version = self.update_available_version.clone(); + snapshot.wrapper_connection_status = self.wrapper_connection_status.clone(); + snapshot.env_editor = self.editor_config.editor.clone(); + 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 } => { + crate::ui::status_panel::McpHttpStatus::Mounted { port: *port } + } + _ => crate::ui::status_panel::McpHttpStatus::NotMounted, } - } - - // Git config - let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); - let git_token_set = match config.git.provider { - Some(GitProviderConfig::GitLab) => std::env::var(&config.git.gitlab.token_env).is_ok(), - // GitHub is the default for all other providers (including None) - _ => std::env::var(&config.git.github.token_env).is_ok(), + } else { + crate::ui::status_panel::McpHttpStatus::NotMounted }; + snapshot.mcp_active_sessions = self.mcp_active_sessions; + snapshot.acp_stdio_advertised = self.acp_status.is_advertised(); + snapshot.acp_active_sessions = self.acp_status.active_sessions(); - StatusSnapshot { - working_dir, - config_file_found: true, // We have a config if we're running - config_path, - tickets_dir, - tickets_dir_exists, - wrapper_type: config.sessions.wrapper.display_name().to_string(), - operator_version: env!("CARGO_PKG_VERSION").to_string(), - api_status: self.rest_api_status.clone(), - backstage_status: self.backstage_status.clone(), - backstage_display: config.backstage.display, - kanban_providers, - llm_tools, - default_llm_tool: config.llm_tools.default_tool.clone(), - default_llm_model: config.llm_tools.default_model.clone(), - delegators, - model_servers, - git_provider, - git_token_set, - git_branch_format: Some(config.git.branch_format.clone()), - git_use_worktrees: config.git.use_worktrees, - update_available_version: self.update_available_version.clone(), - wrapper_connection_status: self.wrapper_connection_status.clone(), - env_editor: self.editor_config.editor.clone(), - env_visual: self.editor_config.visual.clone(), - } + snapshot } pub fn render(&mut self, frame: &mut Frame) { @@ -377,16 +319,23 @@ impl Dashboard { self.focused == FocusedPanel::Completed, ); - // Status bar + // Status bar — show dynamic hints when status panel is focused + let row_hints = if self.focused == FocusedPanel::Status { + let snapshot = self.build_status_snapshot(); + self.status_panel.current_row_hints(&snapshot) + } else { + None + }; let status = StatusBar { paused: self.paused, agent_count: self.in_progress_panel.agents.len(), max_agents: self.max_agents, - backstage_status: self.backstage_status.clone(), rest_api_status: self.rest_api_status.clone(), + embed_ui_available: cfg!(feature = "embed-ui"), exit_confirmation_mode: self.exit_confirmation_mode, update_available_version: self.update_available_version.clone(), status_message: self.status_message.clone(), + row_hints, }; status.render(frame, chunks[2]); } diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index ea3e5133..d1cb0880 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -80,7 +80,8 @@ impl InProgressPanel { // Tool indicator (A=Anthropic/Claude, G=Gemini, O=OpenAI/Codex) let tool_indicator = match a.llm_tool.as_deref() { - Some("claude") => ("A", Color::Rgb(193, 95, 60)), + // Brand salmon #E05D44 (matches tokens.css --color-salmon) + Some("claude") => ("A", Color::Rgb(224, 93, 68)), Some("gemini") => ("G", Color::Rgb(111, 66, 193)), Some("codex") => ("O", Color::Green), _ => (" ", Color::Reset), diff --git a/src/ui/keybindings.rs b/src/ui/keybindings.rs index 33090a77..43e41206 100644 --- a/src/ui/keybindings.rs +++ b/src/ui/keybindings.rs @@ -293,7 +293,7 @@ pub static SHORTCUTS: &[Shortcut] = &[ key: KeyCode::Char('W'), modifiers: KeyModifiers::NONE, alt_key: Some(KeyCode::Char('w')), - description: "Toggle Backstage server", + description: "Open web UI in browser", category: ShortcutCategory::Actions, context: ShortcutContext::Global, }, diff --git a/src/ui/panels.rs b/src/ui/panels.rs index e3c9a448..3ecd4cf6 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -6,11 +6,11 @@ use ratatui::{ Frame, }; -use crate::backstage::ServerStatus; use crate::queue::Ticket; use crate::rest::RestApiStatus; use crate::state::CompletedTicket; use crate::templates::{color_for_key, glyph_for_key}; +use crate::ui::status_panel::RowHints; /// Format the ticket ID for display. /// The `ticket_id` field already contains the full ID (e.g., "FEAT-1234"), @@ -152,11 +152,12 @@ pub struct StatusBar { pub paused: bool, pub agent_count: usize, pub max_agents: usize, - pub backstage_status: ServerStatus, pub rest_api_status: RestApiStatus, + pub embed_ui_available: bool, pub exit_confirmation_mode: bool, pub update_available_version: Option, pub status_message: Option, + pub row_hints: Option, } impl StatusBar { @@ -187,6 +188,38 @@ impl StatusBar { ) } + /// Build context-sensitive hints from the selected status panel row. + fn build_dynamic_hints(hints: &RowHints, width: u16) -> Span<'static> { + let mut parts: Vec = Vec::new(); + + if let Some(verb) = hints.primary_verb { + parts.push(format!("[Enter] {verb}")); + } + if let Some(title) = hints.special_title { + parts.push(format!("[⇧Enter] {title}")); + } + if let Some(title) = hints.refresh_title { + parts.push(format!("[^Enter] {title}")); + } + + if parts.is_empty() { + return Self::build_hints(width); + } + + let full = parts.join(" "); + let hint_text = if (full.len() + 2) <= width as usize { + full + } else if parts.len() > 2 { + parts[..2].join(" ") + } else if parts.len() > 1 { + parts[0].clone() + } else { + parts.first().cloned().unwrap_or_default() + }; + + Span::styled(format!(" {hint_text}"), Style::default().fg(Color::Cyan)) + } + pub fn render(&self, frame: &mut Frame, area: Rect) { // Exit confirmation mode - show only the exit message (highest priority) if self.exit_confirmation_mode { @@ -234,30 +267,14 @@ impl StatusBar { Style::default().fg(Color::Gray), ); - // Web server indicator - shows combined status of both servers - // ● green = both running, ● yellow = starting/stopping, ● red = error, ○ white = stopped - let web_indicator = match (&self.backstage_status, &self.rest_api_status) { - // Both running - green filled circle with port - (ServerStatus::Running { port, .. }, RestApiStatus::Running { .. }) => Span::styled( - format!(" [W]eb ●:{port}"), - Style::default().fg(Color::Green), - ), - // Either starting or stopping - yellow filled circle - (ServerStatus::Starting | ServerStatus::Stopping, _) - | (_, RestApiStatus::Starting | RestApiStatus::Stopping) => { - Span::styled(" [W]eb ●", Style::default().fg(Color::Yellow)) - } - // Either errored - red filled circle - (ServerStatus::Error(_), _) | (_, RestApiStatus::Error(_)) => { - Span::styled(" [W]eb ●", Style::default().fg(Color::Red)) - } - // Both stopped - white hollow circle - _ => Span::styled(" [W]eb ○", Style::default().fg(Color::White)), - }; + let web_ind = web_indicator(&self.rest_api_status, self.embed_ui_available); - let help = Self::build_hints(area.width); + let help = match &self.row_hints { + Some(hints) => Self::build_dynamic_hints(hints, area.width), + None => Self::build_hints(area.width), + }; - let mut spans = vec![status, agents, web_indicator]; + let mut spans = vec![status, agents, web_ind]; // Show transient status message if present if let Some(ref msg) = self.status_message { @@ -308,6 +325,26 @@ impl HeaderBar { } } +/// Build a status indicator span for the REST API / embedded web UI. +fn web_indicator(status: &RestApiStatus, embed_ui: bool) -> Span<'static> { + let label = if embed_ui { "[W]eb" } else { "[A]PI" }; + match status { + RestApiStatus::Running { port } => Span::styled( + format!(" {label} ●:{port}"), + Style::default().fg(Color::Green), + ), + RestApiStatus::Starting | RestApiStatus::Stopping => { + Span::styled(format!(" {label} ●"), Style::default().fg(Color::Yellow)) + } + RestApiStatus::Error(_) => { + Span::styled(format!(" {label} ●"), Style::default().fg(Color::Red)) + } + RestApiStatus::Stopped => { + Span::styled(format!(" {label} ○"), Style::default().fg(Color::White)) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -420,4 +457,130 @@ mod tests { "Minimal width should NOT include [S]ync" ); } + + #[test] + fn test_web_indicator_running() { + let span = web_indicator(&RestApiStatus::Running { port: 7008 }, true); + let text: &str = &span.content; + assert!(text.contains("●:7008"), "should show port: {text}"); + assert_eq!(span.style.fg, Some(Color::Green)); + } + + #[test] + fn test_web_indicator_starting() { + let span = web_indicator(&RestApiStatus::Starting, true); + assert_eq!(span.style.fg, Some(Color::Yellow)); + } + + #[test] + fn test_web_indicator_stopping() { + let span = web_indicator(&RestApiStatus::Stopping, true); + assert_eq!(span.style.fg, Some(Color::Yellow)); + } + + #[test] + fn test_web_indicator_error() { + let span = web_indicator(&RestApiStatus::Error("bind failed".into()), true); + assert_eq!(span.style.fg, Some(Color::Red)); + } + + #[test] + fn test_web_indicator_stopped() { + let span = web_indicator(&RestApiStatus::Stopped, true); + let text: &str = &span.content; + assert!(text.contains('○'), "should show hollow circle: {text}"); + assert_eq!(span.style.fg, Some(Color::White)); + } + + #[test] + fn test_web_indicator_label_with_embed_ui() { + let span = web_indicator(&RestApiStatus::Stopped, true); + let text: &str = &span.content; + assert!( + text.contains("[W]eb"), + "embed_ui=true should show [W]eb: {text}" + ); + } + + #[test] + fn test_web_indicator_label_without_embed_ui() { + let span = web_indicator(&RestApiStatus::Stopped, false); + let text: &str = &span.content; + assert!( + text.contains("[A]PI"), + "embed_ui=false should show [A]PI: {text}" + ); + } + + #[test] + fn test_build_dynamic_hints_all_actions() { + let hints = RowHints { + primary_verb: Some("Edit"), + special_title: Some("Reset"), + refresh_title: Some("Reload"), + }; + let span = StatusBar::build_dynamic_hints(&hints, 120); + let text: &str = &span.content; + assert!(text.contains("[Enter] Edit"), "should show primary: {text}"); + assert!( + text.contains("[⇧Enter] Reset"), + "should show special: {text}" + ); + assert!( + text.contains("[^Enter] Reload"), + "should show refresh: {text}" + ); + assert_eq!(span.style.fg, Some(Color::Cyan)); + } + + #[test] + fn test_build_dynamic_hints_primary_only() { + let hints = RowHints { + primary_verb: Some("Open"), + special_title: None, + refresh_title: None, + }; + let span = StatusBar::build_dynamic_hints(&hints, 120); + let text: &str = &span.content; + assert!(text.contains("[Enter] Open"), "should show primary: {text}"); + assert!( + !text.contains("[⇧Enter]"), + "should not show special: {text}" + ); + assert!( + !text.contains("[^Enter]"), + "should not show refresh: {text}" + ); + } + + #[test] + fn test_build_dynamic_hints_no_actions_falls_back() { + let hints = RowHints { + primary_verb: None, + special_title: None, + refresh_title: None, + }; + let span = StatusBar::build_dynamic_hints(&hints, 120); + let text: &str = &span.content; + assert!( + text.contains("[Q]ueue") || text.contains("[?]Help"), + "should fall back to static hints: {text}" + ); + assert_eq!(span.style.fg, Some(Color::DarkGray)); + } + + #[test] + fn test_build_dynamic_hints_truncates_at_narrow_width() { + let hints = RowHints { + primary_verb: Some("Edit"), + special_title: Some("Reset"), + refresh_title: Some("Reload"), + }; + let span = StatusBar::build_dynamic_hints(&hints, 40); + let text: &str = &span.content; + assert!( + !text.contains("[^Enter] Reload"), + "narrow width should drop refresh: {text}" + ); + } } diff --git a/src/ui/projects_dialog.rs b/src/ui/projects_dialog.rs index 1cce46b1..dbc09c10 100644 --- a/src/ui/projects_dialog.rs +++ b/src/ui/projects_dialog.rs @@ -44,7 +44,7 @@ impl ProjectAction { pub fn label(&self) -> &'static str { match self { ProjectAction::AddOperatorAgents => "Add Operator agents", - ProjectAction::AssessProject => "Assess for Backstage", + ProjectAction::AssessProject => "Assess Project", } } @@ -188,23 +188,19 @@ impl ProjectsDialog { fn handle_project_key(&mut self, key: KeyCode) -> Option { match key { - KeyCode::Up | KeyCode::Char('k') => { - if !self.projects.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i > 0 => i - 1, - _ => self.projects.len().saturating_sub(1), - }; - self.project_state.select(Some(i)); - } + KeyCode::Up | KeyCode::Char('k') if !self.projects.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i > 0 => i - 1, + _ => self.projects.len().saturating_sub(1), + }; + self.project_state.select(Some(i)); } - KeyCode::Down | KeyCode::Char('j') => { - if !self.projects.is_empty() { - let i = match self.project_state.selected() { - Some(i) if i < self.projects.len() - 1 => i + 1, - _ => 0, - }; - self.project_state.select(Some(i)); - } + KeyCode::Down | KeyCode::Char('j') if !self.projects.is_empty() => { + let i = match self.project_state.selected() { + Some(i) if i < self.projects.len() - 1 => i + 1, + _ => 0, + }; + self.project_state.select(Some(i)); } KeyCode::Enter => { if let Some(i) = self.project_state.selected() { @@ -224,23 +220,19 @@ impl ProjectsDialog { fn handle_action_key(&mut self, key: KeyCode) -> Option { let actions = ProjectAction::all(); match key { - KeyCode::Up | KeyCode::Char('k') => { - if !actions.is_empty() { - let i = match self.action_state.selected() { - Some(i) if i > 0 => i - 1, - _ => actions.len().saturating_sub(1), - }; - self.action_state.select(Some(i)); - } + KeyCode::Up | KeyCode::Char('k') if !actions.is_empty() => { + let i = match self.action_state.selected() { + Some(i) if i > 0 => i - 1, + _ => actions.len().saturating_sub(1), + }; + self.action_state.select(Some(i)); } - KeyCode::Down | KeyCode::Char('j') => { - if !actions.is_empty() { - let i = match self.action_state.selected() { - Some(i) if i < actions.len() - 1 => i + 1, - _ => 0, - }; - self.action_state.select(Some(i)); - } + KeyCode::Down | KeyCode::Char('j') if !actions.is_empty() => { + let i = match self.action_state.selected() { + Some(i) if i < actions.len() - 1 => i + 1, + _ => 0, + }; + self.action_state.select(Some(i)); } KeyCode::Enter => { if let Some(i) = self.action_state.selected() { @@ -624,7 +616,7 @@ mod tests { ProjectAction::AddOperatorAgents.label(), "Add Operator agents" ); - assert_eq!(ProjectAction::AssessProject.label(), "Assess for Backstage"); + assert_eq!(ProjectAction::AssessProject.label(), "Assess Project"); } #[test] diff --git a/src/ui/sections/config_section.rs b/src/ui/sections/config_section.rs index 8a848bb5..c9b4817e 100644 --- a/src/ui/sections/config_section.rs +++ b/src/ui/sections/config_section.rs @@ -51,6 +51,7 @@ impl StatusSection for ConfigSection { // Working Dir: primary=open, special=none (must launch from dir), refresh=none TreeRow { section_id: SectionId::Configuration, + id: "working-dir".into(), depth: 1, label: "Working Dir".into(), description: if snapshot.working_dir.is_empty() { @@ -77,6 +78,7 @@ impl StatusSection for ConfigSection { // Config: primary=edit, special=reset to defaults, refresh=reload config TreeRow { section_id: SectionId::Configuration, + id: "config-file".into(), depth: 1, label: "Config".into(), description: if snapshot.config_file_found { @@ -114,6 +116,7 @@ impl StatusSection for ConfigSection { // Tickets: primary=open dir, no special or refresh TreeRow { section_id: SectionId::Configuration, + id: "tickets-dir".into(), depth: 1, label: "Tickets".into(), description: if snapshot.tickets_dir_exists { @@ -139,6 +142,7 @@ impl StatusSection for ConfigSection { let wrapper = &snapshot.wrapper_connection_status; TreeRow { section_id: SectionId::Configuration, + id: "wrapper-connection".into(), depth: 1, label: wrapper.label().into(), description: wrapper.description(), @@ -169,6 +173,7 @@ impl StatusSection for ConfigSection { // Wrapper type: display-only TreeRow { section_id: SectionId::Configuration, + id: "wrapper-type".into(), depth: 1, label: "Wrapper".into(), description: snapshot.wrapper_type.clone(), @@ -180,6 +185,7 @@ impl StatusSection for ConfigSection { // $EDITOR: display-only TreeRow { section_id: SectionId::Configuration, + id: "editor".into(), depth: 1, label: "$EDITOR".into(), description: if snapshot.env_editor.is_empty() { @@ -199,6 +205,7 @@ impl StatusSection for ConfigSection { // $VISUAL: display-only TreeRow { section_id: SectionId::Configuration, + id: "visual".into(), depth: 1, label: "$VISUAL".into(), description: if snapshot.env_visual.is_empty() { @@ -218,6 +225,7 @@ impl StatusSection for ConfigSection { // Version: primary=open downloads, refresh=check for updates TreeRow { section_id: SectionId::Configuration, + id: "version".into(), depth: 1, label: "Version".into(), description: if let Some(ref update) = snapshot.update_available_version { diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs index 3364e7aa..99dafaeb 100644 --- a/src/ui/sections/connections_section.rs +++ b/src/ui/sections/connections_section.rs @@ -1,12 +1,23 @@ -use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ - ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, - StatusSnapshot, TreeRow, WrapperConnectionStatus, + ActionMeta, ActionSet, McpHttpStatus, SectionHealth, SectionId, StatusAction, StatusIcon, + StatusSection, StatusSnapshot, TreeRow, WrapperConnectionStatus, }; pub struct ConnectionsSection; +impl ConnectionsSection { + /// Whether the operator advertises at least one agent protocol — MCP (HTTP + /// mounted or stdio) or ACP (stdio). Drives the section header health so it + /// reflects the connectivity rows the section actually shows, rather than a + /// session-wrapper check that isn't attached in the web context. + fn any_protocol_exposed(&self, snapshot: &StatusSnapshot) -> bool { + let mcp = matches!(snapshot.mcp_http_status, McpHttpStatus::Mounted { .. }) + || snapshot.mcp_stdio_advertised; + mcp || snapshot.acp_stdio_advertised + } +} + impl StatusSection for ConnectionsSection { fn section_id(&self) -> SectionId { SectionId::Connections @@ -21,57 +32,77 @@ impl StatusSection for ConnectionsSection { } fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { - let api_ok = matches!(snapshot.api_status, RestApiStatus::Running { .. }); - let api_starting = matches!(snapshot.api_status, RestApiStatus::Starting); - let wrapper_ok = snapshot.wrapper_connection_status.is_connected(); - - // When backstage is hidden, health is based on API + wrapper only - if !snapshot.backstage_display { - return match (api_ok, wrapper_ok) { - (true, true) => SectionHealth::Green, - _ if api_starting => SectionHealth::Yellow, - (true, false) | (false, true) => SectionHealth::Yellow, - (false, false) => SectionHealth::Red, - }; + if matches!(snapshot.api_status, RestApiStatus::Starting) { + return SectionHealth::Yellow; } - - // When backstage is displayed, include it in health - let bs_ok = matches!(snapshot.backstage_status, ServerStatus::Running { .. }); - let bs_starting = matches!(snapshot.backstage_status, ServerStatus::Starting); - let all_ok = api_ok && bs_ok && wrapper_ok; - let any_starting = api_starting || bs_starting; - - if all_ok { + if !matches!(snapshot.api_status, RestApiStatus::Running { .. }) { + return SectionHealth::Red; + } + // API is up: Green when at least one protocol (MCP or ACP) is exposed, + // Yellow when the API serves but advertises no agent protocol. + if self.any_protocol_exposed(snapshot) { SectionHealth::Green - } else if any_starting || api_ok || bs_ok || wrapper_ok { - SectionHealth::Yellow } else { - SectionHealth::Red + SectionHealth::Yellow } } fn description(&self, snapshot: &StatusSnapshot) -> String { - let api_ok = matches!(snapshot.api_status, RestApiStatus::Running { .. }); - let api_starting = matches!(snapshot.api_status, RestApiStatus::Starting); - let wrapper_ok = snapshot.wrapper_connection_status.is_connected(); - - if api_starting { + if matches!(snapshot.api_status, RestApiStatus::Starting) { return "Starting...".into(); } - if api_ok && wrapper_ok { - return "Connected".into(); - } - if !api_ok && !wrapper_ok { + if !matches!(snapshot.api_status, RestApiStatus::Running { .. }) { return "Disconnected".into(); } - "Partial".into() + if self.any_protocol_exposed(snapshot) { + "Connected".into() + } else { + "Partial".into() + } } fn children(&self, snapshot: &StatusSnapshot) -> Vec { let mut rows = vec![ - // 1. Operator API + // 0. Operator version (informational), mirroring the VS Code section. TreeRow { section_id: SectionId::Connections, + id: "operator-version".into(), + depth: 1, + label: "Operator".into(), + description: format!("Version {}", snapshot.operator_version), + icon: StatusIcon::Check, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }, + // 1. Control wrapper — which session wrapper the operator control + // plane is running inside, and therefore how launched tickets are + // coordinated (VS Code terminal / cmux window / tmux / zellij tab). + // Mirrors the VS Code extension's "Session Wrapper" row. Informational + // only: does not drive the section header health. + TreeRow { + section_id: SectionId::Connections, + id: "control-wrapper".into(), + depth: 1, + label: "Control Wrapper".into(), + description: if snapshot.operator_inside_wrapper { + format!("{} · active", snapshot.wrapper_type) + } else { + format!("{} · not attached", snapshot.wrapper_type) + }, + icon: if snapshot.operator_inside_wrapper { + StatusIcon::Check + } else { + StatusIcon::Warning + }, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }, + // 2. Operator API + TreeRow { + section_id: SectionId::Connections, + id: "operator-api".into(), depth: 1, label: "Operator API".into(), description: match &snapshot.api_status { @@ -116,24 +147,130 @@ impl StatusSection for ConnectionsSection { }, ]; - // 2. Backstage (conditionally displayed) - if snapshot.backstage_display { + // 2. Web UI (when embed-ui feature is compiled in) + if snapshot.embed_ui_available { rows.push(TreeRow { section_id: SectionId::Connections, + id: "web-ui".into(), depth: 1, - label: "Backstage".into(), - description: format!("{:?}", snapshot.backstage_status), - icon: if matches!(snapshot.backstage_status, ServerStatus::Running { .. }) { - StatusIcon::Check - } else { - StatusIcon::Cross + label: "Web UI".into(), + description: match &snapshot.api_status { + RestApiStatus::Running { port } => format!(":{port}"), + RestApiStatus::Starting => "Starting...".into(), + _ => "API stopped".into(), + }, + icon: match &snapshot.api_status { + RestApiStatus::Running { .. } => StatusIcon::Check, + RestApiStatus::Starting => StatusIcon::Warning, + _ => StatusIcon::Cross, }, is_header: false, - actions: ActionSet::primary(StatusAction::ToggleWebServers), + actions: ActionSet { + primary: match &snapshot.api_status { + RestApiStatus::Running { port } => StatusAction::OpenWebUi { port: *port }, + RestApiStatus::Stopped | RestApiStatus::Error(_) => StatusAction::StartApi, + _ => StatusAction::None, + }, + back: StatusAction::None, + special: StatusAction::None, + special_meta: None, + refresh: StatusAction::None, + refresh_meta: None, + }, health: SectionHealth::Gray, }); } + // 3. MCP (always shown). Status reflects HTTP mount + stdio advertise + session count. + rows.push(TreeRow { + section_id: SectionId::Connections, + id: "mcp".into(), + depth: 1, + label: "MCP".into(), + description: match ( + &snapshot.mcp_http_status, + snapshot.mcp_stdio_advertised, + snapshot.mcp_active_sessions, + ) { + (McpHttpStatus::Mounted { port }, true, n) if n > 0 => { + format!(":{port} + stdio · {n} sessions") + } + (McpHttpStatus::Mounted { port }, true, _) => format!(":{port} + stdio"), + (McpHttpStatus::Mounted { port }, false, n) if n > 0 => { + format!(":{port} · {n} sessions") + } + (McpHttpStatus::Mounted { port }, false, _) => format!(":{port} (HTTP only)"), + (McpHttpStatus::NotMounted, true, _) => "stdio only".into(), + (McpHttpStatus::NotMounted, false, _) => "Disabled".into(), + }, + icon: match (&snapshot.mcp_http_status, snapshot.mcp_stdio_advertised) { + (McpHttpStatus::Mounted { .. }, _) | (_, true) => StatusIcon::Check, + _ => StatusIcon::Cross, + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::WriteAndOpenMcpClientConfig { + client: "claude-code".to_string(), + }, + back: StatusAction::None, + special: StatusAction::ToggleMcpHttp, + special_meta: Some(ActionMeta { + title: "HTTP", + tooltip: "Toggle the MCP HTTP transport (restart required)", + }), + refresh: StatusAction::OpenMcpDocs, + refresh_meta: Some(ActionMeta { + title: "Docs", + tooltip: "Open MCP setup docs in browser", + }), + }, + health: SectionHealth::Gray, + }); + + // 3. ACP (always shown). Status reflects whether operator + // advertises itself as an ACP agent. Active session count is + // always 0 in v1 (editor-spawned ACP runs out-of-process). + rows.push(TreeRow { + section_id: SectionId::Connections, + id: "acp".into(), + depth: 1, + label: "ACP".into(), + description: if snapshot.acp_stdio_advertised { + if snapshot.acp_active_sessions > 0 { + format!("stdio · {} sessions", snapshot.acp_active_sessions) + } else { + "stdio ready".into() + } + } else { + "Disabled".into() + }, + icon: if snapshot.acp_stdio_advertised { + StatusIcon::Check + } else { + StatusIcon::Cross + }, + is_header: false, + actions: ActionSet { + primary: StatusAction::WriteAndOpenAcpEditorConfig { + editor: "zed".to_string(), + }, + back: StatusAction::None, + special: StatusAction::WriteAndOpenAcpEditorConfig { + editor: "jetbrains".to_string(), + }, + special_meta: Some(ActionMeta { + title: "JBrn", + tooltip: "Write JetBrains ACP registry snippet", + }), + refresh: StatusAction::OpenAcpDocs, + refresh_meta: Some(ActionMeta { + title: "Docs", + tooltip: "Open ACP setup docs in browser", + }), + }, + health: SectionHealth::Gray, + }); + rows } } @@ -151,16 +288,17 @@ mod tests { tickets_dir: ".tickets".into(), tickets_dir_exists: true, wrapper_type: "tmux".into(), + operator_inside_wrapper: true, operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 7008 }, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators: vec![], model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -173,6 +311,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), + mcp_http_status: McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } @@ -180,7 +324,85 @@ mod tests { fn test_connections_tmux_connected_green_health() { let section = ConnectionsSection; let snap = base_snapshot(); - // API running + tmux connected = Green + // API running + a protocol exposed = Green + assert_eq!(section.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_connections_health_ignores_wrapper() { + // A disconnected session-wrapper must NOT downgrade the section when the + // API and at least one protocol are up — health follows the shown rows. + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.wrapper_connection_status = WrapperConnectionStatus::Tmux { + available: false, + server_running: false, + version: None, + }; + assert_eq!(section.health(&snap), SectionHealth::Green); + assert_eq!(section.description(&snap), "Connected"); + } + + #[test] + fn test_connections_health_partial_when_api_up_but_no_protocol() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = false; + snap.acp_stdio_advertised = false; + assert_eq!(section.health(&snap), SectionHealth::Yellow); + assert_eq!(section.description(&snap), "Partial"); + } + + #[test] + fn test_connections_operator_version_row_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let row = children + .iter() + .find(|r| r.id == "operator-version") + .expect("operator-version row must be present"); + assert_eq!(row.label, "Operator"); + assert!(row.description.contains(&snap.operator_version)); + } + + #[test] + fn test_connections_control_wrapper_row_active() { + let section = ConnectionsSection; + let snap = base_snapshot(); // operator_inside_wrapper: true, wrapper_type: "tmux" + let children = section.children(&snap); + let row = children + .iter() + .find(|r| r.id == "control-wrapper") + .expect("control-wrapper row must be present"); + assert_eq!(row.label, "Control Wrapper"); + assert_eq!(row.description, "tmux · active"); + assert!(matches!(row.icon, StatusIcon::Check)); + } + + #[test] + fn test_connections_control_wrapper_row_not_attached() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.operator_inside_wrapper = false; + snap.wrapper_type = "cmux".into(); + let children = section.children(&snap); + let row = children + .iter() + .find(|r| r.id == "control-wrapper") + .expect("control-wrapper row must be present"); + assert_eq!(row.description, "cmux · not attached"); + assert!(matches!(row.icon, StatusIcon::Warning)); + } + + #[test] + fn test_connections_control_wrapper_does_not_change_health() { + // The control-wrapper row is informational: a not-attached wrapper must + // not downgrade the section when API + a protocol are up. + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.operator_inside_wrapper = false; assert_eq!(section.health(&snap), SectionHealth::Green); } @@ -206,6 +428,37 @@ mod tests { assert_eq!(section.health(&snap), SectionHealth::Red); } + #[test] + fn test_connections_acp_row_present_when_advertised() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let acp_row = children + .iter() + .find(|r| r.label == "ACP") + .expect("ACP row must be in the connections section"); + assert!(matches!(acp_row.icon, StatusIcon::Check)); + assert_eq!(acp_row.description, "stdio ready"); + assert_eq!( + acp_row.actions.primary, + StatusAction::WriteAndOpenAcpEditorConfig { + editor: "zed".to_string() + } + ); + assert_eq!(acp_row.actions.refresh, StatusAction::OpenAcpDocs); + } + + #[test] + fn test_connections_acp_row_disabled_when_flag_off() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.acp_stdio_advertised = false; + let children = section.children(&snap); + let acp_row = children.iter().find(|r| r.label == "ACP").unwrap(); + assert!(matches!(acp_row.icon, StatusIcon::Cross)); + assert_eq!(acp_row.description, "Disabled"); + } + #[test] fn test_connections_api_running_opens_swagger() { let section = ConnectionsSection; @@ -229,25 +482,93 @@ mod tests { } #[test] - fn test_connections_backstage_hidden_by_default() { + fn test_connections_web_ui_row_present_when_embed_ui_available() { let section = ConnectionsSection; let snap = base_snapshot(); let children = section.children(&snap); + let web_ui_row = children + .iter() + .find(|r| r.label == "Web UI") + .expect("Web UI row must be present when embed_ui_available is true"); + assert!(matches!(web_ui_row.icon, StatusIcon::Check)); + assert_eq!(web_ui_row.description, ":7008"); + assert_eq!( + web_ui_row.actions.primary, + StatusAction::OpenWebUi { port: 7008 } + ); + } + + #[test] + fn test_connections_web_ui_row_absent_when_embed_ui_unavailable() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.embed_ui_available = false; + let children = section.children(&snap); assert!( - !children.iter().any(|r| r.label == "Backstage"), - "Backstage should be hidden when backstage_display is false" + !children.iter().any(|r| r.label == "Web UI"), + "Web UI row should be hidden when embed_ui_available is false" ); } #[test] - fn test_connections_backstage_shown_when_display_true() { + fn test_connections_web_ui_row_starts_api_when_stopped() { let section = ConnectionsSection; let mut snap = base_snapshot(); - snap.backstage_display = true; + snap.api_status = RestApiStatus::Stopped; + let children = section.children(&snap); + let web_ui_row = children.iter().find(|r| r.label == "Web UI").unwrap(); + assert!(matches!(web_ui_row.icon, StatusIcon::Cross)); + assert_eq!(web_ui_row.description, "API stopped"); + assert_eq!(web_ui_row.actions.primary, StatusAction::StartApi); + } + + #[test] + fn test_connections_mcp_row_always_present() { + let section = ConnectionsSection; + let snap = base_snapshot(); let children = section.children(&snap); assert!( - children.iter().any(|r| r.label == "Backstage"), - "Backstage should be shown when backstage_display is true" + children.iter().any(|r| r.label == "MCP"), + "MCP row should always be present" + ); + } + + #[test] + fn test_connections_mcp_row_disabled_description() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = false; + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + assert_eq!(mcp_row.description, "Disabled"); + } + + #[test] + fn test_connections_mcp_row_stdio_only_description() { + let section = ConnectionsSection; + let mut snap = base_snapshot(); + snap.mcp_http_status = McpHttpStatus::NotMounted; + snap.mcp_stdio_advertised = true; + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + assert_eq!(mcp_row.description, "stdio only"); + } + + #[test] + fn test_connections_mcp_row_actions() { + let section = ConnectionsSection; + let snap = base_snapshot(); + let children = section.children(&snap); + let mcp_row = children.iter().find(|r| r.label == "MCP").unwrap(); + // Primary writes the claude-code snippet by default. + assert_eq!( + mcp_row.actions.primary, + StatusAction::WriteAndOpenMcpClientConfig { + client: "claude-code".to_string() + } ); + assert_eq!(mcp_row.actions.special, StatusAction::ToggleMcpHttp); + assert_eq!(mcp_row.actions.refresh, StatusAction::OpenMcpDocs); } } diff --git a/src/ui/sections/delegator_section.rs b/src/ui/sections/delegator_section.rs index 252b55ef..8deef0dc 100644 --- a/src/ui/sections/delegator_section.rs +++ b/src/ui/sections/delegator_section.rs @@ -39,6 +39,7 @@ impl StatusSection for DelegatorSection { if snapshot.delegators.is_empty() { return vec![TreeRow { section_id: SectionId::Delegators, + id: "add-delegator".into(), depth: 1, label: "Add delegator".into(), description: "Edit config to configure a delegator".into(), @@ -65,6 +66,7 @@ impl StatusSection for DelegatorSection { TreeRow { section_id: SectionId::Delegators, + id: d.name.clone(), depth: 1, label, description, @@ -91,7 +93,6 @@ impl StatusSection for DelegatorSection { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{DelegatorInfo, WrapperConnectionStatus}; @@ -103,16 +104,17 @@ mod tests { tickets_dir: ".tickets".into(), tickets_dir_exists: true, wrapper_type: "tmux".into(), + operator_inside_wrapper: false, operator_version: "0.1.30".into(), api_status: RestApiStatus::Stopped, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators, model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -125,6 +127,12 @@ mod tests { }, env_editor: String::new(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::NotMounted, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } diff --git a/src/ui/sections/git_section.rs b/src/ui/sections/git_section.rs index 2f318459..167f6b4f 100644 --- a/src/ui/sections/git_section.rs +++ b/src/ui/sections/git_section.rs @@ -39,6 +39,7 @@ impl StatusSection for GitSection { vec![ TreeRow { section_id: SectionId::Git, + id: "configure-github".into(), depth: 1, label: "Configure GitHub".into(), description: "Set up GitHub".into(), @@ -51,6 +52,7 @@ impl StatusSection for GitSection { }, TreeRow { section_id: SectionId::Git, + id: "configure-gitlab".into(), depth: 1, label: "Configure GitLab".into(), description: "Set up GitLab".into(), @@ -69,6 +71,7 @@ impl StatusSection for GitSection { let mut rows = vec![ TreeRow { section_id: SectionId::Git, + id: "git-provider".into(), depth: 1, label: "Provider".into(), description: provider.clone(), @@ -89,6 +92,7 @@ impl StatusSection for GitSection { }, TreeRow { section_id: SectionId::Git, + id: "git-token".into(), depth: 1, label: "Token".into(), description: if snapshot.git_token_set { @@ -116,6 +120,7 @@ impl StatusSection for GitSection { if let Some(ref fmt) = snapshot.git_branch_format { rows.push(TreeRow { section_id: SectionId::Git, + id: "git-branch-format".into(), depth: 1, label: "Branch Format".into(), description: fmt.clone(), @@ -128,6 +133,7 @@ impl StatusSection for GitSection { rows.push(TreeRow { section_id: SectionId::Git, + id: "git-worktrees".into(), depth: 1, label: "Worktrees".into(), description: if snapshot.git_use_worktrees { @@ -150,7 +156,6 @@ impl StatusSection for GitSection { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ DelegatorInfo, KanbanProviderInfo, LlmToolInfo, WrapperConnectionStatus, @@ -164,14 +169,15 @@ mod tests { tickets_dir: ".tickets".into(), tickets_dir_exists: true, wrapper_type: "tmux".into(), + operator_inside_wrapper: false, operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 7008 }, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], delegators: vec![], model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -186,6 +192,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } diff --git a/src/ui/sections/issuetype_section.rs b/src/ui/sections/issuetype_section.rs new file mode 100644 index 00000000..dbacbf92 --- /dev/null +++ b/src/ui/sections/issuetype_section.rs @@ -0,0 +1,165 @@ +use crate::ui::status_panel::{ + ActionSet, SectionHealth, SectionId, StatusIcon, StatusSection, StatusSnapshot, TreeRow, +}; + +/// Issue Types section — mirrors the VS Code extension's `IssueTypeSection`. +/// Visible once Kanban is configured; lists the active issue types. +pub struct IssueTypeSection; + +impl StatusSection for IssueTypeSection { + fn section_id(&self) -> SectionId { + SectionId::IssueTypes + } + + fn label(&self) -> &'static str { + "Issue Types" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Kanban] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.issue_types.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let count = snapshot.issue_types.len(); + if count == 0 { + "Not configured".into() + } else { + format!("{count} type{}", if count == 1 { "" } else { "s" }) + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + snapshot + .issue_types + .iter() + .map(|it| TreeRow { + section_id: SectionId::IssueTypes, + id: it.key.clone(), + depth: 1, + label: it.key.clone(), + description: format!("{} · {}", it.name, it.mode), + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{IssueTypeInfo, WrapperConnectionStatus}; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_inside_wrapper: false, + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + kanban_providers: vec![], + llm_tools: vec![], + delegators: vec![], + model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + default_llm_tool: None, + default_llm_model: None, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, + } + } + + fn snapshot_with(issue_types: Vec) -> StatusSnapshot { + StatusSnapshot { + issue_types, + ..base_snapshot() + } + } + + fn sample_types() -> Vec { + vec![ + IssueTypeInfo { + key: "FEAT".into(), + name: "Feature".into(), + mode: "autonomous".into(), + }, + IssueTypeInfo { + key: "SPIKE".into(), + name: "Spike".into(), + mode: "paired".into(), + }, + ] + } + + #[test] + fn test_issuetype_prerequisite_is_kanban() { + assert_eq!(IssueTypeSection.prerequisites(), &[SectionId::Kanban]); + } + + #[test] + fn test_issuetype_health_yellow_when_empty() { + let snap = snapshot_with(vec![]); + assert_eq!(IssueTypeSection.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_issuetype_health_green_when_present() { + let snap = snapshot_with(sample_types()); + assert_eq!(IssueTypeSection.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_issuetype_description_counts_types() { + assert_eq!( + IssueTypeSection.description(&snapshot_with(vec![])), + "Not configured" + ); + assert_eq!( + IssueTypeSection.description(&snapshot_with(sample_types())), + "2 types" + ); + } + + #[test] + fn test_issuetype_children_render_key_name_mode() { + let snap = snapshot_with(sample_types()); + let children = IssueTypeSection.children(&snap); + assert_eq!(children.len(), 2); + assert_eq!(children[0].label, "FEAT"); + assert_eq!(children[0].description, "Feature · autonomous"); + assert_eq!(children[1].label, "SPIKE"); + assert_eq!(children[1].description, "Spike · paired"); + } +} diff --git a/src/ui/sections/kanban_section.rs b/src/ui/sections/kanban_section.rs index e3a124ae..152edb04 100644 --- a/src/ui/sections/kanban_section.rs +++ b/src/ui/sections/kanban_section.rs @@ -1,3 +1,4 @@ +use crate::api::providers::kanban::KanbanProviderType; use crate::ui::status_panel::{ ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, StatusSnapshot, TreeRow, @@ -35,40 +36,16 @@ impl StatusSection for KanbanSection { } fn children(&self, snapshot: &StatusSnapshot) -> Vec { - if snapshot.kanban_providers.is_empty() { - return vec![ - TreeRow { - section_id: SectionId::Kanban, - depth: 1, - label: "Configure Jira".into(), - description: "Connect to Jira Cloud".into(), - icon: StatusIcon::Plug, - is_header: false, - actions: ActionSet::primary(StatusAction::ConfigureKanbanProvider { - provider: "jira".into(), - }), - health: SectionHealth::Gray, - }, - TreeRow { - section_id: SectionId::Kanban, - depth: 1, - label: "Configure Linear".into(), - description: "Connect to Linear".into(), - icon: StatusIcon::Plug, - is_header: false, - actions: ActionSet::primary(StatusAction::ConfigureKanbanProvider { - provider: "linear".into(), - }), - health: SectionHealth::Gray, - }, - ]; - } - - snapshot + // A row per connected provider, followed by a "Configure" row for every + // supported provider not yet connected. Both the configure list and the + // ordering come from the canonical `KanbanProviderType::ALL` catalog, so + // the TUI, the web `/#/kanban` view, and VS Code onboarding stay aligned. + let mut rows: Vec = snapshot .kanban_providers .iter() .map(|provider| TreeRow { section_id: SectionId::Kanban, + id: provider.domain.clone(), depth: 1, label: provider.provider_type.clone(), description: provider.domain.clone(), @@ -87,14 +64,38 @@ impl StatusSection for KanbanSection { }, health: SectionHealth::Gray, }) - .collect() + .collect(); + + for provider in KanbanProviderType::ALL { + let already_connected = snapshot + .kanban_providers + .iter() + .any(|p| p.provider_type == provider.slug()); + if already_connected { + continue; + } + rows.push(TreeRow { + section_id: SectionId::Kanban, + id: format!("configure-{}", provider.slug()), + depth: 1, + label: format!("Configure {}", provider.display_name()), + description: provider.connect_blurb().to_string(), + icon: StatusIcon::Plug, + is_header: false, + actions: ActionSet::primary(StatusAction::ConfigureKanbanProvider { + provider: provider.slug().to_string(), + }), + health: SectionHealth::Gray, + }); + } + + rows } } #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ DelegatorInfo, KanbanProviderInfo, LlmToolInfo, WrapperConnectionStatus, @@ -108,16 +109,17 @@ mod tests { tickets_dir: ".tickets".into(), tickets_dir_exists: true, wrapper_type: "tmux".into(), + operator_inside_wrapper: false, operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 7008 }, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators: vec![], model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -130,6 +132,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } @@ -170,13 +178,14 @@ mod tests { } #[test] - fn test_kanban_children_empty_shows_configure_options() { + fn test_kanban_children_empty_shows_all_three_configure_options() { let section = KanbanSection; let snap = base_snapshot(); let children = section.children(&snap); - assert_eq!(children.len(), 2); - assert_eq!(children[0].label, "Configure Jira"); + // Every supported provider is offered when none are connected. + assert_eq!(children.len(), 3); + assert_eq!(children[0].label, "Configure Jira Cloud"); assert_eq!(children[0].description, "Connect to Jira Cloud"); assert_eq!( children[0].actions.primary, @@ -185,17 +194,24 @@ mod tests { } ); assert_eq!(children[1].label, "Configure Linear"); - assert_eq!(children[1].description, "Connect to Linear"); assert_eq!( children[1].actions.primary, StatusAction::ConfigureKanbanProvider { provider: "linear".into() } ); + assert_eq!(children[2].label, "Configure GitHub Projects"); + assert_eq!(children[2].description, "Connect to GitHub Projects"); + assert_eq!( + children[2].actions.primary, + StatusAction::ConfigureKanbanProvider { + provider: "github".into() + } + ); } #[test] - fn test_kanban_children_with_providers_shows_provider_rows() { + fn test_kanban_children_with_one_provider_still_offers_the_rest() { let section = KanbanSection; let mut snap = base_snapshot(); snap.kanban_providers.push(KanbanProviderInfo { @@ -204,9 +220,19 @@ mod tests { }); let children = section.children(&snap); - assert_eq!(children.len(), 1); + // The connected provider row, plus "add" rows for the two not yet connected. + assert_eq!(children.len(), 3); assert_eq!(children[0].label, "jira"); assert_eq!(children[0].description, "myteam.atlassian.net"); assert_eq!(children[0].actions.primary, StatusAction::None); + + let configure: Vec<_> = children + .iter() + .filter_map(|r| match &r.actions.primary { + StatusAction::ConfigureKanbanProvider { provider } => Some(provider.as_str()), + _ => None, + }) + .collect(); + assert_eq!(configure, vec!["linear", "github"]); } } diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs index 9f514133..793b426e 100644 --- a/src/ui/sections/llm_section.rs +++ b/src/ui/sections/llm_section.rs @@ -45,6 +45,7 @@ impl StatusSection for LlmSection { // Depth 1: tool name + version rows.push(TreeRow { section_id: SectionId::LlmTools, + id: tool.name.clone(), depth: 1, label: tool.name.clone(), description: tool.version.clone(), @@ -81,6 +82,7 @@ impl StatusSection for LlmSection { rows.push(TreeRow { section_id: SectionId::LlmTools, + id: format!("{}:{}", tool.name, model), depth: 2, label, description: format!("{}:{}", tool.name, model), diff --git a/src/ui/sections/managed_projects_section.rs b/src/ui/sections/managed_projects_section.rs new file mode 100644 index 00000000..2e871ead --- /dev/null +++ b/src/ui/sections/managed_projects_section.rs @@ -0,0 +1,173 @@ +use crate::ui::status_panel::{ + ActionSet, SectionHealth, SectionId, StatusIcon, StatusSection, StatusSnapshot, TreeRow, +}; + +/// Managed Projects section — mirrors the VS Code extension's `ManagedProjectsSection`. +/// Visible once Git is configured; lists the projects operator can assign work to. +pub struct ManagedProjectsSection; + +impl StatusSection for ManagedProjectsSection { + fn section_id(&self) -> SectionId { + SectionId::ManagedProjects + } + + fn label(&self) -> &'static str { + "Managed Projects" + } + + fn prerequisites(&self) -> &[SectionId] { + &[SectionId::Git] + } + + fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth { + if snapshot.managed_projects.is_empty() { + SectionHealth::Yellow + } else { + SectionHealth::Green + } + } + + fn description(&self, snapshot: &StatusSnapshot) -> String { + let count = snapshot.managed_projects.len(); + if count == 0 { + "No projects configured".into() + } else { + format!("{count} project{}", if count == 1 { "" } else { "s" }) + } + } + + fn children(&self, snapshot: &StatusSnapshot) -> Vec { + snapshot + .managed_projects + .iter() + .map(|proj| TreeRow { + section_id: SectionId::ManagedProjects, + id: proj.name.clone(), + depth: 1, + label: proj.name.clone(), + description: if proj.exists { + String::new() + } else { + "missing".into() + }, + icon: if proj.exists { + StatusIcon::Folder + } else { + StatusIcon::Warning + }, + is_header: false, + actions: ActionSet::none(), + health: SectionHealth::Gray, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rest::RestApiStatus; + use crate::ui::status_panel::{ManagedProjectInfo, WrapperConnectionStatus}; + + fn base_snapshot() -> StatusSnapshot { + StatusSnapshot { + working_dir: "/test".into(), + config_file_found: true, + config_path: "operator.toml".into(), + tickets_dir: ".tickets".into(), + tickets_dir_exists: true, + wrapper_type: "tmux".into(), + operator_inside_wrapper: false, + operator_version: "0.1.28".into(), + api_status: RestApiStatus::Running { port: 7008 }, + kanban_providers: vec![], + llm_tools: vec![], + delegators: vec![], + model_servers: vec![], + issue_types: vec![], + managed_projects: vec![], + git_provider: None, + git_token_set: false, + git_branch_format: None, + git_use_worktrees: false, + default_llm_tool: None, + default_llm_model: None, + update_available_version: None, + wrapper_connection_status: WrapperConnectionStatus::Tmux { + available: true, + server_running: true, + version: Some("tmux 3.4".into()), + }, + env_editor: "vim".into(), + env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::Mounted { port: 7008 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, + } + } + + fn snapshot_with(managed_projects: Vec) -> StatusSnapshot { + StatusSnapshot { + managed_projects, + ..base_snapshot() + } + } + + #[test] + fn test_projects_prerequisite_is_git() { + assert_eq!(ManagedProjectsSection.prerequisites(), &[SectionId::Git]); + } + + #[test] + fn test_projects_health_yellow_when_empty() { + let snap = snapshot_with(vec![]); + assert_eq!(ManagedProjectsSection.health(&snap), SectionHealth::Yellow); + } + + #[test] + fn test_projects_health_green_when_present() { + let snap = snapshot_with(vec![ManagedProjectInfo { + name: "operator".into(), + exists: true, + }]); + assert_eq!(ManagedProjectsSection.health(&snap), SectionHealth::Green); + } + + #[test] + fn test_projects_description_counts_projects() { + assert_eq!( + ManagedProjectsSection.description(&snapshot_with(vec![])), + "No projects configured" + ); + let snap = snapshot_with(vec![ManagedProjectInfo { + name: "operator".into(), + exists: true, + }]); + assert_eq!(ManagedProjectsSection.description(&snap), "1 project"); + } + + #[test] + fn test_projects_children_flag_missing_dirs() { + let snap = snapshot_with(vec![ + ManagedProjectInfo { + name: "present".into(), + exists: true, + }, + ManagedProjectInfo { + name: "gone".into(), + exists: false, + }, + ]); + let children = ManagedProjectsSection.children(&snap); + assert_eq!(children.len(), 2); + assert_eq!(children[0].label, "present"); + assert_eq!(children[0].description, ""); + assert!(matches!(children[0].icon, StatusIcon::Folder)); + assert_eq!(children[1].label, "gone"); + assert_eq!(children[1].description, "missing"); + assert!(matches!(children[1].icon, StatusIcon::Warning)); + } +} diff --git a/src/ui/sections/mod.rs b/src/ui/sections/mod.rs index 4ac35467..cce63c0e 100644 --- a/src/ui/sections/mod.rs +++ b/src/ui/sections/mod.rs @@ -2,14 +2,18 @@ mod config_section; mod connections_section; mod delegator_section; mod git_section; +mod issuetype_section; mod kanban_section; mod llm_section; +mod managed_projects_section; mod modelserver_section; pub use config_section::ConfigSection; pub use connections_section::ConnectionsSection; pub use delegator_section::DelegatorSection; pub use git_section::GitSection; +pub use issuetype_section::IssueTypeSection; pub use kanban_section::KanbanSection; pub use llm_section::LlmSection; +pub use managed_projects_section::ManagedProjectsSection; pub use modelserver_section::ModelServerSection; diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs index 36478284..c9abcd02 100644 --- a/src/ui/sections/modelserver_section.rs +++ b/src/ui/sections/modelserver_section.rs @@ -1,3 +1,4 @@ +use crate::api::providers::model_server::ModelServerKind; use crate::ui::status_panel::{ ActionMeta, ActionSet, SectionHealth, SectionId, StatusAction, StatusIcon, StatusSection, StatusSnapshot, TreeRow, @@ -40,7 +41,7 @@ impl StatusSection for ModelServerSection { } fn children(&self, snapshot: &StatusSnapshot) -> Vec { - snapshot + let mut rows: Vec = snapshot .model_servers .iter() .map(|s| { @@ -85,6 +86,7 @@ impl StatusSection for ModelServerSection { TreeRow { section_id: SectionId::ModelServers, + id: s.name.clone(), depth: 1, label, description, @@ -98,7 +100,32 @@ impl StatusSection for ModelServerSection { }, } }) - .collect() + .collect(); + + // Catalog "Add " rows for the addable (non-builtin) kinds, derived + // 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. + for kind in ModelServerKind::ALL { + if kind.is_builtin() { + continue; + } + rows.push(TreeRow { + section_id: SectionId::ModelServers, + id: format!("add-{}", kind.slug()), + depth: 1, + label: format!("Add {}", kind.display_name()), + description: kind.connect_blurb().to_string(), + icon: StatusIcon::Tool, + is_header: false, + actions: ActionSet::primary(StatusAction::ConfigureModelServer { + kind: kind.slug().to_string(), + }), + health: SectionHealth::Gray, + }); + } + + rows } } @@ -114,7 +141,6 @@ fn truncate_base_url(url: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::backstage::ServerStatus; use crate::rest::RestApiStatus; use crate::ui::status_panel::{ModelServerInfo, WrapperConnectionStatus}; @@ -126,16 +152,17 @@ mod tests { tickets_dir: ".tickets".into(), tickets_dir_exists: true, wrapper_type: "tmux".into(), + operator_inside_wrapper: false, operator_version: "0.1.30".into(), api_status: RestApiStatus::Stopped, - backstage_status: ServerStatus::Stopped, - backstage_display: false, kanban_providers: vec![], llm_tools: vec![], default_llm_tool: None, default_llm_model: None, delegators: vec![], model_servers: servers, + issue_types: vec![], + managed_projects: vec![], git_provider: None, git_token_set: false, git_branch_format: None, @@ -148,6 +175,12 @@ mod tests { }, env_editor: String::new(), env_visual: String::new(), + mcp_http_status: crate::ui::status_panel::McpHttpStatus::NotMounted, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } @@ -202,16 +235,42 @@ mod tests { "http://localhost:11434", )]); let rows = ModelServerSection.children(&snapshot); - assert_eq!(rows.len(), 1); - assert!(rows[0].description.contains("ollama")); - assert!(rows[0].description.contains("localhost:11434")); + // First row is the declared server; catalog "Add " rows follow. + let server_row = &rows[0]; + assert_eq!(server_row.id, "ollama-local"); + assert!(server_row.description.contains("ollama")); + assert!(server_row.description.contains("localhost:11434")); } #[test] fn test_children_mark_builtin_as_such() { let snapshot = snapshot_with_servers(vec![builtin("anthropic-api", "anthropic-api")]); let rows = ModelServerSection.children(&snapshot); - assert_eq!(rows.len(), 1); + // 1 server row + the addable-kind catalog rows. assert!(rows[0].description.contains("builtin")); } + + #[test] + fn test_children_offer_add_rows_for_non_builtin_kinds() { + let snapshot = snapshot_with_servers(vec![builtin("anthropic-api", "anthropic-api")]); + 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); + assert!(add_rows + .iter() + .any(|r| matches!(&r.actions.primary, StatusAction::ConfigureModelServer { kind } if kind == "ollama"))); + // No add-row for vendor builtins. + assert!(!add_rows.iter().any(|r| r.id == "add-anthropic-api")); + } + + #[test] + fn test_configure_model_server_web_url_points_at_setup_page() { + let action = StatusAction::ConfigureModelServer { + kind: "ollama".to_string(), + }; + let url = action.web_url().expect("ollama kind has a setup url"); + assert!(url.starts_with("http")); + } } diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index dae6f323..51695470 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -663,65 +663,4 @@ impl SetupScreen { } } } - - /// Re-check tmux availability (for [R] key binding) - #[allow(dead_code)] // Will be connected to [R] key handler in app event loop - pub fn recheck_tmux(&mut self) { - self.check_tmux_availability(); - } - - // ─── Kanban Setup Helper Methods ──────────────────────────────────────────── - // These methods are called from app.rs during async credential testing - // and project fetching. They are infrastructure for the full kanban setup flow. - - /// Skip kanban setup entirely - #[allow(dead_code)] - pub fn skip_kanban(&mut self) { - self.kanban_skipped = true; - } - - /// Mark a provider as valid after testing - #[allow(dead_code)] - pub fn mark_provider_valid(&mut self, index: usize) { - use crate::api::providers::kanban::ProviderStatus; - - if let Some(provider) = self.detected_kanban_providers.get_mut(index) { - provider.status = ProviderStatus::Valid; - if !self.valid_kanban_providers.contains(&index) { - self.valid_kanban_providers.push(index); - } - } - } - - /// Mark a provider as failed after testing - #[allow(dead_code)] - pub fn mark_provider_failed(&mut self, index: usize, error: String) { - use crate::api::providers::kanban::ProviderStatus; - - if let Some(provider) = self.detected_kanban_providers.get_mut(index) { - provider.status = ProviderStatus::Failed { error }; - } - } - - /// Set the projects for the current kanban provider - #[allow(dead_code)] - pub fn set_kanban_projects( - &mut self, - projects: Vec, - ) { - self.kanban_projects.set_items(projects); - } - - /// Get the selected kanban project - #[allow(dead_code)] - pub fn selected_kanban_project(&self) -> Option<&crate::api::providers::kanban::ProjectInfo> { - self.kanban_projects.selected_item() - } - - /// Set the preview info for the selected project - #[allow(dead_code)] - pub fn set_kanban_preview(&mut self, issue_types: Vec, member_count: usize) { - self.kanban_issue_types = issue_types; - self.kanban_member_count = member_count; - } } diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index 475f1677..12720a6c 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -123,9 +123,10 @@ impl StartupTicketOption { } /// Tmux availability detection status -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum TmuxDetectionStatus { /// Not yet checked + #[default] NotChecked, /// Tmux is available with the given version Available { version: String }, @@ -135,17 +136,12 @@ pub enum TmuxDetectionStatus { VersionTooOld { current: String, required: String }, } -impl Default for TmuxDetectionStatus { - fn default() -> Self { - Self::NotChecked - } -} - /// VS Code extension detection status -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // Variants will be used when VS Code extension support is implemented +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[allow(dead_code)] // Placeholder for VS Code extension detection (Phase 2) pub enum VSCodeDetectionStatus { /// Not yet checked + #[default] NotChecked, /// Currently checking connection Checking, @@ -155,12 +151,6 @@ pub enum VSCodeDetectionStatus { NotReachable, } -impl Default for VSCodeDetectionStatus { - fn default() -> Self { - Self::NotChecked - } -} - /// Session wrapper options shown in setup #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionWrapperOption { diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index cf2fe5d6..2e1764d6 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -10,12 +10,14 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::backstage::ServerStatus; +use std::path::Path; + +use crate::config::{Config, GitProviderConfig, SessionWrapperType}; use crate::rest::RestApiStatus; use super::sections::{ - ConfigSection, ConnectionsSection, DelegatorSection, GitSection, KanbanSection, LlmSection, - ModelServerSection, + ConfigSection, ConnectionsSection, DelegatorSection, GitSection, IssueTypeSection, + KanbanSection, LlmSection, ManagedProjectsSection, ModelServerSection, }; // --------------------------------------------------------------------------- @@ -71,6 +73,33 @@ impl SectionHealth { SectionHealth::Gray => Color::Gray, } } + + /// Stable lowercase string for serialization (REST / web UI). + pub fn as_str(self) -> &'static str { + match self { + SectionHealth::Green => "green", + SectionHealth::Yellow => "yellow", + SectionHealth::Red => "red", + SectionHealth::Gray => "gray", + } + } +} + +impl SectionId { + /// Stable string id matching the serde rename (REST / web UI / VS Code). + pub fn as_str(self) -> &'static str { + match self { + SectionId::Configuration => "config", + SectionId::Connections => "connections", + SectionId::Kanban => "kanban", + SectionId::LlmTools => "llm", + SectionId::ModelServers => "model-servers", + SectionId::Git => "git", + SectionId::IssueTypes => "issuetypes", + SectionId::Delegators => "delegators", + SectionId::ManagedProjects => "projects", + } + } } /// Declarative section metadata — shared between TUI and `VSCode`. @@ -118,6 +147,22 @@ impl StatusIcon { StatusIcon::None => Span::raw(" "), } } + + /// Stable lowercase icon name for serialization (REST / web UI). + pub fn as_str(self) -> &'static str { + match self { + StatusIcon::Check => "check", + StatusIcon::Cross => "cross", + StatusIcon::Warning => "warning", + StatusIcon::Folder => "folder", + StatusIcon::File => "file", + StatusIcon::Plug => "plug", + StatusIcon::Key => "key", + StatusIcon::Branch => "branch", + StatusIcon::Tool => "tool", + StatusIcon::None => "none", + } + } } // --------------------------------------------------------------------------- @@ -129,6 +174,12 @@ impl StatusIcon { #[allow(dead_code)] pub struct TreeRow { pub section_id: SectionId, + /// Stable, section-scoped row identifier. Used by clients (web UI, VS Code + /// extension) as a tree key and to route row-specific commands without + /// matching on the (mutable) display label. For dynamic rows this is the + /// underlying entity key (issue-type key, project name, delegator name); + /// for static rows it is a fixed slug (e.g. "git-token"). + pub id: String, pub depth: u16, pub label: String, pub description: String, @@ -149,30 +200,127 @@ pub enum StatusAction { EditFile(String), /// Open a URL in the default browser OpenUrl(String), - /// Start the REST API server (without backstage) + /// Start the REST API server StartApi, /// Open Swagger UI for the running API OpenSwagger { port: u16 }, /// Restart the session wrapper connection RestartWrapperConnection, - /// Toggle the web servers (backstage + REST API) - ToggleWebServers, + /// Open the embedded web UI in the default browser + OpenWebUi { port: u16 }, + /// Open the embedded web UI at a specific hash route (e.g. "/config", "/issuetypes") + OpenWebUiAt { port: u16, route: String }, /// Set the global default LLM tool and model SetDefaultLlm { tool_name: String, model: String }, /// Open onboarding for a kanban provider (e.g. "jira", "linear") ConfigureKanbanProvider { provider: String }, /// Open setup page for a git provider (e.g. "github", "gitlab") ConfigureGitProvider { provider: String }, + /// Open the setup page for a model-server kind (e.g. "ollama", "openai-compat") + ConfigureModelServer { kind: String }, /// Re-check a specific section's health status RefreshSection(SectionId), /// Reset config to factory defaults (TUI: double-confirm dialog) ResetConfig, /// Reload config from disk and restart operator experience ReloadConfig, + /// Toggle `[mcp].http_enabled` (requires API restart to take effect). + ToggleMcpHttp, + /// Generate a client config snippet, write it to + /// `/operator/mcp/.json`, and open it in `$EDITOR`. + /// `client` is one of: "claude-code", "claude-desktop", "cursor", "vscode", "zed". + WriteAndOpenMcpClientConfig { client: String }, + /// Open the operator MCP docs page in the default browser. + OpenMcpDocs, + /// Generate an ACP editor registration snippet, write it to + /// `/operator/acp/.{json,el,toml}`, and open it in + /// `$EDITOR`. `editor` is one of: "zed", "jetbrains", "emacs", "kiro". + WriteAndOpenAcpEditorConfig { editor: String }, + /// Open the operator ACP docs page in the default browser. + OpenAcpDocs, /// No action available for this row None, } +#[allow(dead_code)] +impl StatusAction { + pub fn display_verb(&self) -> Option<&'static str> { + match self { + Self::None => None, + Self::ToggleSection(_) => Some("Toggle"), + Self::OpenDirectory(_) => Some("Open"), + Self::EditFile(_) => Some("Edit"), + Self::OpenUrl(_) => Some("Open"), + Self::StartApi => Some("Start API"), + Self::OpenSwagger { .. } => Some("Swagger"), + Self::RestartWrapperConnection => Some("Restart"), + Self::OpenWebUi { .. } => Some("Web UI"), + Self::OpenWebUiAt { .. } => Some("Web UI"), + Self::SetDefaultLlm { .. } => Some("Set LLM"), + Self::ConfigureKanbanProvider { .. } => Some("Setup"), + Self::ConfigureGitProvider { .. } => Some("Setup"), + Self::ConfigureModelServer { .. } => Some("Setup"), + Self::RefreshSection(_) => Some("Refresh"), + Self::ResetConfig => Some("Reset"), + Self::ReloadConfig => Some("Reload"), + Self::ToggleMcpHttp => Some("Toggle"), + Self::WriteAndOpenMcpClientConfig { .. } => Some("Generate"), + Self::OpenMcpDocs => Some("Docs"), + Self::WriteAndOpenAcpEditorConfig { .. } => Some("Generate"), + Self::OpenAcpDocs => Some("Docs"), + } + } + + /// The browser URL this action opens, if it is a link-style action. + /// + /// Shared by the TUI action handler (`App::handle_status_action`) and the + /// web `/api/v1/sections` projection so both open the same destinations. + /// Returns `None` for command/file-writing actions that have no + /// web-openable URL. + pub fn web_url(&self) -> Option { + match self { + Self::OpenSwagger { port } => Some(format!("http://localhost:{port}/swagger-ui")), + Self::OpenWebUi { port } => Some(format!("http://localhost:{port}/")), + Self::OpenWebUiAt { port, route } => Some(format!("http://localhost:{port}/#{route}")), + Self::OpenUrl(url) => Some(url.clone()), + Self::OpenMcpDocs => Some("https://untra.io/operator/docs/mcp/".to_string()), + Self::OpenAcpDocs => Some("https://untra.io/operator/docs/acp/".to_string()), + // The web `/#/kanban` "Configure" rows link to the provider's token + // page (there is no in-browser onboarding wizard). + Self::ConfigureKanbanProvider { provider } => { + crate::api::providers::kanban::KanbanProviderType::from_slug(provider) + .map(|p| p.setup_url().to_string()) + } + // The web `/#/model-servers` "Add " rows link to the kind's + // setup/credential page. + Self::ConfigureModelServer { kind } => { + crate::api::providers::model_server::ModelServerKind::from_slug(kind) + .map(|k| k.setup_url().to_string()) + } + _ => None, + } + } +} + +/// Hint data for the currently selected status panel row. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct RowHints { + pub primary_verb: Option<&'static str>, + pub special_title: Option<&'static str>, + pub refresh_title: Option<&'static str>, +} + +/// MCP HTTP transport status reflected on the dashboard's MCP row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpHttpStatus { + /// MCP HTTP routes mounted on the REST API server on the given port. + Mounted { port: u16 }, + /// MCP HTTP routes disabled by `[mcp].http_enabled = false`, or the + /// API server itself is not running. + NotMounted, +} + /// Which button was pressed — maps to ABXY gamepad layout. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActionButton { @@ -298,6 +446,23 @@ pub struct ModelServerInfo { pub user_declared: bool, } +/// Information about an active issue type (mirrors `IssueTypeSummary` from the REST DTO). +#[derive(Debug, Clone)] +pub struct IssueTypeInfo { + pub key: String, + pub name: String, + /// "autonomous" or "paired". + pub mode: String, +} + +/// Information about a configured managed project. +#[derive(Debug, Clone)] +pub struct ManagedProjectInfo { + pub name: String, + /// Whether the project directory exists on disk. + pub exists: bool, +} + /// Connection status for the active session wrapper. #[derive(Debug, Clone)] pub enum WrapperConnectionStatus { @@ -408,16 +573,22 @@ pub struct StatusSnapshot { pub tickets_dir: String, pub tickets_dir_exists: bool, pub wrapper_type: String, + /// Whether operator is running *inside* its configured control wrapper + /// (tmux/cmux/zellij/vscode), detected from env markers. Reports how launched + /// tickets will be coordinated from the operator control plane. + pub operator_inside_wrapper: bool, pub operator_version: String, pub api_status: RestApiStatus, - pub backstage_status: ServerStatus, - pub backstage_display: bool, pub kanban_providers: Vec, pub llm_tools: Vec, pub default_llm_tool: Option, pub default_llm_model: Option, pub delegators: Vec, pub model_servers: Vec, + /// Active issue types (drives the Issue Types section). + pub issue_types: Vec, + /// Configured managed projects (drives the Managed Projects section). + pub managed_projects: Vec, pub git_provider: Option, pub git_token_set: bool, pub git_branch_format: Option, @@ -428,6 +599,220 @@ pub struct StatusSnapshot { pub env_editor: String, /// Resolved `$VISUAL` value pub env_visual: String, + /// MCP HTTP transport status (mounted on API server, or disabled). + pub mcp_http_status: McpHttpStatus, + /// Whether the descriptor advertises the stdio entrypoint. + pub mcp_stdio_advertised: bool, + /// Currently active MCP SSE sessions on the HTTP transport. + pub mcp_active_sessions: usize, + /// Whether `[acp].stdio_advertised` is true (operator advertises itself + /// as an ACP agent for editor integration). + pub acp_stdio_advertised: bool, + /// Currently active ACP sessions visible to the TUI. v1: always 0 + /// because editor-spawned `operator acp` runs out-of-process. + pub acp_active_sessions: usize, + /// Whether the embedded SPA (ui/) was compiled into the binary via the `embed-ui` feature. + pub embed_ui_available: bool, +} + +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), + _ => None, + } + } + + /// Build a snapshot from config alone, with default (non-live) runtime fields. + /// + /// Shared by the TUI dashboard — which overrides the runtime fields + /// (`api_status`, wrapper/mcp/acp liveness, editor env) with live state — + /// and the REST `/api/v1/sections` endpoint, which uses the config-derived + /// result as-is. `issue_types` is passed in because the TUI and REST source + /// it from different registries. Everything else here is derived purely from + /// config, so section *health* for the config-gated sections matches across + /// surfaces; only live runtime detail differs. + pub fn from_config(config: &Config, issue_types: Vec) -> StatusSnapshot { + let working_dir = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let config_path = Config::operator_config_path() + .to_string_lossy() + .into_owned(); + let tickets_dir = config.paths.tickets.clone(); + let tickets_dir_exists = Path::new(&tickets_dir).exists(); + + // Kanban providers from jira + linear + github configs. Provider type + // strings use the canonical `KanbanProviderType` slugs so they match the + // catalog the Kanban section renders against. + use crate::api::providers::kanban::KanbanProviderType; + let mut kanban_providers: Vec = Vec::new(); + for domain in config.kanban.jira.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: KanbanProviderType::Jira.slug().to_string(), + domain: domain.clone(), + }); + } + for slug in config.kanban.linear.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: KanbanProviderType::Linear.slug().to_string(), + domain: slug.clone(), + }); + } + for owner in config.kanban.github.keys() { + kanban_providers.push(KanbanProviderInfo { + provider_type: KanbanProviderType::Github.slug().to_string(), + domain: owner.clone(), + }); + } + + let llm_tools: Vec = config + .llm_tools + .detected + .iter() + .map(|t| LlmToolInfo { + name: t.name.clone(), + version: t.version.clone(), + model_aliases: t.model_aliases.clone(), + }) + .collect(); + + let delegators: Vec = config + .delegators + .iter() + .map(|d| DelegatorInfo { + name: d.name.clone(), + display_name: d.display_name.clone(), + llm_tool: d.llm_tool.clone(), + model: d.model.clone(), + yolo: d.launch_config.as_ref().is_some_and(|lc| lc.yolo), + model_server: d.model_server.clone(), + }) + .collect(); + + // Model servers — user-declared plus implicit vendor builtins. + let mut model_servers: Vec = config + .model_servers + .iter() + .map(|s| ModelServerInfo { + name: s.name.clone(), + kind: s.kind.clone(), + base_url: s.base_url.clone(), + display_name: s.display_name.clone(), + user_declared: true, + }) + .collect(); + for tool in ["claude", "codex", "gemini"] { + let implicit = crate::config::implicit_model_server_for_tool(tool); + if !model_servers.iter().any(|s| s.name == implicit.name) { + model_servers.push(ModelServerInfo { + name: implicit.name, + kind: implicit.kind, + base_url: implicit.base_url, + display_name: implicit.display_name, + user_declared: false, + }); + } + } + + let git_provider = config.git.provider.as_ref().map(|p| format!("{p:?}")); + let git_token_set = match config.git.provider { + Some(GitProviderConfig::GitLab) => std::env::var(&config.git.gitlab.token_env).is_ok(), + // GitHub is the default for all other providers (including None). + _ => std::env::var(&config.git.github.token_env).is_ok(), + }; + + // Managed projects — names from config, resolved against the projects base dir. + let projects_base = Path::new(&config.paths.projects); + let managed_projects: Vec = config + .projects + .iter() + .map(|name| ManagedProjectInfo { + name: name.clone(), + exists: projects_base.join(name).exists(), + }) + .collect(); + + // Wrapper connection: config-derived, liveness defaulted to "not yet checked". + let wrapper_connection_status = match config.sessions.wrapper { + SessionWrapperType::Tmux => WrapperConnectionStatus::Tmux { + available: false, + server_running: false, + version: None, + }, + SessionWrapperType::Vscode => WrapperConnectionStatus::Vscode { + webhook_running: false, + port: Some(config.sessions.vscode.webhook_port), + }, + SessionWrapperType::Cmux => WrapperConnectionStatus::Cmux { + binary_available: false, + in_cmux: std::env::var("CMUX_WORKSPACE_ID").is_ok(), + }, + SessionWrapperType::Zellij => WrapperConnectionStatus::Zellij { + binary_available: false, + in_zellij: std::env::var("ZELLIJ").is_ok(), + }, + }; + + let acp_status = crate::acp::AcpAgentServer::from_config(config).status(); + + StatusSnapshot { + working_dir, + config_file_found: true, + config_path, + tickets_dir, + tickets_dir_exists, + wrapper_type: config.sessions.wrapper.display_name().to_string(), + operator_inside_wrapper: config.sessions.wrapper.is_active_context(), + operator_version: env!("CARGO_PKG_VERSION").to_string(), + // Runtime field — callers with live state override this. + api_status: RestApiStatus::Stopped, + kanban_providers, + llm_tools, + default_llm_tool: config.llm_tools.default_tool.clone(), + default_llm_model: config.llm_tools.default_model.clone(), + delegators, + model_servers, + issue_types, + managed_projects, + git_provider, + git_token_set, + git_branch_format: Some(config.git.branch_format.clone()), + git_use_worktrees: config.git.use_worktrees, + update_available_version: None, + wrapper_connection_status, + env_editor: String::new(), + env_visual: String::new(), + mcp_http_status: McpHttpStatus::NotMounted, + mcp_stdio_advertised: config.mcp.stdio_advertised, + mcp_active_sessions: 0, + acp_stdio_advertised: acp_status.is_advertised(), + acp_active_sessions: acp_status.active_sessions(), + embed_ui_available: cfg!(feature = "embed-ui"), + } + } + + /// Overlay live connection facts onto a config-derived snapshot. + /// + /// `from_config` defaults the runtime fields to "stopped" because it has no + /// way to know the server state. Callers with live state (the TUI dashboard + /// and the REST `/api/v1/sections` handler) apply this so the connections + /// section reflects reality. Shared so both surfaces stay in lockstep. + pub fn apply_api_connection(&mut self, live: &crate::rest::dto::LiveConnectionStatus) { + if live.api_running { + self.api_status = RestApiStatus::Running { port: live.port }; + self.mcp_http_status = if live.mcp_http_enabled { + McpHttpStatus::Mounted { port: live.port } + } else { + McpHttpStatus::NotMounted + }; + } else { + self.api_status = RestApiStatus::Stopped; + self.mcp_http_status = McpHttpStatus::NotMounted; + } + self.mcp_active_sessions = live.mcp_active_sessions; + } } // --------------------------------------------------------------------------- @@ -488,8 +873,10 @@ impl TreeState { expanded.insert(SectionId::Kanban, false); expanded.insert(SectionId::LlmTools, false); expanded.insert(SectionId::ModelServers, false); - expanded.insert(SectionId::Delegators, false); expanded.insert(SectionId::Git, false); + expanded.insert(SectionId::IssueTypes, false); + expanded.insert(SectionId::Delegators, false); + expanded.insert(SectionId::ManagedProjects, false); Self { expanded, selected: 0, @@ -503,6 +890,111 @@ impl TreeState { // Status panel (orchestrator) // --------------------------------------------------------------------------- +/// The canonical, ordered list of status sections. +/// +/// This order is the single source of truth, matching the `SectionId` enum and +/// the VS Code extension's `allSections`. Used by both the TUI status panel and +/// the REST `/api/v1/sections` endpoint (via [`build_section_dtos`]). +pub fn all_sections() -> Vec> { + vec![ + Box::new(ConfigSection), + Box::new(ConnectionsSection), + Box::new(KanbanSection), + Box::new(LlmSection), + Box::new(ModelServerSection), + Box::new(GitSection), + Box::new(IssueTypeSection), + Box::new(DelegatorSection), + Box::new(ManagedProjectsSection), + ] +} + +/// Build the REST `SectionDto` list from a snapshot by running every canonical +/// section. Lives here (not in `rest`) because the section logic is ui-layer; +/// the binary injects this into `rest` via `register_section_provider` so the +/// web UI's Status page renders the same sections the TUI and VS Code show. +/// +/// Collect the URL/link-style actions from a row's `ActionSet` for the web UI. +/// +/// Walks the primary/special/refresh slots, keeping only actions that resolve +/// to a browser URL (via [`StatusAction::web_url`]) and deduping by URL so the +/// API row's primary+special Swagger pair doesn't render twice. Labels prefer +/// the slot's `ActionMeta` title, falling back to the action's display verb. +fn web_actions(actions: &ActionSet) -> Vec { + use crate::rest::dto::RowActionDto; + + let slots: [(&StatusAction, Option<&ActionMeta>); 3] = [ + (&actions.primary, None), + (&actions.special, actions.special_meta.as_ref()), + (&actions.refresh, actions.refresh_meta.as_ref()), + ]; + + let mut out: Vec = Vec::new(); + for (action, meta) in slots { + let Some(url) = action.web_url() else { + continue; + }; + if out.iter().any(|a| a.url == url) { + continue; + } + let label = meta + .map(|m| m.title.to_string()) + .or_else(|| action.display_verb().map(str::to_string)) + .unwrap_or_else(|| "Open".to_string()); + out.push(RowActionDto { label, url }); + } + out +} + +/// Returns all sections with a `met` flag (computed from prerequisite health) +/// rather than hiding unmet ones, so the web UI can render every section. +pub fn build_section_dtos(snapshot: &StatusSnapshot) -> Vec { + use crate::rest::dto::{SectionDto, SectionRowDto}; + + let sections = all_sections(); + + // First pass: each section's health, for prerequisite checks. + let health_by_id: HashMap = sections + .iter() + .map(|s| (s.section_id(), s.health(snapshot))) + .collect(); + + sections + .iter() + .map(|s| { + let met = s + .prerequisites() + .iter() + .all(|p| health_by_id.get(p) == Some(&SectionHealth::Green)); + SectionDto { + id: s.section_id().as_str().to_string(), + label: s.label().to_string(), + health: s.health(snapshot).as_str().to_string(), + description: s.description(snapshot), + prerequisites: s + .prerequisites() + .iter() + .map(|p| p.as_str().to_string()) + .collect(), + met, + children: s + .children(snapshot) + .into_iter() + .map(|r| SectionRowDto { + id: r.id, + depth: r.depth, + label: r.label, + description: r.description, + icon: r.icon.as_str().to_string(), + health: r.health.as_str().to_string(), + actions: web_actions(&r.actions), + }) + .collect(), + } + }) + .collect() +} + /// The status panel widget — a collapsible tree with progressive disclosure. pub struct StatusPanel { pub tree_state: TreeState, @@ -512,19 +1004,10 @@ pub struct StatusPanel { impl StatusPanel { pub fn new(title: String) -> Self { - let sections: Vec> = vec![ - Box::new(ConfigSection), - Box::new(ConnectionsSection), - Box::new(KanbanSection), - Box::new(LlmSection), - Box::new(ModelServerSection), - Box::new(DelegatorSection), - Box::new(GitSection), - ]; Self { tree_state: TreeState::new(), title, - sections, + sections: all_sections(), } } @@ -568,14 +1051,34 @@ impl StatusPanel { let health = section.health(snapshot); // Header row + let web_route = web_ui_route_for(section.section_id()); + let special = if let (Some(route), Some(port)) = (web_route, snapshot.api_port()) { + StatusAction::OpenWebUiAt { + port, + route: route.to_string(), + } + } else { + StatusAction::None + }; rows.push(TreeRow { section_id: section.section_id(), + id: section.section_id().as_str().to_string(), depth: 0, label: section.label().to_string(), description: section.description(snapshot), icon: StatusIcon::None, is_header: true, - actions: ActionSet::primary(StatusAction::ToggleSection(section.section_id())), + actions: ActionSet { + primary: StatusAction::ToggleSection(section.section_id()), + back: StatusAction::None, + special, + special_meta: web_route.map(|_| ActionMeta { + title: "Web", + tooltip: "Open this section in the web UI", + }), + refresh: StatusAction::None, + refresh_meta: None, + }, health, }); @@ -685,6 +1188,38 @@ impl StatusPanel { self.flatten(snapshot).len() } + /// Get hint data for the currently selected row, if any. + #[allow(dead_code)] + pub fn current_row_hints(&self, snapshot: &StatusSnapshot) -> Option { + let rows = self.flatten(snapshot); + let row = rows.get(self.tree_state.selected)?; + Some(RowHints { + primary_verb: row.actions.primary.display_verb(), + special_title: if row.actions.special == StatusAction::None { + None + } else { + Some( + row.actions + .special_meta + .as_ref() + .map(|m| m.title) + .unwrap_or("Special"), + ) + }, + refresh_title: if row.actions.refresh == StatusAction::None { + None + } else { + Some( + row.actions + .refresh_meta + .as_ref() + .map(|m| m.title) + .unwrap_or("Refresh"), + ) + }, + }) + } + /// Render the status panel into the given area. pub fn render(&self, frame: &mut Frame, area: Rect, focused: bool, snapshot: &StatusSnapshot) { let rows = self.flatten(snapshot); @@ -831,6 +1366,20 @@ impl StatusPanel { } // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Deep-link routing: maps section IDs to hash routes in the embedded web UI. +// --------------------------------------------------------------------------- + +fn web_ui_route_for(section: SectionId) -> Option<&'static str> { + match section { + SectionId::Configuration => Some("/config"), + SectionId::Kanban => Some("/config"), + SectionId::IssueTypes => Some("/issuetypes"), + SectionId::ManagedProjects => Some("/config"), + _ => None, + } +} + // Tests // --------------------------------------------------------------------------- @@ -838,6 +1387,189 @@ impl StatusPanel { mod tests { use super::*; + #[test] + fn test_build_section_dtos_returns_all_nine_in_canonical_order() { + let snapshot = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + let dtos = build_section_dtos(&snapshot); + let ids: Vec<&str> = dtos.iter().map(|d| d.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + "config", + "connections", + "kanban", + "llm", + "model-servers", + "git", + "issuetypes", + "delegators", + "projects", + ] + ); + } + + #[test] + fn test_build_section_dtos_sets_met_from_prerequisites() { + let snapshot = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + let dtos = build_section_dtos(&snapshot); + // Configuration has no prerequisites, so it is always met. + let config = dtos.iter().find(|d| d.id == "config").unwrap(); + assert!(config.met); + assert!(config.prerequisites.is_empty()); + // Managed Projects requires Git; on a default config Git is not Green, + // so its prerequisites are not met. + let projects = dtos.iter().find(|d| d.id == "projects").unwrap(); + assert_eq!(projects.prerequisites, vec!["git"]); + assert!(!projects.met); + } + + #[test] + fn test_apply_api_connection_overrides_stopped_defaults() { + let mut snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + assert_eq!(snap.api_status, RestApiStatus::Stopped); + snap.apply_api_connection(&crate::rest::dto::LiveConnectionStatus { + api_running: true, + port: 7008, + mcp_http_enabled: true, + mcp_active_sessions: 3, + }); + assert_eq!(snap.api_status, RestApiStatus::Running { port: 7008 }); + assert_eq!(snap.mcp_http_status, McpHttpStatus::Mounted { port: 7008 }); + assert_eq!(snap.mcp_active_sessions, 3); + } + + #[test] + fn test_apply_api_connection_http_disabled_keeps_mcp_unmounted() { + let mut snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + snap.apply_api_connection(&crate::rest::dto::LiveConnectionStatus { + api_running: true, + port: 7008, + mcp_http_enabled: false, + mcp_active_sessions: 0, + }); + assert_eq!(snap.mcp_http_status, McpHttpStatus::NotMounted); + } + + #[test] + fn test_configure_kanban_provider_web_url_resolves_per_provider() { + // The web `/#/kanban` rows are only clickable when the action yields a URL. + assert_eq!( + StatusAction::ConfigureKanbanProvider { + provider: "jira".into() + } + .web_url() + .as_deref(), + Some("https://id.atlassian.com/manage-profile/security/api-tokens") + ); + assert_eq!( + StatusAction::ConfigureKanbanProvider { + provider: "github".into() + } + .web_url() + .as_deref(), + Some("https://github.com/settings/personal-access-tokens") + ); + // Unknown slugs don't produce a link. + assert_eq!( + StatusAction::ConfigureKanbanProvider { + provider: "bogus".into() + } + .web_url(), + None + ); + } + + #[test] + fn test_from_config_includes_github_kanban_providers() { + let mut config = crate::config::Config::default(); + config.kanban.github.insert( + "my-org".into(), + crate::config::kanban::GithubProjectsConfig::default(), + ); + let snap = StatusSnapshot::from_config(&config, vec![]); + let github = snap + .kanban_providers + .iter() + .find(|p| p.provider_type == "github") + .expect("github provider should be populated from config"); + assert_eq!(github.domain, "my-org"); + } + + #[test] + fn test_build_section_dtos_connections_has_link_actions() { + let mut snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + snap.apply_api_connection(&crate::rest::dto::LiveConnectionStatus { + api_running: true, + port: 7008, + mcp_http_enabled: true, + mcp_active_sessions: 0, + }); + let dtos = build_section_dtos(&snap); + let conn = dtos + .iter() + .find(|d| d.id == "connections") + .expect("connections section present"); + let api_row = conn + .children + .iter() + .find(|r| r.id == "operator-api") + .expect("operator-api row"); + assert!( + api_row + .actions + .iter() + .any(|a| a.url.contains("/swagger-ui")), + "API row should carry a Swagger link" + ); + let mcp_row = conn + .children + .iter() + .find(|r| r.id == "mcp") + .expect("mcp row"); + assert!( + mcp_row.actions.iter().any(|a| a.url.contains("/docs/mcp")), + "MCP row should carry a docs link" + ); + } + + #[test] + fn test_build_section_dtos_kanban_configure_rows_have_link_actions() { + // End-to-end projection: the Kanban section's TreeRows must survive + // `web_actions` with populated, clickable links so the web `/#/kanban` + // view shows all three providers as actionable options. This guards the + // `children() -> web_actions -> SectionRowDto` composition, not just the + // pieces in isolation. + let snap = StatusSnapshot::from_config(&crate::config::Config::default(), vec![]); + let dtos = build_section_dtos(&snap); + let kanban = dtos + .iter() + .find(|d| d.id == "kanban") + .expect("kanban section present"); + + // All three providers are offered when none are connected. + assert_eq!(kanban.children.len(), 3); + + for (id, expected_url) in [ + ("configure-jira", "id.atlassian.com"), + ("configure-linear", "linear.app"), + ( + "configure-github", + "github.com/settings/personal-access-tokens", + ), + ] { + let row = kanban + .children + .iter() + .find(|r| r.id == id) + .unwrap_or_else(|| panic!("{id} row present")); + assert!( + row.actions.iter().any(|a| a.url.contains(expected_url)), + "{id} row should carry a clickable {expected_url} link, got {:?}", + row.actions + ); + } + } + fn test_snapshot() -> StatusSnapshot { StatusSnapshot { working_dir: "/home/user/project".into(), @@ -846,12 +1578,9 @@ mod tests { tickets_dir: ".tickets".into(), tickets_dir_exists: true, wrapper_type: "tmux".into(), + operator_inside_wrapper: true, operator_version: "0.1.28".into(), api_status: RestApiStatus::Running { port: 3100 }, - backstage_status: ServerStatus::Running { - port: 7007, - pid: 1234, - }, kanban_providers: vec![KanbanProviderInfo { provider_type: "Linear".into(), domain: "myteam.linear.app".into(), @@ -872,6 +1601,8 @@ mod tests { model_server: None, }], model_servers: Vec::new(), + issue_types: Vec::new(), + managed_projects: Vec::new(), git_provider: Some("GitHub".into()), git_token_set: true, git_branch_format: Some("feature/{ticket}".into()), @@ -884,7 +1615,12 @@ mod tests { }, env_editor: "vim".into(), env_visual: String::new(), - backstage_display: false, + mcp_http_status: McpHttpStatus::Mounted { port: 3100 }, + mcp_stdio_advertised: true, + mcp_active_sessions: 0, + acp_stdio_advertised: true, + acp_active_sessions: 0, + embed_ui_available: true, } } @@ -1321,6 +2057,129 @@ mod tests { assert_ne!(config.actions.refresh, StatusAction::None); } + #[test] + fn test_display_verb_returns_none_for_none() { + assert_eq!(StatusAction::None.display_verb(), None); + } + + #[test] + fn test_display_verb_returns_verb_for_each_variant() { + let cases: Vec<(StatusAction, &str)> = vec![ + ( + StatusAction::ToggleSection(SectionId::Configuration), + "Toggle", + ), + (StatusAction::OpenDirectory("/tmp".into()), "Open"), + (StatusAction::EditFile("config.toml".into()), "Edit"), + (StatusAction::OpenUrl("https://example.com".into()), "Open"), + (StatusAction::StartApi, "Start API"), + (StatusAction::OpenSwagger { port: 3100 }, "Swagger"), + (StatusAction::RestartWrapperConnection, "Restart"), + (StatusAction::OpenWebUi { port: 7007 }, "Web UI"), + ( + StatusAction::OpenWebUiAt { + port: 7007, + route: "/config".into(), + }, + "Web UI", + ), + ( + StatusAction::SetDefaultLlm { + tool_name: "claude".into(), + model: "opus".into(), + }, + "Set LLM", + ), + ( + StatusAction::ConfigureKanbanProvider { + provider: "jira".into(), + }, + "Setup", + ), + ( + StatusAction::ConfigureGitProvider { + provider: "github".into(), + }, + "Setup", + ), + ( + StatusAction::RefreshSection(SectionId::Connections), + "Refresh", + ), + (StatusAction::ResetConfig, "Reset"), + (StatusAction::ReloadConfig, "Reload"), + (StatusAction::ToggleMcpHttp, "Toggle"), + ( + StatusAction::WriteAndOpenMcpClientConfig { + client: "claude-code".into(), + }, + "Generate", + ), + (StatusAction::OpenMcpDocs, "Docs"), + ( + StatusAction::WriteAndOpenAcpEditorConfig { + editor: "zed".into(), + }, + "Generate", + ), + (StatusAction::OpenAcpDocs, "Docs"), + ]; + + for (action, expected) in cases { + assert_eq!( + action.display_verb(), + Some(expected), + "display_verb() for {action:?} should be {expected:?}" + ); + } + } + + #[test] + fn test_current_row_hints_returns_none_for_empty_panel() { + let mut snap = test_snapshot(); + snap.config_file_found = false; + let mut panel = StatusPanel::new("Status".into()); + panel.tree_state.selected = 9999; + let hints = panel.current_row_hints(&snap); + assert!(hints.is_none()); + } + + #[test] + fn test_current_row_hints_primary_only_row() { + let snap = test_snapshot(); + + let mut panel = StatusPanel::new("Status".into()); + panel.tree_state.selected = 1; + let hints = panel.current_row_hints(&snap).unwrap(); + assert_eq!(hints.primary_verb, Some("Open")); + assert!(hints.special_title.is_none()); + assert!(hints.refresh_title.is_none()); + } + + #[test] + fn test_current_row_hints_with_special_and_refresh() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Config row (index 2) has special=ResetConfig and refresh=ReloadConfig + panel.tree_state.selected = 2; + let hints = panel.current_row_hints(&snap).unwrap(); + assert_eq!(hints.primary_verb, Some("Edit")); + assert!(hints.special_title.is_some()); + assert!(hints.refresh_title.is_some()); + } + + #[test] + fn test_current_row_hints_header_row() { + let mut panel = StatusPanel::new("Status".into()); + let snap = test_snapshot(); + + // Header row (index 0) has primary=ToggleSection + panel.tree_state.selected = 0; + let hints = panel.current_row_hints(&snap).unwrap(); + assert_eq!(hints.primary_verb, Some("Toggle")); + } + #[test] fn test_refreshing_set_tracks_state() { let mut state = TreeState::new(); diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs new file mode 100644 index 00000000..1efe3e06 --- /dev/null +++ b/src/workflow_gen/command.rs @@ -0,0 +1,235 @@ +//! The shared "export workflow for a ticket" operation. +//! +//! This is the single code path every surface goes through: the CLI and TUI +//! call it directly in-process, and the REST handler calls it after resolving +//! the ticket — so the web UI and VS Code extension reach the same logic over +//! HTTP. Ticket resolution (filesystem/queue I/O) stays at the edges; this +//! function takes an already-resolved ticket plus the issue-type registry. + +use anyhow::{anyhow, Result}; + +use super::export::PipelineEnv; +use super::export_workflow; +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. +#[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`). + pub suggested_filename: String, + /// The generated `.js` workflow source. + 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. +pub fn export_workflow_for_ticket( + ticket: &Ticket, + registry: &IssueTypeRegistry, + pr_config: Option<&PrConfig>, + config: &Config, +) -> Result { + let issuetype = registry.get(&ticket.ticket_type).ok_or_else(|| { + anyhow!( + "No issue type '{}' registered for ticket {}", + ticket.ticket_type, + ticket.id + ) + })?; + + let env = pipeline_env_for(config, ticket, issuetype); + let contents = export_workflow(ticket, issuetype, pr_config, &env)?; + + Ok(ExportedWorkflow { + ticket_id: ticket.id.clone(), + issuetype_key: issuetype.key.clone(), + suggested_filename: format!("{}.workflow.js", ticket.id), + contents, + }) +} + +/// Render a *preview* workflow for an issue type, without a concrete ticket. +/// +/// Used by the UI to visualize an issue type's workflow shape. A placeholder +/// ticket is synthesized so the existing renderer can interpolate handlebars +/// variables — values are illustrative, not real. This is filesystem-safe: +/// `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 { + 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())?; + + Ok(ExportedWorkflow { + ticket_id: ticket.id, + issuetype_key: issuetype.key.clone(), + suggested_filename: format!("{}.preview.workflow.js", issuetype.key), + contents, + }) +} + +/// Build the pipeline-resolution environment for a real ticket export. +/// +/// Project discovery (a directory scan) only runs when the issuetype actually +/// contains a `projects` item source; the glob root is a pure path join. +fn pipeline_env_for(config: &Config, ticket: &Ticket, issuetype: &IssueType) -> PipelineEnv { + let needs_projects = issuetype.steps.iter().any(|s| { + matches!( + s.pipeline_config.as_ref().map(|c| &c.item_source), + Some(ItemSource::Projects) + ) + }); + PipelineEnv { + projects: if needs_projects { + config.discover_projects() + } else { + Vec::new() + }, + glob_root: Some(config.projects_path().join(&ticket.project)), + } +} + +/// Build an illustrative placeholder ticket for an issue type preview. +fn preview_ticket(issuetype: &IssueType) -> Ticket { + let key = &issuetype.key; + Ticket { + filename: format!("{key}-PREVIEW.md"), + filepath: format!("preview/{key}-PREVIEW.md"), + timestamp: "preview".to_string(), + ticket_type: key.clone(), + project: "preview".to_string(), + id: format!("{key}-PREVIEW"), + summary: format!("Sample {}", issuetype.name), + priority: "P2-medium".to_string(), + status: "queued".to_string(), + step: String::new(), + content: String::new(), + sessions: std::collections::HashMap::new(), + step_delegators: std::collections::HashMap::new(), + llm_task: LlmTask::default(), + worktree_path: None, + branch: None, + external_id: None, + external_url: None, + external_provider: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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, + } + } + + #[test] + fn exports_known_ticket_type_into_workflow() { + let exported = + export_workflow_for_ticket(&ticket("FEAT"), ®istry(), None, &Config::default()) + .unwrap(); + assert_eq!(exported.ticket_id, "FEAT-1234"); + assert_eq!(exported.issuetype_key, "FEAT"); + assert_eq!(exported.suggested_filename, "FEAT-1234.workflow.js"); + assert!( + exported.contents.contains("agent("), + "workflow body missing:\n{}", + exported.contents + ); + assert!( + exported.contents.contains("FEAT-1234"), + "ticket id not interpolated" + ); + } + + #[test] + fn errors_when_issuetype_unknown() { + let result = + export_workflow_for_ticket(&ticket("NOPE"), ®istry(), None, &Config::default()); + assert!(result.is_err(), "unknown issuetype should error"); + } + + #[test] + 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(); + assert_eq!(exported.issuetype_key, "FEAT"); + assert_eq!(exported.suggested_filename, "FEAT.preview.workflow.js"); + assert!( + exported.contents.contains("export const meta"), + "preview missing meta:\n{}", + exported.contents + ); + assert!( + exported.contents.contains("await agent("), + "preview missing agent calls:\n{}", + exported.contents + ); + // Compiler-native: no default-export wrapper (would hide the body). + assert!( + !exported.contents.contains("export default async function"), + "preview must not wrap body in a function:\n{}", + exported.contents + ); + } + + /// 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. + #[test] + fn dumps_all_builtin_previews_for_compiler_check() { + let reg = registry(); + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .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"); + } + } +} diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs new file mode 100644 index 00000000..908280f5 --- /dev/null +++ b/src/workflow_gen/export.rs @@ -0,0 +1,594 @@ +//! Orchestration: render a ticket against its issuetype into a Claude +//! dynamic-workflow `.js` source string. + +use anyhow::{Context, Result}; +use handlebars::Handlebars; +use serde_json::Value; + +use super::js; +use super::step_map; +use super::GAP_MARKER; +use crate::issuetypes::IssueType; +use crate::pr_config::PrConfig; +use crate::queue::Ticket; +use crate::steps::manager::StepManager; +use crate::templates::schema::{ItemSource, RagSource, ReviewType, StepSchema, StepTypeTag}; + +/// Environment inputs needed to resolve pipeline item sources that depend on +/// the machine (project list, glob root). Absent entries (e.g. the issuetype +/// preview path) make those sources fall back to a GAP-marked placeholder. +/// +/// Resolving these at export time bakes machine state into the artifact: the +/// export is deterministic *given the same project/filesystem environment* — +/// the same conditional that already applies to step-output loading in +/// `build_ticket_context` — and the output still never contains wall-clock or +/// randomness. +#[derive(Debug, Clone, Default)] +pub struct PipelineEnv { + /// Configured/discovered project names for `item_source: projects`. + pub projects: Vec, + /// Root directory against which `item_source: glob` patterns expand. + pub glob_root: Option, +} + +/// Render `ticket` against `issuetype` into a concrete Claude dynamic-workflow +/// `.js` source string. +/// +/// Deterministic given the same project/filesystem environment: the output +/// never contains wall-clock, `Date.now`, or `Math.random`, so the same inputs +/// in the same environment always produce byte-identical output. +pub fn export_workflow( + ticket: &Ticket, + issuetype: &IssueType, + pr_config: Option<&PrConfig>, + pipeline_env: &PipelineEnv, +) -> Result { + // 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 steps = ordered_steps(issuetype); + + // The body is emitted as TOP-LEVEL statements (after `export const meta`), + // NOT wrapped in `export default async function () { ... }`. This is the + // form the `@untra/naiveworkflow-compiler` walks — a wrapped body compiles + // to an empty graph — and mirrors the top-level shape of the Claude + // Workflow-tool format. + let mut out = String::new(); + out.push_str(&provenance_header(ticket, issuetype)); + out.push_str(&meta_block(ticket, issuetype, &steps)); + for step in &steps { + out.push_str(&render_step(&hbs, &ctx, step, pipeline_env)?); + } + Ok(out) +} + +/// 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> { + let mut order: Vec<&StepSchema> = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + let mut current = it.first_step(); + while let Some(step) = current { + if !seen.insert(step.name.as_str()) { + break; + } + order.push(step); + current = step.next_step.as_deref().and_then(|n| it.get_step(n)); + } + for step in &it.steps { + if seen.insert(step.name.as_str()) { + order.push(step); + } + } + order +} + +fn render(hbs: &Handlebars, template: &str, ctx: &Value) -> Result { + hbs.render_template(template, ctx) + .context("failed to render step prompt for workflow export") +} + +fn provenance_header(ticket: &Ticket, it: &IssueType) -> String { + format!( + "/* ───────────── OPERATOR PROVENANCE (audit) ─────────────\n\ + \x20* Generated by: operator workflow export\n\ + \x20* Ticket: {id} ({ttype}, project {project})\n\ + \x20* Issuetype: {key} — {name}\n\ + \x20* NOTE: autonomous flattening of an operator issuetype. Human review_type/on_reject\n\ + \x20* gates are emitted as judge-agent loops (see {gap} markers below). One operator\n\ + \x20* step = a full session; one agent() call = one background subagent.\n\ + \x20* ──────────────────────────────────────────────────────── */\n", + id = js::comment_safe(&ticket.id), + ttype = js::comment_safe(&ticket.ticket_type), + project = js::comment_safe(&ticket.project), + key = js::comment_safe(&it.key), + name = js::comment_safe(&it.name), + gap = GAP_MARKER, + ) +} + +fn meta_block(ticket: &Ticket, it: &IssueType, steps: &[&StepSchema]) -> String { + let name = if ticket.summary.is_empty() { + format!("{} — {}", ticket.id, it.name) + } else { + format!("{} — {}", ticket.id, ticket.summary) + }; + let description = format!("{}: {}", it.name, it.description); + + let mut phases = String::new(); + for step in steps { + phases.push_str(&format!( + " {{ title: {} }},\n", + js::quote(step.display_name()) + )); + } + + format!( + "export const meta = {{\n name: {name},\n description: {desc},\n phases: [\n{phases} ],\n}};\n\n", + name = js::quote(&name), + desc = js::quote(&description), + phases = phases, + ) +} + +fn render_step( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + pipeline_env: &PipelineEnv, +) -> Result { + let var = step_map::result_var(&step.name); + let prompt = render(hbs, &step.prompt, ctx)?; + let has_review = step.requires_review() || step.on_reject.is_some(); + + let mut s = String::new(); + s.push_str(&format!( + "await phase({});\n", + js::quote(step.display_name()) + )); + + match step.step_type { + StepTypeTag::Task | StepTypeTag::Delegator => { + // A delegator step may prepend prompt flavor and pin a model. + let mut effective_prompt = prompt.clone(); + let mut model: Option = step.agent.clone(); + if step.step_type == 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{prompt}"); + } + } + } + if !step.allowed_tools.is_empty() { + s.push_str(&format!( + "// allowed tools: {}\n", + step.allowed_tools.join(", ") + )); + } + if has_review { + s.push_str(&judge_loop(step, &var, &effective_prompt, model.as_deref())); + } else { + s.push_str(&agent_call( + &var, + &effective_prompt, + &step.name, + model.as_deref(), + )); + } + } + 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 { + serde_json::json!({ "type": "object" }) + }; + let schema_str = serde_json::to_string(&schema).unwrap_or_else(|_| "{}".to_string()); + s.push_str(&format!( + "const {var} = await agent({prompt}, {{ label: {label}, schema: {schema} }});\n", + prompt = js::quote(&prompt), + label = js::quote(&step.name), + schema = schema_str, + )); + if has_review { + s.push_str(&review_comment(step)); + } + } + StepTypeTag::MultiModel => { + s.push_str(&render_multi_model(hbs, ctx, step, &var, &prompt)?); + } + StepTypeTag::MultiPrompt => { + s.push_str(&render_multi_prompt(hbs, ctx, step, &var)?); + } + StepTypeTag::Matrixed => { + s.push_str(&render_matrixed(hbs, ctx, step, &var)?); + } + StepTypeTag::Rag => { + s.push_str(&format!( + "// {GAP_MARKER}: rag step — the workflow sandbox has no filesystem; context sources must be gathered by the agent.\n" + )); + if let Some(cfg) = &step.rag_config { + let srcs: Vec = cfg.sources.iter().map(describe_rag_source).collect(); + if !srcs.is_empty() { + s.push_str(&format!("// context sources: {}\n", srcs.join(", "))); + } + } + s.push_str(&agent_call(&var, &prompt, &step.name, None)); + } + StepTypeTag::Pipeline => { + s.push_str(&render_pipeline(hbs, ctx, step, &var, pipeline_env)?); + } + StepTypeTag::Mcp => { + s.push_str(&format!( + "// {GAP_MARKER}: mcp step — the workflow sandbox cannot guarantee MCP tool availability.\n" + )); + if let Some(cfg) = &step.mcp_config { + let tools: Vec = cfg + .required_tools + .iter() + .map(|t| match &t.tool { + Some(tool) => format!("{}/{}", t.server, tool), + None => t.server.clone(), + }) + .collect(); + if !tools.is_empty() { + s.push_str(&format!("// required tools: {}\n", tools.join(", "))); + } + } + s.push_str(&agent_call(&var, &prompt, &step.name, None)); + } + } + + s.push('\n'); + Ok(s) +} + +/// A single `const r_x = await agent("...", { label, [model] });` line. +fn agent_call(var: &str, prompt: &str, label: &str, model: Option<&str>) -> String { + let mut opts = format!("label: {}", js::quote(label)); + if let Some(m) = model { + opts.push_str(&format!(", model: {}", js::quote(m))); + } + format!( + "const {var} = await agent({prompt}, {{ {opts} }});\n", + prompt = js::quote(prompt), + ) +} + +fn review_label(review: &ReviewType) -> &'static str { + match review { + ReviewType::None => "output", + ReviewType::Plan => "plan", + ReviewType::Visual => "visual", + ReviewType::Pr => "pr", + } +} + +/// Autonomous judge-agent retry loop standing in for a human review gate. +fn judge_loop(step: &StepSchema, var: &str, prompt: &str, model: Option<&str>) -> String { + let review = review_label(&step.review_type); + let goto = step + .on_reject + .as_ref() + .map(|r| r.goto_step.clone()) + .unwrap_or_else(|| step.name.clone()); + + let mut opts = format!("label: {}", js::quote(&step.name)); + if let Some(m) = model { + opts.push_str(&format!(", model: {}", js::quote(m))); + } + + // The work call is a `const ..._work` declaration (not a bare reassignment) + // so the naiveworkflow compiler walks it and shows it inside the loop node. + // `var ..._verdict` is hoisted out of the `do` block so the `while` + // condition can read it. + format!( + "// {gap}: step {name} had human review_type={review}; a human gate cannot run mid-workflow.\n\ + // Emitted as an autonomous judge loop (max 3 attempts); original on_reject -> goto {goto}.\n\ + let {var};\n\ + let {var}_attempt = 0;\n\ + do {{\n\ + \x20 const {var}_work = await agent({prompt}, {{ {opts} }});\n\ + \x20 {var} = {var}_work;\n\ + \x20 var {var}_verdict = await agent({judge} + JSON.stringify({var}), {{ label: {jlabel} }});\n\ + }} while (String({var}_verdict).includes(\"REVISE\") && ++{var}_attempt < 3);\n\ + log({logmsg});\n", + gap = GAP_MARKER, + name = js::quote(&step.name), + review = js::quote(review), + goto = js::quote(&goto), + var = var, + prompt = js::quote(prompt), + opts = opts, + judge = js::quote( + "Review the previous step's output against its acceptance criteria. Reply with PASS or REVISE and the reasons.\n\nOutput:\n" + ), + jlabel = js::quote(&format!("{}-review", step.name)), + logmsg = js::quote(&format!( + "{GAP_MARKER}: step '{}' review was autonomous; a human gate (review_type={review}, on_reject->goto {goto}) was flattened.", + step.name + )), + ) +} + +fn review_comment(step: &StepSchema) -> String { + format!( + "// {GAP_MARKER}: step '{}' carries review_type={}; verify its output before relying on it.\n", + step.name, + review_label(&step.review_type), + ) +} + +fn render_multi_model( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + var: &str, + prompt: &str, +) -> Result { + let Some(cfg) = &step.multi_model_config else { + return Ok(agent_call(var, prompt, &step.name, None)); + }; + let mut branches = String::new(); + for d in &cfg.delegators { + branches.push_str(&format!( + " () => agent({prompt}, {{ model: {model} }}),\n", + prompt = js::quote(prompt), + model = js::quote(d), + )); + } + let vote_prompt = match &cfg.voting_prompt { + Some(p) => render(hbs, p, ctx)?, + None => { + "Review the candidate answers and select or synthesize the single best one.".to_string() + } + }; + let strategy = serde_json::to_string(&cfg.voting_strategy).unwrap_or_default(); + // Capture the fan-out in an intermediate `..._answers` binding, then vote + // with a plain `agent(...)`. A `parallel(...).then(...)` chain would be + // silently dropped by the naiveworkflow compiler. + Ok(format!( + "// multi-model fan-out + vote (voting_strategy: {strategy})\n\ + const {var}_answers = await parallel([\n{branches}]);\n\ + const {var} = await agent({vote} + JSON.stringify({var}_answers), {{ label: {label} }});\n", + var = var, + branches = branches, + vote = js::quote(&format!("{vote_prompt}\n\nCandidate answers:\n")), + label = js::quote(&format!("{}-vote", step.name)), + )) +} + +fn render_multi_prompt( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + var: &str, +) -> Result { + let Some(cfg) = &step.multi_prompt_config else { + let prompt = render(hbs, &step.prompt, ctx)?; + return Ok(agent_call(var, &prompt, &step.name, None)); + }; + let mut branches = String::new(); + for variation in &cfg.prompt_variations { + let rendered = render(hbs, variation, ctx)?; + branches.push_str(&format!(" () => agent({}),\n", js::quote(&rendered))); + } + let select_prompt = match &cfg.selection_prompt { + Some(p) => render(hbs, p, ctx)?, + None => "Review the candidate outputs and select the single best one.".to_string(), + }; + let strategy = serde_json::to_string(&cfg.selection_strategy).unwrap_or_default(); + // Intermediate `..._outputs` binding + plain select `agent(...)` — see the + // note in `render_multi_model` on why `.then()` is avoided. + Ok(format!( + "// multi-prompt variations + select (selection_strategy: {strategy})\n\ + const {var}_outputs = await parallel([\n{branches}]);\n\ + const {var} = await agent({select} + JSON.stringify({var}_outputs), {{ label: {label} }});\n", + var = var, + branches = branches, + select = js::quote(&format!("{select_prompt}\n\nCandidate outputs:\n")), + label = js::quote(&format!("{}-select", step.name)), + )) +} + +fn render_matrixed(hbs: &Handlebars, ctx: &Value, step: &StepSchema, var: &str) -> Result { + let Some(cfg) = &step.matrixed_config else { + let prompt = render(hbs, &step.prompt, ctx)?; + return Ok(agent_call(var, &prompt, &step.name, None)); + }; + let rendered_variations: Vec = cfg + .prompt_variations + .iter() + .map(|v| render(hbs, v, ctx)) + .collect::>()?; + + let format_str = serde_json::to_string(&cfg.output_format).unwrap_or_default(); + let mut rows = String::new(); + for d in &cfg.delegators { + let mut cells = String::new(); + for v in &rendered_variations { + cells.push_str(&format!( + " () => agent({prompt}, {{ model: {model} }}),\n", + prompt = js::quote(v), + model = js::quote(d), + )); + } + rows.push_str(&format!(" () => parallel([\n{cells} ]),\n")); + } + Ok(format!( + "// matrixed {n}x{m} (delegators x prompt variations, output_format: {format_str})\n\ + const {var} = await parallel([\n{rows}]);\n", + n = cfg.delegators.len(), + m = rendered_variations.len(), + var = var, + rows = rows, + )) +} + +/// Render a pipeline step: one top-level `const r_x = await pipeline(items, +/// …stage thunks);`. Items resolve per `ItemSource` (literal array → static +/// fan-out width in the compiled graph; identifier → symbolic). The step graph +/// stays linear — the N-item fan-out lives entirely inside this one statement. +fn render_pipeline( + hbs: &Handlebars, + ctx: &Value, + step: &StepSchema, + var: &str, + env: &PipelineEnv, +) -> Result { + let Some(cfg) = &step.pipeline_config else { + let prompt = render(hbs, &step.prompt, ctx)?; + return Ok(agent_call(var, &prompt, &step.name, None)); + }; + + let (preamble, items_expr) = pipeline_items_expr(&cfg.item_source, var, env); + + let mut stages = String::new(); + for (i, stage) in cfg.stages.iter().enumerate() { + let prompt = render(hbs, &stage.prompt, ctx)?; + // Structurally append the per-item runtime bindings to the quoted + // prompt (mirrors render_multi_model's `+ JSON.stringify(...)`); a + // template literal would change escaping rules. Stage 0's `prev` IS + // the item (Workflow-tool contract), so only later stages append it. + let mut prompt_expr = format!( + "{} + \"\\n\\nItem: \" + JSON.stringify(item)", + js::quote(&prompt) + ); + if i > 0 { + prompt_expr.push_str(" + \"\\n\\nPrior stage output:\\n\" + JSON.stringify(prev)"); + } + + let label = stage + .label + .clone() + .unwrap_or_else(|| format!("{}:{i}", step.name)); + // Explicit `phase` opt: stage agents run inside a container, where + // relying on the global phase() state would race. + let mut opts = format!( + "label: {}, phase: {}", + js::quote(&label), + js::quote(step.display_name()) + ); + let model = stage + .model + .as_deref() + .or(stage.agent.as_deref()) + .or(step.agent.as_deref()); + if let Some(m) = model { + opts.push_str(&format!(", model: {}", js::quote(m))); + } + if let Some(schema) = &stage.json_schema { + let schema_str = serde_json::to_string(schema).unwrap_or_else(|_| "{}".to_string()); + opts.push_str(&format!(", schema: {schema_str}")); + } + + stages.push_str(&format!( + " (prev, item, i) => agent({prompt_expr}, {{ {opts} }}),\n" + )); + } + + Ok(format!( + "{preamble}const {var} = await pipeline({items_expr},\n{stages});\n" + )) +} + +/// Resolve a pipeline `ItemSource` to `(preamble lines, items expression)`. +/// +/// `Static`, `Projects`, and `Glob` emit a literal array (static ×N in the +/// compiled graph); `FromStep` emits the prior step's result identifier +/// (symbolic ×N). Sources the export environment cannot resolve fall back to +/// an empty, GAP-marked placeholder binding — runtime-safe (zero iterations) +/// and rendered symbolic. +fn pipeline_items_expr(source: &ItemSource, var: &str, env: &PipelineEnv) -> (String, String) { + match source { + ItemSource::Static { items } => (String::new(), items_literal(items)), + ItemSource::FromStep { step } => ( + // The referenced step's existence is validated at schema load; its + // *shape* is a runtime value operator cannot statically check. + format!( + "// {GAP_MARKER}: items are step '{}' output at runtime; operator cannot statically check it is an array.\n", + js::comment_safe(step) + ), + step_map::result_var(step), + ), + ItemSource::Projects => { + if env.projects.is_empty() { + return unresolved_items(var, "projects"); + } + // Sorted for determinism regardless of discovery (read_dir) order. + let mut projects = env.projects.clone(); + projects.sort(); + (String::new(), items_literal(&projects)) + } + ItemSource::Glob { pattern } => { + let Some(root) = &env.glob_root else { + return unresolved_items(var, &format!("glob:{pattern}")); + }; + let matches = expand_glob(root, pattern); + if matches.is_empty() { + return unresolved_items(var, &format!("glob:{pattern} (no matches)")); + } + (String::new(), items_literal(&matches)) + } + // Deferred: ticket field values are not captured at export time (and + // no list FieldType exists yet), so Field cannot resolve here. + ItemSource::Field { name } => unresolved_items(var, &format!("field:{name}")), + } +} + +/// Quote a list of items into a JS array literal. +fn items_literal(items: &[String]) -> String { + let quoted: Vec = items.iter().map(|i| js::quote(i)).collect(); + format!("[{}]", quoted.join(", ")) +} + +/// Expand `pattern` against `root`, returning matches relative to `root` +/// (host-absolute paths would leak machine state into the artifact), sorted +/// for determinism. +fn expand_glob(root: &std::path::Path, pattern: &str) -> Vec { + let full = root.join(pattern); + let mut matches: Vec = glob::glob(&full.to_string_lossy()) + .map(|paths| { + paths + .flatten() + .filter_map(|p| { + p.strip_prefix(root) + .ok() + .map(|rel| rel.to_string_lossy().to_string()) + }) + .collect() + }) + .unwrap_or_default(); + matches.sort(); + matches +} + +/// GAP-marked placeholder for an item source that could not be resolved at +/// export time. Passing the identifier (not a literal) keeps the compiled +/// graph honest: the fan-out width shows as symbolic, not a fake static count. +fn unresolved_items(var: &str, desc: &str) -> (String, String) { + let ident = format!("{var}_items"); + ( + format!( + "// {GAP_MARKER}: pipeline items '{}' unresolved at export time; placeholder iterates zero items.\nconst {ident} = [];\n", + js::comment_safe(desc) + ), + ident, + ) +} + +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}"), + } +} diff --git a/src/workflow_gen/js.rs b/src/workflow_gen/js.rs new file mode 100644 index 00000000..b2a73251 --- /dev/null +++ b/src/workflow_gen/js.rs @@ -0,0 +1,35 @@ +//! Helpers for emitting safe `JavaScript` source. + +/// Escape a string for embedding inside a double-quoted JS string literal. +/// +/// Returns the escaped *content* only (no surrounding quotes). Handles the +/// characters that would otherwise terminate the literal or change its meaning: +/// backslash, double quote, and the common control characters. `${` and +/// backticks need no escaping because we only ever emit double-quoted literals. +pub fn escape_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + // Strip other C0 control chars that have no business in a literal. + c if (c as u32) < 0x20 => out.push(' '), + c => out.push(c), + } + } + out +} + +/// Wrap a string as a complete double-quoted JS string literal. +pub fn quote(s: &str) -> String { + format!("\"{}\"", escape_str(s)) +} + +/// Make a string safe to embed inside a `/* ... */` block comment by defusing +/// any sequence that would close the comment early. +pub fn comment_safe(s: &str) -> String { + s.replace("*/", "* /") +} diff --git a/src/workflow_gen/mod.rs b/src/workflow_gen/mod.rs new file mode 100644 index 00000000..2f70e06b --- /dev/null +++ b/src/workflow_gen/mod.rs @@ -0,0 +1,522 @@ +#![allow(dead_code)] // Public surface consumed by the `workflow export` CLI command. + +//! Export an operator ticket + issuetype to a Claude Code "dynamic workflow" +//! `.js` file. +//! +//! This is **export-only**: operator never parses `.js` back. The unit of +//! export is a ticket *and* its issuetype together — the issuetype supplies the +//! step structure, the ticket supplies the concrete field values. Rendering +//! them produces a workflow specialized to that exact ticket. +//! +//! The mapping is a lossy, autonomous *flattening*, not an equivalence: +//! human review gates (`review_type` / `on_reject`) have no workflow analog +//! (workflows forbid mid-run human input), so they are emitted as autonomous +//! judge-agent retry loops marked with `OPERATOR-GAP`. `rag`/`mcp` steps depend +//! on capabilities the workflow sandbox lacks and are likewise marked. + +mod command; +mod export; +pub mod js; +mod step_map; + +pub use command::{export_workflow_for_issuetype, export_workflow_for_ticket, ExportedWorkflow}; +pub use export::export_workflow; + +/// Marker emitted into generated workflows wherever an operator concept could +/// not be faithfully represented. +pub const GAP_MARKER: &str = "OPERATOR-GAP"; + +#[cfg(test)] +mod tests { + use super::export::PipelineEnv; + use super::*; + use crate::issuetypes::IssueType; + use crate::queue::Ticket; + + /// Build a ticket with the given id/project/summary; other fields fixed. + fn ticket(id: &str, project: &str, summary: &str) -> Ticket { + Ticket { + filename: "20241221-1430-FEAT-proj-test.md".to_string(), + filepath: "/test/path".to_string(), + timestamp: "20241221-1430".to_string(), + ticket_type: "FEAT".to_string(), + project: project.to_string(), + id: id.to_string(), + summary: summary.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, + } + } + + /// Build an issuetype with the given steps JSON array. + fn issuetype(steps_json: &str) -> IssueType { + let json = format!( + r#"{{ + "key": "FEAT", + "name": "Feature", + "description": "A new feature", + "mode": "autonomous", + "glyph": "*", + "fields": [], + "steps": {steps_json} + }}"# + ); + IssueType::from_json(&json).expect("valid issuetype json") + } + + fn export(steps_json: &str) -> String { + let it = issuetype(steps_json); + let tk = ticket("FEAT-1234", "gamesvc", "Add pagination"); + export_workflow(&tk, &it, None, &PipelineEnv::default()).expect("export ok") + } + + #[test] + fn js_escape_handles_quotes_backslashes_and_newlines() { + assert_eq!(js::escape_str("a\"b\\c\nd\te"), "a\\\"b\\\\c\\nd\\te"); + } + + #[test] + fn task_step_emits_agent_with_interpolated_prompt_and_phase() { + let out = export( + r#"[{"name":"plan","display_name":"Planning","outputs":["plan"], + "prompt":"Work on {{ id }} in {{ project }}"}]"#, + ); + assert!( + out.contains(r#"agent("Work on FEAT-1234 in gamesvc""#), + "interpolated agent call missing:\n{out}" + ); + assert!( + out.contains(r#"phase("Planning")"#), + "phase missing:\n{out}" + ); + assert!(!out.contains("{{"), "unrendered handlebars left in:\n{out}"); + } + + #[test] + fn classifier_step_emits_schema_opt() { + let out = export( + r#"[{"name":"triage","type":"classifier","outputs":["report"], + "prompt":"Is this a bug?","classifier_config":{"output_type":"boolean"}}]"#, + ); + assert!(out.contains("schema:"), "classifier schema missing:\n{out}"); + } + + #[test] + fn multi_model_step_emits_parallel_then_vote() { + let out = export( + r#"[{"name":"consensus","type":"multi_model","outputs":["code"], + "prompt":"Solve {{ id }}", + "multi_model_config":{"delegators":["opus","sonnet"],"voting_strategy":"majority"}}]"#, + ); + assert!(out.contains("parallel(["), "parallel missing:\n{out}"); + // The naiveworkflow compiler only walks recognized statements; a + // `parallel(...).then(...)` chain is silently dropped. Capture the + // fan-out in an intermediate binding, then vote with a plain agent(). + assert!( + !out.contains(".then("), + "must not emit a .then() chain (compiler drops it):\n{out}" + ); + assert!( + out.contains("_answers = await parallel(["), + "fan-out result not captured in an intermediate binding:\n{out}" + ); + assert!(out.contains("consensus-vote"), "vote agent missing:\n{out}"); + } + + #[test] + fn multi_prompt_step_emits_parallel_and_select() { + let out = export( + r#"[{"name":"explore","type":"multi_prompt","outputs":["plan"], + "prompt":"base", + "multi_prompt_config":{"prompt_variations":["angle A","angle B"],"selection_strategy":"model_choice"}}]"#, + ); + assert!(out.contains("parallel(["), "parallel missing:\n{out}"); + assert!( + !out.contains(".then("), + "must not emit a .then() chain (compiler drops it):\n{out}" + ); + assert!( + out.contains("_outputs = await parallel(["), + "fan-out result not captured in an intermediate binding:\n{out}" + ); + assert!( + out.contains("angle A") && out.contains("angle B"), + "variations missing:\n{out}" + ); + } + + #[test] + fn matrixed_step_emits_nested_parallel() { + let out = export( + r#"[{"name":"matrix","type":"matrixed","outputs":["code"], + "prompt":"base", + "matrixed_config":{"delegators":["opus","sonnet"],"prompt_variations":["v1","v2"],"output_format":"structured"}}]"#, + ); + // Nested parallel: at least two occurrences. + assert!( + out.matches("parallel(").count() >= 2, + "expected nested parallel:\n{out}" + ); + } + + #[test] + fn rag_and_mcp_steps_emit_gap_markers() { + let rag = export( + r#"[{"name":"gather","type":"rag","outputs":["report"],"prompt":"summarize", + "rag_config":{"sources":[{"type":"glob","pattern":"src/**/*.rs"}]}}]"#, + ); + assert!(rag.contains(GAP_MARKER), "rag gap marker missing:\n{rag}"); + + let mcp = export( + r#"[{"name":"lookup","type":"mcp","outputs":["report"],"prompt":"check", + "mcp_config":{"required_tools":[{"server":"jira","tool":"search"}]}}]"#, + ); + assert!(mcp.contains(GAP_MARKER), "mcp gap marker missing:\n{mcp}"); + } + + #[test] + fn review_gate_emits_judge_loop_and_gap_marker() { + let out = export( + r#"[{"name":"plan","outputs":["plan"],"prompt":"make a plan", + "review_type":"plan", + "on_reject":{"goto_step":"plan","prompt":"redo"}, + "next_step":"ship"}, + {"name":"ship","outputs":["pr"],"prompt":"ship it"}]"#, + ); + assert!(out.contains(GAP_MARKER), "gap marker missing:\n{out}"); + assert!( + out.contains("do {") && out.contains("} while"), + "judge loop missing:\n{out}" + ); + assert!(out.contains("log("), "log marker missing:\n{out}"); + } + + #[test] + fn output_has_provenance_header_and_meta() { + let out = export(r#"[{"name":"plan","outputs":["plan"],"prompt":"go"}]"#); + assert!( + out.contains("OPERATOR PROVENANCE"), + "provenance header missing:\n{out}" + ); + assert!( + out.contains("FEAT-1234"), + "ticket id missing from provenance:\n{out}" + ); + assert!( + out.contains("export const meta"), + "meta block missing:\n{out}" + ); + // Compiler-native format: steps are emitted as top-level statements + // (the naiveworkflow compiler only walks the program body), NOT wrapped + // in `export default async function`, which would hide the whole body. + assert!( + !out.contains("export default async function"), + "must not wrap the body in a default-export function:\n{out}" + ); + assert!( + out.contains("await phase("), + "top-level phase statement missing:\n{out}" + ); + } + + #[test] + fn output_is_deterministic_and_has_no_wallclock() { + let it = issuetype(r#"[{"name":"plan","outputs":["plan"],"prompt":"go {{ id }}"}]"#); + let tk = ticket("FEAT-1234", "gamesvc", "x"); + let a = export_workflow(&tk, &it, None, &PipelineEnv::default()).unwrap(); + let b = export_workflow(&tk, &it, None, &PipelineEnv::default()).unwrap(); + assert_eq!(a, b, "export is not deterministic"); + assert!(!a.contains("Date.now"), "must not emit Date.now"); + assert!(!a.contains("Math.random"), "must not emit Math.random"); + } + + #[test] + fn prompt_with_special_chars_is_escaped_into_valid_literal() { + let out = export( + r#"[{"name":"plan","outputs":["plan"],"prompt":"say \"hi\"\nuse `x` and ${y}"}]"#, + ); + // The double quotes inside the prompt must be backslash-escaped so the + // agent("...") literal is not terminated early. + assert!( + out.contains(r#"say \"hi\""#), + "inner quotes not escaped:\n{out}" + ); + assert!(out.contains("\\n"), "newline not escaped:\n{out}"); + // Backtick and ${ are safe inside a double-quoted literal and pass through. + assert!( + out.contains("`x`") && out.contains("${y}"), + "backtick/dollar mangled:\n{out}" + ); + } + + // ── Pipeline step emission ────────────────────────────────────── + + #[test] + fn pipeline_static_emits_literal_items_and_stage_thunks() { + let out = export( + r#"[{"name":"triage","display_name":"Per-project triage","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"static","items":["alpha","beta"]}, + "stages":[{"prompt":"Triage {{ id }}"}]}}]"#, + ); + // Top-level const binding over the pipeline call, items as a literal array. + assert!( + out.contains(r#"const r_triage = await pipeline(["alpha", "beta"],"#), + "pipeline call with literal items missing:\n{out}" + ); + // Stage is a (prev, item, i) thunk around agent(). + assert!( + out.contains("(prev, item, i) => agent("), + "stage thunk missing:\n{out}" + ); + // The handlebars-rendered prompt with the item binding structurally appended. + assert!( + out.contains(r#"agent("Triage FEAT-1234" + "\n\nItem: " + JSON.stringify(item)"#), + "item binding not appended to stage prompt:\n{out}" + ); + // No .then() chains (compiler drops them). + assert!(!out.contains(".then("), "must not emit .then():\n{out}"); + } + + #[test] + fn pipeline_stage_opts_carry_label_and_phase() { + let out = export( + r#"[{"name":"triage","display_name":"Per-project triage","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"static","items":["a"]}, + "stages":[{"prompt":"x"}]}}]"#, + ); + // Default label :; explicit phase opt so stage agents group + // under this step's phase instead of racing the global phase() state. + assert!( + out.contains(r#"label: "triage:0""#), + "default stage label missing:\n{out}" + ); + assert!( + out.contains(r#"phase: "Per-project triage""#), + "explicit phase opt missing:\n{out}" + ); + } + + #[test] + fn pipeline_later_stages_append_prev_but_first_does_not() { + let out = export( + r#"[{"name":"sweep","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"static","items":["a"]}, + "stages":[{"prompt":"first"},{"prompt":"second"}]}}]"#, + ); + // Stage 0: prev IS the item (Workflow-tool contract), so no prev append. + assert!( + out.contains(r#"agent("first" + "\n\nItem: " + JSON.stringify(item), {"#), + "stage 0 should append only the item:\n{out}" + ); + // Stage 1+: previous stage's output appended. + assert!( + out.contains( + r#"agent("second" + "\n\nItem: " + JSON.stringify(item) + "\n\nPrior stage output:\n" + JSON.stringify(prev), {"# + ), + "stage 1 should append item and prev:\n{out}" + ); + } + + #[test] + fn pipeline_from_step_emits_prior_result_identifier() { + let out = export( + r#"[{"name":"find","type":"classifier","outputs":["report"], + "prompt":"List affected modules","next_step":"fix", + "classifier_config":{"output_type":"big_text"}}, + {"name":"fix","type":"pipeline","outputs":["code"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"from_step","step":"find"}, + "stages":[{"prompt":"Fix the module"}]}}]"#, + ); + // Items expression is the prior step's result var — a runtime value, so + // the compiled graph shows a symbolic (not static) fan-out width. + assert!( + out.contains("const r_fix = await pipeline(r_find,"), + "from_step identifier items missing:\n{out}" + ); + // Operator cannot statically check that the prior step returns an + // array — that gap must be marked. + assert!( + out.contains(GAP_MARKER) && out.contains("array"), + "array-ness GAP marker missing:\n{out}" + ); + } + + /// Export with an explicit pipeline environment (projects / glob root). + fn export_with_env(steps_json: &str, env: &PipelineEnv) -> String { + let it = issuetype(steps_json); + let tk = ticket("FEAT-1234", "gamesvc", "Add pagination"); + export_workflow(&tk, &it, None, env).expect("export ok") + } + + const PROJECTS_PIPELINE: &str = r#"[{"name":"sweep","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"projects"}, + "stages":[{"prompt":"Check the project"}]}}]"#; + + #[test] + fn pipeline_projects_emits_sorted_project_list_from_env() { + let env = PipelineEnv { + projects: vec!["svc-b".to_string(), "svc-a".to_string()], + glob_root: None, + }; + let out = export_with_env(PROJECTS_PIPELINE, &env); + // Sorted for determinism regardless of discovery (read_dir) order. + assert!( + out.contains(r#"const r_sweep = await pipeline(["svc-a", "svc-b"],"#), + "sorted project items missing:\n{out}" + ); + } + + #[test] + fn pipeline_projects_without_env_emits_gap_placeholder() { + let out = export_with_env(PROJECTS_PIPELINE, &PipelineEnv::default()); + // No environment (e.g. issuetype preview): runtime-safe empty binding, + // passed as an identifier so the compiled graph shows symbolic width. + assert!( + out.contains(GAP_MARKER) && out.contains("const r_sweep_items = [];"), + "GAP placeholder missing:\n{out}" + ); + assert!( + out.contains("await pipeline(r_sweep_items,"), + "placeholder identifier not passed to pipeline:\n{out}" + ); + } + + #[test] + fn pipeline_glob_expands_relative_to_root_sorted() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("b.md"), "b").unwrap(); + std::fs::write(dir.path().join("a.md"), "a").unwrap(); + std::fs::write(dir.path().join("skip.txt"), "no").unwrap(); + let env = PipelineEnv { + projects: vec![], + glob_root: Some(dir.path().to_path_buf()), + }; + let out = export_with_env( + r#"[{"name":"docs","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"glob","pattern":"*.md"}, + "stages":[{"prompt":"Review the file"}]}}]"#, + &env, + ); + // Matches emitted relative to the glob root (host-absolute paths would + // leak machine state into the artifact), sorted for determinism. + assert!( + out.contains(r#"const r_docs = await pipeline(["a.md", "b.md"],"#), + "glob-expanded items missing:\n{out}" + ); + } + + #[test] + fn pipeline_glob_without_root_emits_gap_placeholder() { + let out = export_with_env( + r#"[{"name":"docs","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"glob","pattern":"*.md"}, + "stages":[{"prompt":"Review"}]}}]"#, + &PipelineEnv::default(), + ); + assert!( + out.contains(GAP_MARKER) && out.contains("await pipeline(r_docs_items,"), + "glob GAP placeholder missing:\n{out}" + ); + } + + #[test] + fn pipeline_field_always_emits_gap_placeholder() { + // Field resolution is deferred: ticket field values are not captured + // at export time (no list FieldType exists yet). + let env = PipelineEnv { + projects: vec!["p".to_string()], + glob_root: None, + }; + let out = export_with_env( + r#"[{"name":"perf","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"field","name":"modules"}, + "stages":[{"prompt":"Profile"}]}}]"#, + &env, + ); + assert!( + out.contains(GAP_MARKER) && out.contains("await pipeline(r_perf_items,"), + "field GAP placeholder missing:\n{out}" + ); + } + + /// Companion to `dumps_all_builtin_previews_for_compiler_check`: no builtin + /// issuetype has a pipeline step yet, so dump a representative pipeline + /// workflow (static items, `from_step` items, two stages) for the + /// naiveworkflow-compiler verification pass. + #[test] + fn dumps_pipeline_sample_for_compiler_check() { + let env = PipelineEnv { + projects: vec!["svc-b".to_string(), "svc-a".to_string()], + glob_root: None, + }; + let out = export_with_env( + r#"[{"name":"find","type":"classifier","outputs":["report"], + "prompt":"List affected projects","next_step":"sweep", + "classifier_config":{"output_type":"big_text"}}, + {"name":"sweep","display_name":"Per-project sweep","type":"pipeline","outputs":["report"], + "prompt":"","next_step":"fix", + "pipeline_config":{ + "item_source":{"type":"projects"}, + "stages":[{"prompt":"Audit the project"},{"prompt":"Summarize findings"}]}}, + {"name":"fix","type":"pipeline","outputs":["code"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"from_step","step":"find"}, + "stages":[{"prompt":"Apply the fix"}]}}]"#, + &env, + ); + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("workflow-previews"); + std::fs::create_dir_all(&dir).expect("create preview dir"); + std::fs::write(dir.join("PIPELINE-sample.workflow.js"), &out).expect("write sample"); + assert!(out.contains("await pipeline("), "sample missing pipeline"); + } + + #[test] + fn pipeline_stage_model_and_schema_opts() { + let out = export( + r#"[{"name":"triage","type":"pipeline","outputs":["report"], + "prompt":"", + "pipeline_config":{ + "item_source":{"type":"static","items":["a"]}, + "stages":[{"prompt":"x","model":"opus", + "jsonSchema":{"type":"object","required":["value"]}}]}}]"#, + ); + assert!( + out.contains(r#"model: "opus""#), + "stage model opt missing:\n{out}" + ); + assert!( + out.contains(r#"schema: {"required":["value"],"type":"object"}"#) + || out.contains(r#"schema: {"type":"object","required":["value"]}"#), + "stage schema opt missing:\n{out}" + ); + } +} diff --git a/src/workflow_gen/step_map.rs b/src/workflow_gen/step_map.rs new file mode 100644 index 00000000..53a30550 --- /dev/null +++ b/src/workflow_gen/step_map.rs @@ -0,0 +1,48 @@ +//! Pure helpers for mapping a `StepSchema` onto a Claude workflow primitive. + +use crate::templates::schema::{ClassifierConfig, ClassifierOutputType}; + +/// Sanitize a step name into a valid JS identifier suffix (`r_`). +pub fn result_var(step_name: &str) -> String { + let cleaned: String = step_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + format!("r_{cleaned}") +} + +/// Synthesize a JSON-schema `serde_json::Value` for a classifier step that has +/// no explicit `json_schema`. The result is an object with a single `value` +/// property typed according to the classifier's output type. +pub fn classifier_schema(cfg: &ClassifierConfig) -> serde_json::Value { + let mut value_schema = serde_json::Map::new(); + value_schema.insert( + "type".to_string(), + serde_json::json!(classifier_value_type(&cfg.output_type)), + ); + if cfg.output_type == ClassifierOutputType::Enum { + if let Some(opts) = &cfg.options { + value_schema.insert("enum".to_string(), serde_json::json!(opts)); + } + } + if cfg.output_type == ClassifierOutputType::ShortString { + if let Some(max) = cfg.max_length { + value_schema.insert("maxLength".to_string(), serde_json::json!(max)); + } + } + serde_json::json!({ + "type": "object", + "properties": { "value": serde_json::Value::Object(value_schema) }, + "required": ["value"] + }) +} + +/// Map a `ClassifierOutputType` to its primitive JSON type name. +pub fn classifier_value_type(output: &ClassifierOutputType) -> &'static str { + match output { + ClassifierOutputType::Boolean => "boolean", + ClassifierOutputType::Number => "number", + ClassifierOutputType::ShortString | ClassifierOutputType::BigText => "string", + ClassifierOutputType::Enum => "string", + } +} diff --git a/tests/acp_integration.rs b/tests/acp_integration.rs new file mode 100644 index 00000000..45d52d25 --- /dev/null +++ b/tests/acp_integration.rs @@ -0,0 +1,334 @@ +//! End-to-end tests: spawn `operator acp` as a subprocess and roundtrip +//! real ACP messages over stdio. +//! +//! Phase A: `initialize` roundtrip. Phase B adds `session/new` and +//! `session/prompt` with `/bin/cat` as a stand-in delegator. + +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +const PER_LINE_TIMEOUT: Duration = Duration::from_secs(5); + +async fn read_line( + reader: &mut tokio::io::Lines>, +) -> String { + tokio::time::timeout(PER_LINE_TIMEOUT, reader.next_line()) + .await + .expect("timeout waiting for stdio response") + .expect("stdio read error") + .expect("eof before response") +} + +#[tokio::test] +async fn test_operator_acp_stdio_initialize_roundtrip() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-integration-test","version":"0.0.0"}}} +"#; + stdin.write_all(request).await.expect("write request"); + stdin.flush().await.expect("flush request"); + + let line = read_line(&mut reader).await; + let response: serde_json::Value = + serde_json::from_str(&line).expect("response should be valid JSON"); + assert_eq!(response["jsonrpc"], "2.0", "response missing jsonrpc=2.0"); + assert_eq!(response["id"], 1, "response id should echo request id"); + let result = response["result"] + .as_object() + .expect("result should be an object"); + assert_eq!( + result["protocolVersion"], 1, + "should echo protocolVersion 1" + ); + assert_eq!( + result["agentInfo"]["name"], "operator", + "agentInfo.name should identify operator: {result:?}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} + +fn write_sleep_delegator_config( + tickets_dir: &std::path::Path, +) -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::TempDir::new().unwrap(); + let config_path = temp.path().join("operator.toml"); + let body = format!( + r#" +[paths] +tickets = "{tickets}" +projects = "." +state = "{tickets}/operator" +worktrees = "/tmp/operator-worktrees" + +[llm_tools] + +[[llm_tools.detected]] +name = "sleeper" +path = "/bin/sleep" +version = "noop" +command_template = "sleep 60" + +[[delegators]] +name = "test-sleeper" +llm_tool = "sleeper" +model = "noop" + +[acp] +default_delegator = "test-sleeper" +"#, + tickets = tickets_dir.display() + ); + std::fs::write(&config_path, body).unwrap(); + (temp, config_path) +} + +/// Write a minimal TOML config that makes `/bin/cat` look like a configured +/// LLM tool + delegator, then return its path (kept alive by the returned +/// `TempDir`). +fn write_cat_delegator_config( + tickets_dir: &std::path::Path, +) -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::TempDir::new().unwrap(); + let config_path = temp.path().join("operator.toml"); + let body = format!( + r#" +[paths] +tickets = "{tickets}" +projects = "." +state = "{tickets}/operator" +worktrees = "/tmp/operator-worktrees" + +[llm_tools] + +[[llm_tools.detected]] +name = "cat" +path = "/bin/cat" +version = "noop" +command_template = "cat {{{{prompt_file}}}}" + +[[delegators]] +name = "test-cat" +llm_tool = "cat" +model = "noop" + +[acp] +default_delegator = "test-cat" +"#, + tickets = tickets_dir.display() + ); + std::fs::write(&config_path, body).unwrap(); + (temp, config_path) +} + +#[tokio::test] +async fn test_operator_acp_session_new_and_prompt_with_cat_delegator() { + let exe = env!("CARGO_BIN_EXE_operator"); + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let (_config_keep, config_path) = write_cat_delegator_config(tickets.path()); + + let mut child = Command::new(exe) + .arg("--config") + .arg(&config_path) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp with cat-delegator config"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + // 1. initialize + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-prompt-test","version":"0.0.0"}}}"#; + stdin.write_all(init).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let init_line = read_line(&mut reader).await; + let init_resp: serde_json::Value = serde_json::from_str(&init_line).unwrap(); + assert_eq!(init_resp["id"], 1, "initialize id mismatch: {init_resp}"); + + // 2. session/new with our cwd + let new_session = format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":"{}","mcpServers":[]}}}}"#, + canonical_cwd.display() + ); + stdin.write_all(new_session.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let new_line = read_line(&mut reader).await; + let new_resp: serde_json::Value = serde_json::from_str(&new_line).unwrap(); + assert_eq!(new_resp["id"], 2, "session/new id mismatch: {new_resp}"); + let session_id = new_resp["result"]["sessionId"] + .as_str() + .expect("sessionId in response: {new_resp}") + .to_string(); + assert!(!session_id.is_empty(), "sessionId must be non-empty"); + + // 3. session/prompt with text "hello" + let prompt = format!( + r#"{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{session_id}","prompt":[{{"type":"text","text":"hello"}}]}}}}"# + ); + stdin.write_all(prompt.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + // Read lines until we see (a) a session/update notification containing + // "hello" and (b) the session/prompt response with id=3. Streaming + // ordering isn't strict: assert both observed within ~10s budget. + let mut saw_hello_update = false; + let mut prompt_response: Option = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + let line = match tokio::time::timeout(Duration::from_secs(5), reader.next_line()).await { + Ok(Ok(Some(l))) => l, + Ok(Ok(None)) => break, + _ => break, + }; + let msg: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + if msg["method"] == "session/update" { + let text = msg["params"]["update"]["content"]["text"] + .as_str() + .unwrap_or(""); + if text.contains("hello") { + saw_hello_update = true; + } + } else if msg["id"] == 3 { + prompt_response = Some(msg); + break; + } + } + + let resp = prompt_response.expect("session/prompt response must arrive"); + assert!( + saw_hello_update, + "expected at least one session/update containing 'hello'; final response: {resp}" + ); + assert_eq!( + resp["result"]["stopReason"], "end_turn", + "cat exits 0, expected stopReason=end_turn: {resp}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} + +#[tokio::test] +async fn test_cancel_kills_delegator() { + let exe = env!("CARGO_BIN_EXE_operator"); + let tickets = tempfile::TempDir::new().unwrap(); + let cwd = tempfile::TempDir::new().unwrap(); + let canonical_cwd = std::fs::canonicalize(cwd.path()).unwrap(); + let (_config_keep, config_path) = write_sleep_delegator_config(tickets.path()); + + let mut child = Command::new(exe) + .arg("--config") + .arg(&config_path) + .arg("acp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator acp with sleep-delegator config"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + // 1. initialize + let init = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"acp-cancel-test","version":"0.0.0"}}}"#; + stdin.write_all(init).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let init_line = read_line(&mut reader).await; + let init_resp: serde_json::Value = serde_json::from_str(&init_line).unwrap(); + assert_eq!(init_resp["id"], 1); + + // 2. session/new + let new_session = format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"session/new","params":{{"cwd":"{}","mcpServers":[]}}}}"#, + canonical_cwd.display() + ); + stdin.write_all(new_session.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + let new_line = read_line(&mut reader).await; + let new_resp: serde_json::Value = serde_json::from_str(&new_line).unwrap(); + assert_eq!(new_resp["id"], 2); + let session_id = new_resp["result"]["sessionId"] + .as_str() + .expect("sessionId") + .to_string(); + + // 3. session/prompt (delegator runs `sleep 60` — a long-running process) + let prompt = format!( + r#"{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{session_id}","prompt":[{{"type":"text","text":"ignored"}}]}}}}"# + ); + stdin.write_all(prompt.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + // Give the delegator a moment to start + tokio::time::sleep(Duration::from_millis(500)).await; + + // 4. Send cancel notification (no id — it's a notification) + let cancel = format!( + r#"{{"jsonrpc":"2.0","method":"session/cancel","params":{{"sessionId":"{session_id}"}}}}"# + ); + stdin.write_all(cancel.as_bytes()).await.unwrap(); + stdin.write_all(b"\n").await.unwrap(); + stdin.flush().await.unwrap(); + + // 5. The prompt response should arrive quickly with stopReason "cancelled" + let mut prompt_response: Option = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while tokio::time::Instant::now() < deadline { + let line = match tokio::time::timeout(Duration::from_secs(3), reader.next_line()).await { + Ok(Ok(Some(l))) => l, + Ok(Ok(None)) => break, + _ => break, + }; + let msg: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + if msg["id"] == 3 { + prompt_response = Some(msg); + break; + } + } + + let resp = prompt_response.expect("session/prompt response must arrive after cancel"); + assert_eq!( + resp["result"]["stopReason"], "cancelled", + "cancel should yield stopReason=cancelled: {resp}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index 57e5d6b8..c1074b61 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -96,33 +96,28 @@ fn test_vscode_extension_has_all_core_operations() { } } -/// Test that REST API routes are registered for all Core Operations +/// Test that REST API routes are registered for all Core Operations. +/// +/// Asserts against the generated OpenAPI spec rather than grepping the router +/// source: since the router was migrated to `utoipa_axum::OpenApiRouter`, +/// mounting a route *is* registering it in the spec, so the spec is the +/// authoritative list of live routes. Path params use OpenAPI `{param}` syntax. #[test] fn test_api_routes_are_registered() { - // Read mod.rs to verify routes are registered - let mod_rs = include_str!("../src/rest/mod.rs"); - - // Check that pause/resume/sync endpoints are registered - assert!( - mod_rs.contains("/api/v1/queue/pause"), - "REST API should have /api/v1/queue/pause route" - ); - assert!( - mod_rs.contains("/api/v1/queue/resume"), - "REST API should have /api/v1/queue/resume route" - ); - assert!( - mod_rs.contains("/api/v1/queue/sync"), - "REST API should have /api/v1/queue/sync route" - ); - assert!( - mod_rs.contains("/api/v1/agents/:agent_id/approve"), - "REST API should have /api/v1/agents/:agent_id/approve route" - ); - assert!( - mod_rs.contains("/api/v1/agents/:agent_id/reject"), - "REST API should have /api/v1/agents/:agent_id/reject route" - ); + let spec = operator::rest::ApiDoc::json().expect("generate OpenAPI spec"); + + for route in [ + "/api/v1/queue/pause", + "/api/v1/queue/resume", + "/api/v1/queue/sync", + "/api/v1/agents/{agent_id}/approve", + "/api/v1/agents/{agent_id}/reject", + ] { + assert!( + spec.contains(route), + "REST API OpenAPI spec should document the {route} route" + ); + } } /// Test that all Core Operations are documented in session management docs diff --git a/tests/mcp_stdio_integration.rs b/tests/mcp_stdio_integration.rs new file mode 100644 index 00000000..bccd9cc1 --- /dev/null +++ b/tests/mcp_stdio_integration.rs @@ -0,0 +1,68 @@ +//! End-to-end test: spawn `operator mcp` as a subprocess and roundtrip +//! a real JSON-RPC handshake over stdio. + +use std::process::Stdio; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +#[tokio::test] +async fn test_operator_mcp_stdio_initialize_and_list_tools() { + let exe = env!("CARGO_BIN_EXE_operator"); + let mut child = Command::new(exe) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn operator mcp"); + + let mut stdin = child.stdin.take().expect("take stdin"); + let stdout = child.stdout.take().expect("take stdout"); + let mut reader = BufReader::new(stdout).lines(); + + stdin + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n") + .await + .unwrap(); + stdin + .write_all(b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n") + .await + .unwrap(); + stdin.flush().await.unwrap(); + + let line1 = tokio::time::timeout(Duration::from_secs(5), reader.next_line()) + .await + .expect("timeout waiting for initialize response") + .expect("read err") + .expect("eof"); + let resp1: serde_json::Value = serde_json::from_str(&line1).unwrap(); + assert_eq!(resp1["id"], 1); + assert_eq!(resp1["result"]["serverInfo"]["name"], "operator"); + assert!( + resp1["result"]["capabilities"]["resources"].is_object(), + "resources capability should be advertised" + ); + + let line2 = tokio::time::timeout(Duration::from_secs(5), reader.next_line()) + .await + .expect("timeout waiting for tools/list response") + .expect("read err") + .expect("eof"); + let resp2: serde_json::Value = serde_json::from_str(&line2).unwrap(); + assert_eq!(resp2["id"], 2); + let tools = resp2["result"]["tools"].as_array().expect("tools array"); + assert!( + tools.len() >= 8, + "expected at least 8 read tools, got {}", + tools.len() + ); + let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert!( + names.contains(&"operator_list_tickets"), + "operator_list_tickets should be in tool list, got: {names:?}" + ); + + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; +} diff --git a/tests/surface_parity.rs b/tests/surface_parity.rs new file mode 100644 index 00000000..cd4b5d57 --- /dev/null +++ b/tests/surface_parity.rs @@ -0,0 +1,269 @@ +//! Surface Parity Integration Tests +//! +//! Validates that operator capabilities stay aligned across all integration +//! surfaces: slash commands (Zed), MCP tools, REST routes, and TUI keybindings. +//! +//! Uses `include_str!` to scan source files (same pattern as `feature_parity_test.rs`) +//! and the capability inventory from `src/integrations/inventory.rs`. + +use operator::integrations::all_capabilities; + +// ============================================================================ +// Source files scanned via include_str! +// ============================================================================ + +const EXTENSION_TOML: &str = include_str!("../zed-extension/extension.toml"); +const MCP_TOOLS_RS: &str = include_str!("../src/mcp/tools.rs"); +const KEYBINDINGS_RS: &str = include_str!("../src/ui/keybindings.rs"); + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Extract keybinding description strings from keybindings.rs source. +fn get_keybinding_descriptions() -> Vec { + let mut descriptions = Vec::new(); + for line in KEYBINDINGS_RS.lines() { + if let Some(start) = line.find("description:") { + if let Some(quote_start) = line[start..].find('"') { + let after_quote = &line[start + quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + descriptions.push(after_quote[..quote_end].to_string()); + } + } + } + } + descriptions +} + +/// Extract the path portion from a "METHOD /path" REST endpoint string. +fn rest_path(endpoint: &str) -> &str { + endpoint.split_whitespace().last().unwrap_or("") +} + +/// Convert axum-style `:param` path segments to OpenAPI `{param}` syntax so +/// inventory endpoints (written `:id`) match the generated spec (`{id}`). +fn colon_to_curly(path: &str) -> String { + path.split('/') + .map(|seg| { + seg.strip_prefix(':') + .map_or_else(|| seg.to_string(), |name| format!("{{{name}}}")) + }) + .collect::>() + .join("/") +} + +/// The generated OpenAPI spec — the authoritative list of mounted REST routes +/// since the router was migrated to `utoipa_axum::OpenApiRouter`. +fn rest_spec() -> String { + operator::rest::ApiDoc::json().expect("generate OpenAPI spec") +} + +// ============================================================================ +// Per-surface validation +// ============================================================================ + +/// Every capability with a `slash_command` must appear in extension.toml +#[test] +fn test_slash_commands_present_in_extension_toml() { + for cap in all_capabilities() { + if let Some(cmd) = cap.slash_command { + assert!( + EXTENSION_TOML.contains(cmd), + "Capability '{}': slash command '{}' not found in zed-extension/extension.toml", + cap.name, + cmd + ); + } + } +} + +/// Every capability with an `mcp_tool` must appear in tools.rs +#[test] +fn test_mcp_tools_present_in_tools_rs() { + for cap in all_capabilities() { + if let Some(tool) = cap.mcp_tool { + assert!( + MCP_TOOLS_RS.contains(tool), + "Capability '{}': MCP tool '{}' not found in src/mcp/tools.rs", + cap.name, + tool + ); + } + } +} + +/// Every capability with a `rest_endpoint` must appear in the generated OpenAPI +/// spec (the source of truth for mounted routes post-`OpenApiRouter` migration). +#[test] +fn test_rest_endpoints_present_in_rest_mod() { + let spec = rest_spec(); + for cap in all_capabilities() { + if let Some(endpoint) = cap.rest_endpoint { + let path = colon_to_curly(rest_path(endpoint)); + assert!( + spec.contains(&path), + "Capability '{}': REST path '{}' not found in the OpenAPI spec", + cap.name, + path + ); + } + } +} + +/// Every capability with a `tui_action` must match a keybinding description +#[test] +fn test_tui_actions_present_in_keybindings() { + let descriptions = get_keybinding_descriptions(); + for cap in all_capabilities() { + if let Some(action) = cap.tui_action { + let found = descriptions + .iter() + .any(|d| d.to_lowercase().contains(&action.to_lowercase())); + assert!( + found, + "Capability '{}': TUI action '{}' not found in keybinding descriptions.\nAvailable: {:?}", + cap.name, action, descriptions + ); + } + } +} + +// ============================================================================ +// Cross-surface coverage +// ============================================================================ + +/// Every slash command in extension.toml should map to at least one capability +#[test] +fn test_no_orphan_slash_commands() { + let caps = all_capabilities(); + let known_commands: Vec<&str> = caps.iter().filter_map(|c| c.slash_command).collect(); + + // Extract slash command keys from extension.toml + for line in EXTENSION_TOML.lines() { + let trimmed = line.trim(); + // Slash command lines look like: op-status = { description = "..." } + if trimmed.starts_with("op-") { + if let Some(key) = trimmed.split('=').next() { + let key = key.trim(); + assert!( + known_commands.contains(&key), + "Slash command '{key}' in extension.toml has no matching capability in inventory" + ); + } + } + } +} + +/// No MCP tool name referenced in inventory should be missing from tools.rs +/// (redundant with per-capability test, but guards against typos in the inventory) +#[test] +fn test_inventory_mcp_tools_are_real() { + for cap in all_capabilities() { + if let Some(tool) = cap.mcp_tool { + // Check that the tool name appears as a string literal in tools.rs + let quoted = format!("\"{tool}\""); + assert!( + MCP_TOOLS_RS.contains("ed), + "Capability '{}': MCP tool '{}' does not appear as a quoted string in tools.rs (possible typo)", + cap.name, + tool + ); + } + } +} + +// ============================================================================ +// Schema validation +// ============================================================================ + +/// Validate extension.toml parses as a valid Zed extension manifest. +/// Struct mirrors Zed's actual schema: metadata at top level, not nested +/// under `[extension]`. Catches missing required fields before Zed does. +#[test] +fn test_extension_toml_schema_valid() { + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct ExtensionToml { + id: String, + name: String, + version: String, + schema_version: u32, + #[serde(default)] + slash_commands: std::collections::HashMap, + #[serde(default)] + context_servers: std::collections::HashMap, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct SlashCommandEntry { + description: String, + requires_argument: bool, + } + + let parsed: ExtensionToml = + toml::from_str(EXTENSION_TOML).expect("extension.toml must parse as valid Zed manifest"); + + assert!(!parsed.id.is_empty()); + assert!(parsed.schema_version >= 1); + + for (name, entry) in &parsed.slash_commands { + assert!( + !entry.description.is_empty(), + "Slash command '{name}' has empty description" + ); + } +} + +// ============================================================================ +// Summary +// ============================================================================ + +/// Print a human-readable surface parity matrix +#[test] +fn test_surface_parity_summary() { + let descriptions = get_keybinding_descriptions(); + let spec = rest_spec(); + + println!("\n=== Surface Parity Matrix ===\n"); + println!( + "{:<22} | {:<7} | {:<7} | {:<7} | {:<7}", + "Capability", "Slash", "MCP", "REST", "TUI" + ); + println!( + "{:-<22}-+-{:-<7}-+-{:-<7}-+-{:-<7}-+-{:-<7}", + "", "", "", "", "" + ); + + for cap in all_capabilities() { + let has_slash = cap + .slash_command + .is_some_and(|cmd| EXTENSION_TOML.contains(cmd)); + let has_mcp = cap.mcp_tool.is_some_and(|tool| MCP_TOOLS_RS.contains(tool)); + let has_rest = cap + .rest_endpoint + .is_some_and(|ep| spec.contains(&colon_to_curly(rest_path(ep)))); + let has_tui = cap.tui_action.is_some_and(|action| { + descriptions + .iter() + .any(|d| d.to_lowercase().contains(&action.to_lowercase())) + }); + + let mark = |claimed: bool, present: bool| match (claimed, present) { + (true, true) => "Y", + (true, false) => "MISS", + (false, _) => "-", + }; + + println!( + "{:<22} | {:<7} | {:<7} | {:<7} | {:<7}", + cap.name, + mark(cap.slash_command.is_some(), has_slash), + mark(cap.mcp_tool.is_some(), has_mcp), + mark(cap.rest_endpoint.is_some(), has_rest), + mark(cap.tui_action.is_some(), has_tui), + ); + } + println!(); +} diff --git a/tests/ui_packaging.rs b/tests/ui_packaging.rs new file mode 100644 index 00000000..84dfc316 --- /dev/null +++ b/tests/ui_packaging.rs @@ -0,0 +1,132 @@ +//! Integration tests enforcing strict packaging constraints on the embedded UI. +//! +//! These tests run without the `embed-ui` feature — they validate the source +//! artifacts (package.json, dist directory) directly from the filesystem. + +use std::path::Path; + +const ALLOWED_RUNTIME_DEPS: &[&str] = &[ + "react", + "react-dom", + "react-router-dom", + "@dnd-kit/core", + "@dnd-kit/sortable", + "@dnd-kit/utilities", + // Codicon webfont for the sidebar/page icon vocabulary. Zero-dependency, + // Microsoft-maintained; the concept→icon mapping lives in ui/src/concepts.ts + // and the canonical table at docs/design-system/. + "@vscode/codicons", + // Workflow-graph visualization. The two @untra packages are first-party + // (same author as operator); the compiler parses workflow .js via acorn + // (never executes it) and the react package renders the IR with React Flow. + // Used by ui/src/components/WorkflowGraphView.tsx. + "@untra/naiveworkflow-compiler", + "@untra/naiveworkflow-react", + // React Flow — peer dependency of @untra/naiveworkflow-react; the graph + // renderer/canvas. (dagre, its layout engine, comes in transitively.) + "@xyflow/react", +]; + +const UNCOMPRESSED_BUDGET_BYTES: u64 = 10_485_760; // 10MB + +#[test] +fn test_ui_package_json_dep_allowlist() { + let pkg_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/package.json"); + assert!(pkg_path.exists(), "ui/package.json must exist"); + let content = std::fs::read_to_string(&pkg_path).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + + if let Some(deps) = pkg.get("dependencies").and_then(|d| d.as_object()) { + for dep_name in deps.keys() { + assert!( + ALLOWED_RUNTIME_DEPS.contains(&dep_name.as_str()), + "Unauthorized runtime dependency '{dep_name}' in ui/package.json. \ + Allowed: {ALLOWED_RUNTIME_DEPS:?}. Add to ALLOWED_RUNTIME_DEPS in tests/ui_packaging.rs if intentional.", + ); + } + } +} + +#[test] +fn test_ui_package_json_no_css_in_js() { + let pkg_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/package.json"); + if !pkg_path.exists() { + return; + } + let content = std::fs::read_to_string(&pkg_path).unwrap(); + + let banned = [ + "styled-components", + "@emotion/react", + "@emotion/styled", + "tailwindcss", + "@mui/material", + "chakra-ui", + ]; + for lib in &banned { + assert!( + !content.contains(lib), + "Banned CSS-in-JS / heavy UI library '{lib}' found in ui/package.json", + ); + } +} + +#[test] +fn test_ui_dist_size_budget_uncompressed() { + let dist_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/dist"); + if !dist_path.exists() || !dist_path.join("index.html").exists() { + return; // Not built yet — skip + } + + let total = walk_dir_size(&dist_path); + assert!( + total < UNCOMPRESSED_BUDGET_BYTES, + "ui/dist/ is {}B ({:.1}MB) uncompressed — exceeds 5MB budget", + total, + total as f64 / 1_048_576.0 + ); +} + +#[test] +fn test_ui_dist_no_source_maps_in_production() { + let dist_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/dist"); + if !dist_path.exists() { + return; + } + + let map_files = find_files_with_extension(&dist_path, "map"); + assert!( + map_files.is_empty(), + "Source maps found in ui/dist/ — these should not ship in the embedded binary: {map_files:?}", + ); +} + +fn walk_dir_size(dir: &Path) -> u64 { + let mut total = 0u64; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + total += walk_dir_size(&path); + } else if let Ok(meta) = path.metadata() { + total += meta.len(); + } + } + } + total +} + +fn find_files_with_extension(dir: &Path, ext: &str) -> Vec { + let mut results = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + results.extend(find_files_with_extension(&path, ext)); + } else if path.extension().is_some_and(|e| e == ext) { + results.push(path.display().to_string()); + } + } + } + results +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..1360a556 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,76 @@ +# operator/ui + +The embedded web UI for Operator — a [Vite](https://vite.dev) + React 19 single-page +app that talks to the operator REST API (`/api/v1/*`). It is one of Operator's **four +rendering surfaces** (alongside the Ratatui TUI, the Jekyll docs site, and the VS Code +webview); see the root `CLAUDE.md` "Design & UI Consistency" section for how they stay +consistent. + +At runtime this SPA is compiled and **baked into the Rust binary** — there is no separate +web server to deploy. The TUI opens it in a browser (or the VS Code extension hosts it in a +webview). + +## Toolchain (bun) + +This package uses [bun](https://bun.sh) (`bun.lock`). The npm scripts wrap Vite/tsc: + +```bash +bun install # install dependencies +bun run dev # Vite dev server on http://127.0.0.1:5173 + # proxies /api and /swagger-ui → http://127.0.0.1:7008 + # (run the operator REST server separately: cargo run -- serve) +bun run build # production build → ui/dist +bun run typecheck # tsc --noEmit +bun run preview # preview the production build +``` + +For local development run the operator REST server on its default port (7008) so the dev +server's `/api` proxy has something to talk to. + +## How it embeds into the Rust binary + +`src/rest/web_ui.rs` uses [`rust-embed`](https://crates.io/crates/rust-embed) with +`#[folder = "ui/dist"]`, gated behind the **`embed-ui`** cargo feature. So: + +1. `bun run build` writes the hashed assets (including the codicon `.ttf`) into `ui/dist`. +2. `cargo build --features embed-ui` embeds that directory into the binary; the SPA is then + served offline by `spa_handler`, with `index.html` as the client-side-routing fallback. + +If `ui/dist` wasn't built, `build.rs` writes a placeholder so the TUI can show an actionable +message instead of a blank page. There are size-budget tests in `web_ui.rs` (10 MB gzipped / +15 MB uncompressed) — keep new assets well under them. + +## Routing model + +[`HashRouter`](src/main.tsx) (`#/path`), because the app is served from a `file:`-style +embedded context. Two kinds of routes: + +- **Status sections** — one route per concept in the `SectionId` model shared with the TUI + and VS Code extension (`src/ui/status_panel.rs`): `#/config`, `#/connections`, `#/kanban`, + `#/llm`, `#/model-servers`, `#/git`, `#/issuetypes`, `#/delegators`, `#/projects`. Each + renders the live status of that section from `GET /api/v1/sections`. `#/status` is the + "all sections" overview (reachable from the Dashboard). +- **Web-only pages** — `#/` (Dashboard) and `#/queue`, which have no section analog. + +The sidebar (`src/Layout.tsx`) reflects each section's health and gates not-yet-available +sections (disabled with a tooltip naming the unmet prerequisites). Section data is polled +once by `SectionsProvider` (`src/sections-context.tsx`) and shared by the sidebar and pages. + +## Styling + +Brand colors come from the single shared source of truth, +[`docs/assets/css/tokens.css`](../docs/assets/css/tokens.css), imported by +[`src/index.css`](src/index.css). On top of that palette `index.css` layers app-only +**semantic tokens** (`--surface`, `--border`, `--text`, `--danger`, `--warning`, `--success`, +radii, fonts) with light/dark variants. Components use **CSS Modules** (`*.module.css`) and +reference semantic tokens — never raw hex (per `CLAUDE.md`). + +## Icons + +Sidebar and page icons use [`@vscode/codicons`](https://github.com/microsoft/vscode-codicons) +(MIT-licensed code, **CC-BY-4.0 icons**), the same vocabulary as the VS Code extension. The +font is imported once in `src/main.tsx`; Vite fingerprints `codicon.ttf` into `dist/assets`, +so it stays embedded and offline. The concept→icon mapping lives in +[`src/concepts.ts`](src/concepts.ts) and follows the **canonical table** documented at +[`/design-system/`](../docs/design-system/index.md) — the single place to consult or update +when giving an operator concept an icon. diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 00000000..27e6df65 --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,328 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@operator/ui", + "dependencies": { + "@untra/naiveworkflow-compiler": "0.0.3", + "@untra/naiveworkflow-react": "0.0.3", + "@vscode/codicons": "^0.0.45", + "@xyflow/react": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.6.1", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@untra/naiveworkflow-compiler": ["@untra/naiveworkflow-compiler@0.0.3", "", { "dependencies": { "acorn": "^8.14.0", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.1" } }, "sha512-1XbbQVzh4fjGiY4dAowXQukaPLk3HucLZ/7cwb16KPTG53+jmKCMmjNHWo3M5u/COOS23l0p2aJhDrHCVtAR+w=="], + + "@untra/naiveworkflow-react": ["@untra/naiveworkflow-react@0.0.3", "", { "dependencies": { "@untra/naiveworkflow-compiler": "0.0.3", "dagre": "^0.8.5" }, "peerDependencies": { "@xyflow/react": "^12.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-AL5BFgmbTPf/S79pMeTO/hbDi4VaC1uqa/mbDpdTcIaR99v3RFxhETg4TknRSa4n8I6Bif+wEubZjwUBSAjTrA=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@vscode/codicons": ["@vscode/codicons@0.0.45", "", {}, "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw=="], + + "@xyflow/react": ["@xyflow/react@12.11.0", "", { "dependencies": { "@xyflow/system": "0.0.77", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA=="], + + "@xyflow/system": ["@xyflow/system@0.0.77", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre": ["dagre@0.8.5", "", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], + + "react-router-dom": ["react-router-dom@7.15.1", "", { "dependencies": { "react-router": "7.15.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..4ef4cf6b --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Operator + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 00000000..d46ecb04 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2091 @@ +{ + "name": "@operator/ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@operator/ui", + "version": "0.0.0", + "dependencies": { + "@untra/naiveworkflow-compiler": "0.0.3", + "@untra/naiveworkflow-react": "0.0.3", + "@vscode/codicons": "^0.0.45", + "@xyflow/react": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.6.1" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@untra/naiveworkflow-compiler": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@untra/naiveworkflow-compiler/-/naiveworkflow-compiler-0.0.3.tgz", + "integrity": "sha512-1XbbQVzh4fjGiY4dAowXQukaPLk3HucLZ/7cwb16KPTG53+jmKCMmjNHWo3M5u/COOS23l0p2aJhDrHCVtAR+w==", + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.1" + } + }, + "node_modules/@untra/naiveworkflow-react": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@untra/naiveworkflow-react/-/naiveworkflow-react-0.0.3.tgz", + "integrity": "sha512-AL5BFgmbTPf/S79pMeTO/hbDi4VaC1uqa/mbDpdTcIaR99v3RFxhETg4TknRSa4n8I6Bif+wEubZjwUBSAjTrA==", + "license": "MIT", + "dependencies": { + "@untra/naiveworkflow-compiler": "0.0.3", + "dagre": "^0.8.5" + }, + "peerDependencies": { + "@xyflow/react": "^12.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vscode/codicons": { + "version": "0.0.45", + "license": "CC-BY-4.0" + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "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==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..259773ed --- /dev/null +++ b/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@operator/ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@untra/naiveworkflow-compiler": "0.0.3", + "@untra/naiveworkflow-react": "0.0.3", + "@vscode/codicons": "^0.0.45", + "@xyflow/react": "^12.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.6.1" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/ui/src/Layout.module.css b/ui/src/Layout.module.css new file mode 100644 index 00000000..415dca2c --- /dev/null +++ b/ui/src/Layout.module.css @@ -0,0 +1,210 @@ +.layout { + display: flex; + min-height: 100vh; + font-family: var(--font-sans); + color: var(--text); + background: var(--color-bg); +} + +.nav { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--color-cream); + border-right: 1px solid var(--border); + padding: 24px 16px; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.brandRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; +} + +.brand { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-salmon); + letter-spacing: 0.02em; + text-decoration: none; +} + +.themeToggle { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--color-green-l1); + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 4px; + transition: color 0.2s; +} + +.themeToggle:hover { + color: var(--color-green-l2); +} + +.group { + margin-bottom: 20px; +} + +.groupLabel { + margin: 0 0 8px; + padding: 0 4px; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-cornflower); +} + +.navList { + list-style: none; + margin: 0; + padding: 0; +} + +.navList li { + margin-bottom: 6px; +} + +.navLink { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-green-l1); + color: var(--color-cream); + text-decoration: none; + font-size: 0.875rem; + border-radius: var(--radius); + transition: background-color 0.2s, color 0.2s; +} + +.navLink:hover { + background: var(--color-green-l2); + color: var(--color-cream); +} + +.active { + background: var(--color-green-l3); + color: var(--color-cream); +} + +.navIcon { + font-size: 1rem; + line-height: 1; + flex-shrink: 0; +} + +.navLabel { + flex: 1; +} + +/* Health badge for a status section. A cream ring keeps every health color + * legible against the green nav pill. Maps a semantic role to a token. */ +.navDot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + background: var(--color-cream); + box-shadow: 0 0 0 1px var(--color-cream); +} + +.navDot[data-health='green'] { + background: var(--color-green-l3); +} + +/* On the active pill (bg green-l3) a green-l3 dot would vanish — lighten it so + * the healthy dot stays a filled mark, not just the cream ring. */ +.active .navDot[data-health='green'] { + background: var(--color-green-l1); +} +.navDot[data-health='yellow'] { + background: var(--warning); +} +.navDot[data-health='red'] { + background: var(--danger); +} +.navDot[data-health='gray'] { + background: var(--text-muted); +} + +/* Section whose prerequisites aren't met: shown but not navigable. */ +.navDisabled, +.navDisabled:hover { + background: transparent; + color: var(--text-muted); + cursor: not-allowed; +} + +.navDisabled .navDot { + box-shadow: 0 0 0 1px var(--surface-alt); +} + +.main { + flex: 1; + padding: 48px; + overflow-y: auto; +} + +/* Right-hand detail sidepanel (see right-panel.tsx). Fixed width, scrolls + * independently of the main view, divided from it by a left border. Minimal by + * design — content components own their own styling. */ +.rightPanel { + width: 380px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface); + border-left: 1px solid var(--border); + overflow-y: auto; +} + +.rightPanelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--surface); +} + +.rightPanelTitle { + font-weight: 700; + font-size: 0.9375rem; + color: var(--text); +} + +.rightPanelClose { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 4px; + border-radius: var(--radius-sm); +} + +.rightPanelClose:hover { + color: var(--text); + background: var(--surface-alt); +} + +.rightPanelBody { + padding: 20px; +} diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx new file mode 100644 index 00000000..a2b02ba7 --- /dev/null +++ b/ui/src/Layout.tsx @@ -0,0 +1,131 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import styles from './Layout.module.css'; +import { useTheme } from './theme'; +import type { Concept } from './concepts'; +import { CONCEPTS, STATUS_KEYS, PAGE_KEYS } from './concepts'; +import { ConceptIcon } from './components/ConceptIcon'; +import { SectionsProvider, useSections } from './sections-context'; +import { RightPanelProvider, useRightPanel } from './right-panel'; +import type { SectionDto } from './api-client'; + +// The "Status" group mirrors the canonical section order shared with the TUI and +// VS Code extension (the SectionId enum in src/ui/status_panel.rs) and reflects +// each section's live health from GET /api/v1/sections. A section whose +// prerequisites aren't met yet is shown disabled with a tooltip naming what it +// needs — the user sees it exists and why it isn't reachable. "Pages" are +// web-only views (Dashboard, Queue) with no section analog. + +function NavRow({ concept, section }: { concept: Concept; section?: SectionDto }) { + const met = section ? section.met : true; + + const inner = ( + <> + + {concept.label} + {section && } + + ); + + if (!met) { + const needs = (section?.prerequisites ?? []) + .map((id) => CONCEPTS[id]?.label ?? id) + .join(', '); + return ( + + {inner} + + ); + } + + return ( + (isActive ? `${styles.navLink} ${styles.active}` : styles.navLink)} + > + {inner} + + ); +} + +function NavGroup({ label, keys }: { label: string; keys: readonly string[] }) { + const { sections } = useSections(); + return ( +
+

{label}

+
    + {keys.map((key) => { + const concept = CONCEPTS[key]; + const section = sections?.find((s) => s.id === key); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +// The detail sidepanel. Renders nothing until a view opens it via +// useRightPanel().open(...); when content is present it slides in on the right +// with a header (title + close) above the caller-supplied node. +function RightPanel() { + const { content, title, close } = useRightPanel(); + if (!content) return null; + return ( + + ); +} + +export function Layout() { + const { theme, toggleTheme } = useTheme(); + + return ( + + +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts new file mode 100644 index 00000000..b8dca6cc --- /dev/null +++ b/ui/src/api-client.ts @@ -0,0 +1,244 @@ +import type { Host } from './host'; +import type { HealthResponse } from '@operator/bindings/HealthResponse'; +import type { StatusResponse } from '@operator/bindings/StatusResponse'; +import type { SectionDto } from '@operator/bindings/SectionDto'; +import type { SectionRowDto } from '@operator/bindings/SectionRowDto'; +import type { QueueStatusResponse } from '@operator/bindings/QueueStatusResponse'; +import type { KanbanBoardResponse } from '@operator/bindings/KanbanBoardResponse'; +import type { KanbanTicketCard } from '@operator/bindings/KanbanTicketCard'; +import type { ActiveAgentsResponse } from '@operator/bindings/ActiveAgentsResponse'; +import type { IssueTypeSummary } from '@operator/bindings/IssueTypeSummary'; +import type { IssueTypeResponse } from '@operator/bindings/IssueTypeResponse'; +import type { CollectionResponse } from '@operator/bindings/CollectionResponse'; +import type { ProjectSummary } from '@operator/bindings/ProjectSummary'; +import type { CompletedTicket } from '@operator/bindings/CompletedTicket'; +import type { CreateIssueTypeRequest } from '@operator/bindings/CreateIssueTypeRequest'; +import type { UpdateIssueTypeRequest } from '@operator/bindings/UpdateIssueTypeRequest'; +import type { LaunchTicketRequest } from '@operator/bindings/LaunchTicketRequest'; +import type { LaunchTicketResponse } from '@operator/bindings/LaunchTicketResponse'; +import type { QueueControlResponse } from '@operator/bindings/QueueControlResponse'; +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'; + +export type { + HealthResponse, + StatusResponse, + SectionDto, + SectionRowDto, + QueueStatusResponse, + KanbanBoardResponse, + KanbanTicketCard, + ActiveAgentsResponse, + IssueTypeSummary, + IssueTypeResponse, + CollectionResponse, + ProjectSummary, + CompletedTicket, + CreateIssueTypeRequest, + UpdateIssueTypeRequest, + LaunchTicketRequest, + LaunchTicketResponse, + QueueControlResponse, + Config, + AgentDetailResponse, + WorkflowExportResponse, + WorkflowPreviewResponse, +}; + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(base: string, path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, init); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); + throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); + } + return res.json() as Promise; +} + +async function requestVoid(base: string, path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, init); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: `HTTP ${res.status}` })); + throw new ApiError(res.status, body.message ?? body.error ?? `HTTP ${res.status}`); + } +} + +export class OperatorApi { + private base: string; + + constructor(host: Host) { + this.base = host.baseUrl(); + } + + // --- Health --- + + health(): Promise { + return request(this.base, '/api/v1/health'); + } + + status(): Promise { + return request(this.base, '/api/v1/status'); + } + + // --- Status sections (canonical, shared with TUI / VS Code) --- + + sections(): Promise { + return request(this.base, '/api/v1/sections'); + } + + // --- Queue --- + + queueStatus(): Promise { + return request(this.base, '/api/v1/queue/status'); + } + + kanban(): Promise { + return request(this.base, '/api/v1/queue/kanban'); + } + + pauseQueue(): Promise { + return request(this.base, '/api/v1/queue/pause', { method: 'POST' }); + } + + resumeQueue(): Promise { + return request(this.base, '/api/v1/queue/resume', { method: 'POST' }); + } + + syncKanban(): Promise { + return requestVoid(this.base, '/api/v1/queue/sync', { method: 'POST' }); + } + + // --- Agents --- + + activeAgents(): Promise { + return request(this.base, '/api/v1/agents/active'); + } + + getAgent(agentId: string): Promise { + return request(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}`); + } + + approveReview(agentId: string): Promise { + return requestVoid(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}/approve`, { + method: 'POST', + }); + } + + rejectReview(agentId: string, reason: string): Promise { + return requestVoid(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason }), + }); + } + + /** + * Focus the agent's terminal session in its session wrapper. Used for cmux + * launches: operator (running inside cmux) shells out to `cmux focus-workspace`. + * cmux has no browser URL scheme, so the control plane is the bridge. + */ + focusSession(agentId: string): Promise { + return requestVoid(this.base, `/api/v1/agents/${encodeURIComponent(agentId)}/focus`, { + method: 'POST', + }); + } + + // --- Tickets --- + + launchTicket(ticketId: string, options: LaunchTicketRequest): Promise { + return request(this.base, `/api/v1/tickets/${encodeURIComponent(ticketId)}/launch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(options), + }); + } + + // --- Projects --- + + listProjects(): Promise { + return request(this.base, '/api/v1/projects'); + } + + // --- Issue Types --- + + listIssueTypes(): Promise { + return request(this.base, '/api/v1/issuetypes'); + } + + getIssueType(key: string): Promise { + return request(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`); + } + + createIssueType(req: CreateIssueTypeRequest): Promise { + return request(this.base, '/api/v1/issuetypes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + } + + updateIssueType(key: string, req: UpdateIssueTypeRequest): Promise { + return request(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }); + } + + deleteIssueType(key: string): Promise { + return requestVoid(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}`, { + method: 'DELETE', + }); + } + + // --- Collections --- + + listCollections(): Promise { + return request(this.base, '/api/v1/collections'); + } + + activateCollection(name: string): Promise { + return requestVoid(this.base, `/api/v1/collections/${encodeURIComponent(name)}/activate`, { + method: 'PUT', + }); + } + + // --- Configuration --- + + getConfiguration(): Promise { + return request(this.base, '/api/v1/configuration'); + } + + updateConfiguration(config: Partial): Promise { + return request(this.base, '/api/v1/configuration', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + } + + // --- Workflow export --- + + /** Export a ticket (rendered against its issue type) to a Claude dynamic workflow (.js). */ + exportWorkflow(ticketId: string): Promise { + return request( + this.base, + `/api/v1/tickets/${encodeURIComponent(ticketId)}/workflow-export`, + { method: 'POST' }, + ); + } + + /** Preview an issue type's workflow shape (.js, placeholder values) for visualization. */ + previewWorkflow(key: string): Promise { + return request(this.base, `/api/v1/issuetypes/${encodeURIComponent(key)}/workflow-preview`); + } +} diff --git a/ui/src/components/ConceptIcon.tsx b/ui/src/components/ConceptIcon.tsx new file mode 100644 index 00000000..9f846ffe --- /dev/null +++ b/ui/src/components/ConceptIcon.tsx @@ -0,0 +1,15 @@ +// Renders a single codicon glyph. `name` is a codicon name without the +// `codicon-` prefix (e.g. "git-branch"); see ui/src/concepts.ts for the +// canonical concept→icon mapping. The font is loaded once in main.tsx via +// `@vscode/codicons/dist/codicon.css`. Icons inherit `currentColor`, so they +// recolor with surrounding text in both light and dark themes. + +interface ConceptIconProps { + name: string; + className?: string; +} + +export function ConceptIcon({ name, className }: ConceptIconProps) { + const cls = className ? `codicon codicon-${name} ${className}` : `codicon codicon-${name}`; + return