diff --git a/.forgejo/README.md b/.forgejo/README.md new file mode 100644 index 00000000..5303fe39 --- /dev/null +++ b/.forgejo/README.md @@ -0,0 +1,130 @@ +# Forgejo Actions runbook + +`workflows/deploy-casa.yaml` builds `Dockerfile.local` with the dev license keyring and swaps the image on the +`operator` release at `operator.`. `workflows/pr.yaml` is the required check on `main`: it builds the +same arm64 image without pushing it, then runs the `e2e/` Playwright suites against it (`scripts/ci/e2e.sh`). +Runner egress isn't restricted (jobs reach the internet through OrbStack's NAT), but everything they pull comes +from Forgejo mirrors anyway, for pinning and speed. These commands refresh the mirrors from a workstation. + +| Workflow / job | Runner label | Reaches | +| --- | --- | --- | +| `deploy-casa` / `build` | `docker` | Forgejo registry and mirrors; `deb.debian.org`, `registry.npmjs.org`, `index.crates.io`, `static.crates.io` at build time | +| `deploy-casa` / `deploy` | `helmfile` | Forgejo registry, cluster API | +| `pr` / `arm64` | `docker` | as `build`, plus `webhook.operator.untra.casa` for the license suite | + +Common shell setup: + +```sh +FORGEJO=https://forgejo. +REPO=untra-operator/operator +MIRROR=forgejo./untra-operator/mirror +CHARTS=forgejo./untra-operator/charts +FORGEJO_TOKEN=... # scopes: write:package, write:repository, write:organization +api() { curl -fsS -H "Authorization: token $FORGEJO_TOKEN" -H 'Content-Type: application/json' "$@"; } +``` + +## License secrets + +`OPERATOR_LICENSE_ROOT_KEYS` must carry the **dev** root only; the prod root belongs to GitHub `untra/operator`. + +```sh +api -X PUT "$FORGEJO/api/v1/repos/$REPO/actions/secrets/OPERATOR_LICENSE_ROOT_KEYS" \ + -d "$(jq -cn --arg k "$(cat ~/untra-keys/keys/untra-dev-2026.pub)" '{data: ({"untra-dev-2026": $k} | tojson)}')" +api -X PUT "$FORGEJO/api/v1/repos/$REPO/actions/secrets/OPERATOR_LICENSE_ISSUER" -d '{"data":"operator-licensing"}' +api -X PUT "$FORGEJO/api/v1/repos/$REPO/actions/secrets/OPERATOR_PURCHASE_URL" -d '{"data":"https://app.operator.untra.casa/"}' +api "$FORGEJO/api/v1/repos/$REPO/actions/secrets" | jq -r '.[].name' # names only; values are write-only +``` + +## Base image mirror + +Re-run whenever a `FROM` in `Dockerfile.local` or `e2e/Dockerfile` changes; the Playwright tag tracks +`@playwright/test` in `e2e/package.json`. Digest-pinned refs copy whole, so the pin still resolves; the rest copy arm64 only. `--jobs 1` keeps large layer uploads under the ingress timeout. + +```sh +: "${MIRROR:?run the common shell setup first}" +crane auth login forgejo. -u --password-stdin <<<"$FORGEJO_TOKEN" +sed -n 's/^FROM \${BASE_REGISTRY}\([^ ]*\).*/\1/p' Dockerfile.local e2e/Dockerfile | sort -u | while read -r ref; do + case $ref in + *@*) crane copy --jobs 1 "$ref" "$MIRROR/${ref%@*}" ;; + *) crane copy --jobs 1 --platform linux/arm64 "$ref" "$MIRROR/$ref" ;; + esac +done +crane digest "$MIRROR/debian:trixie-slim" # must equal the sha256 pinned in Dockerfile.local +``` + +## Chart mirror + +Mirror every chart version that helmfile deploys; `deploy` upgrades at the release's current chart version. + +```sh +: "${CHARTS:?run the common shell setup first}" +v=$(helm get metadata operator -n operator | awk '/^VERSION:/ {print $2}') +helm pull oci://ghcr.io/untra/charts/operator --version "$v" -d /tmp +helm registry login forgejo. -u --password-stdin <<<"$FORGEJO_TOKEN" +helm push "/tmp/operator-$v.tgz" "oci://$CHARTS" +helm show chart "oci://$CHARTS/operator" --version "$v" +``` + +## Checkout action mirror + +A pull mirror refreshes from the Forgejo server, so only the server needs egress to `code.forgejo.org`. +The `actions` org must be public so runners can fetch it without credentials. + +```sh +api -X POST "$FORGEJO/api/v1/orgs" -d '{"username":"actions","visibility":"public"}' # once +api -X POST "$FORGEJO/api/v1/repos/migrate" -d '{"clone_addr":"https://code.forgejo.org/actions/checkout", + "repo_owner":"actions","repo_name":"checkout","mirror":true,"service":"git"}' # once +api -X POST "$FORGEJO/api/v1/repos/actions/checkout/mirror-sync" # refresh +``` + +## E2E suites + +`pr` runs both projects in `e2e/playwright.config.ts`: `workspace` (admin setup and onboarding on a fresh server) +and `license` (a test-mode key from the platform webhook's `GET /e2e/license`, applied to that server's +configuration). The license suite skips while `OPERATOR_E2E_LICENSE_TOKEN` is unset, so leave it unset until the +casa webhook serves the route. Its value is the casa webhook's `E2E_LICENSE_TOKEN`. + +```sh +api -X PUT "$FORGEJO/api/v1/repos/$REPO/actions/secrets/OPERATOR_E2E_LICENSE_TOKEN" \ + -d "$(jq -cn --arg v "$E2E_LICENSE_TOKEN" '{data: $v}')" +api -X PUT "$FORGEJO/api/v1/repos/$REPO/actions/secrets/OPERATOR_E2E_CA_CERT" \ + -d "$(jq -cn --arg v "$(cat casa-root-ca.pem)" '{data: $v}')" # only if the runner does not already trust the casa CA +``` + +Run the same thing locally with `make e2e`; pass the two `OPERATOR_E2E_*` variables to include the license suite. + +## Required check on main + +The status context is ` / ()`. Confirm it on the first PR's commit status +before requiring it, because a misspelled context blocks every merge. + +```sh +api "$FORGEJO/api/v1/repos/$REPO/commits//statuses" | jq -r '.[].context' # expect: pr / arm64 (pull_request) +api -X POST "$FORGEJO/api/v1/repos/$REPO/branch_protections" -d '{"rule_name":"main", + "enable_status_check":true,"status_check_contexts":["pr / arm64 (pull_request)"]}' +# rule already exists: +api -X PATCH "$FORGEJO/api/v1/repos/$REPO/branch_protections/main" -d '{"enable_status_check":true, + "status_check_contexts":["pr / arm64 (pull_request)"]}' +``` + +## Dispatch, status and rollback + +```sh +api -X POST "$FORGEJO/api/v1/repos/$REPO/actions/workflows/deploy-casa.yaml/dispatches" -d '{"ref":"main"}' +api "$FORGEJO/api/v1/repos/$REPO/actions/runs?limit=1" | jq '.workflow_runs[0] | {id, status, html_url}' +helm history operator -n operator +helm rollback operator -n operator +``` + +Job logs are not readable through the API; open the run in the web UI. + +## Housekeeping + +```sh +api "$FORGEJO/api/v1/repos/$REPO/actions/runners" | jq -r '.[] | "\(.name) \(.status) \(.labels | join(","))"' +api "$FORGEJO/api/v1/packages/untra-operator?type=container&q=operator" | jq -r '.[] | "\(.name):\(.version)"' +api -X DELETE "$FORGEJO/api/v1/packages/untra-operator/container/operator/" +docker buildx prune --filter until=168h --force # on the docker runner host +``` + +`buildcache` is overwritten by every build; delete it to force a cold build. diff --git a/.forgejo/workflows/deploy-casa.yaml b/.forgejo/workflows/deploy-casa.yaml new file mode 100644 index 00000000..dfca5599 --- /dev/null +++ b/.forgejo/workflows/deploy-casa.yaml @@ -0,0 +1,108 @@ +name: deploy-casa +on: + workflow_dispatch: + inputs: + ref: + description: Branch, tag or sha to deploy (blank deploys the dispatched branch) + required: false + default: "" + +# Builds Dockerfile.local with the dev license keyring and rolls it out to operator.kube.untra.casa. +# build runs-on: docker native arm64 buildx push to the Forgejo registry, tagged by commit sha +# deploy runs-on: helmfile swaps the image on the helmfile-owned release, at its deployed chart version and values +# Base images, the chart and the checkout action come from Forgejo mirrors; see .forgejo/README.md. +# +# Variables: REGISTRY_IMAGE +# Secrets: REGISTRY_USERNAME, REGISTRY_TOKEN, KUBECONFIG, +# OPERATOR_LICENSE_ROOT_KEYS (dev root only), OPERATOR_LICENSE_ISSUER, OPERATOR_PURCHASE_URL + +concurrency: + group: deploy-casa + cancel-in-progress: false + +jobs: + build: + runs-on: docker + outputs: + tag: ${{ steps.meta.outputs.tag }} + digest: ${{ steps.push.outputs.digest }} + steps: + - name: License secrets present + run: | + missing=0 + for name in OPERATOR_LICENSE_ROOT_KEYS OPERATOR_LICENSE_ISSUER; do + if [ -z "$(printenv "$name")" ]; then + echo "::error::repository secret $name is unset or empty" + missing=1 + fi + done + exit "$missing" + env: + OPERATOR_LICENSE_ROOT_KEYS: ${{ secrets.OPERATOR_LICENSE_ROOT_KEYS }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + + - uses: https://forgejo.kube.untra.casa/actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - id: meta + run: echo "tag=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Registry login + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY_IMAGE%%/*}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} + + # Default docker driver: a docker-container builder would not trust the home CA. + # OPERATOR_RELEASE=1 makes build.rs fail on an empty keyring or issuer instead of shipping one that rejects every license. + - name: Build and push + id: push + run: | + docker buildx build \ + --file Dockerfile.local \ + --build-arg BASE_REGISTRY="${REGISTRY_IMAGE%/*}/mirror/" \ + --build-arg OPERATOR_RELEASE=1 \ + --build-arg OPERATOR_LICENSE_ISSUER="$OPERATOR_LICENSE_ISSUER" \ + --build-arg OPERATOR_LICENSE_ROOT_KEYS="$OPERATOR_LICENSE_ROOT_KEYS" \ + --build-arg OPERATOR_PURCHASE_URL="$OPERATOR_PURCHASE_URL" \ + --cache-from "type=registry,ref=$REGISTRY_IMAGE:buildcache" \ + --cache-to "type=registry,ref=$REGISTRY_IMAGE:buildcache,mode=max" \ + --tag "$REGISTRY_IMAGE:${{ steps.meta.outputs.tag }}" \ + --metadata-file meta.json \ + --push \ + . + echo "digest=$(node -p "require('./meta.json')['containerimage.digest']")" >> "$GITHUB_OUTPUT" + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + OPERATOR_LICENSE_ROOT_KEYS: ${{ secrets.OPERATOR_LICENSE_ROOT_KEYS }} + OPERATOR_PURCHASE_URL: ${{ secrets.OPERATOR_PURCHASE_URL }} + + deploy: + needs: build + runs-on: helmfile + steps: + - name: Kubeconfig + run: | + mkdir -p ~/.kube + echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config + chmod 600 ~/.kube/config + + - name: Registry login + run: echo "${{ secrets.REGISTRY_TOKEN }}" | helm registry login "${REGISTRY_IMAGE%%/*}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} + + # Helmfile owns the release: keep its chart version and values, and swap only the image. + # The tag carries the digest: a re-run on the same commit pushes a new image under the same + # tag, and a bare tag would leave the StatefulSet unchanged (and IfNotPresent on the old one). + - name: Helm upgrade + run: | + version=$(helm get metadata operator -n operator | awk '/^VERSION:/ {print $2}') + helm upgrade operator "oci://${REGISTRY_IMAGE%/*}/charts/operator" --version "$version" -n operator \ + --reset-then-reuse-values \ + --set image.repository="$REGISTRY_IMAGE" \ + --set image.tag="${{ needs.build.outputs.tag }}@${{ needs.build.outputs.digest }}" \ + --wait + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} diff --git a/.forgejo/workflows/pr.yaml b/.forgejo/workflows/pr.yaml new file mode 100644 index 00000000..41de2ec6 --- /dev/null +++ b/.forgejo/workflows/pr.yaml @@ -0,0 +1,80 @@ +name: pr +on: + pull_request: + branches: [main] + +# Required check on main: builds the arm64 Dockerfile.local image this PR would deploy, then runs e2e/ against it. +# arm64 runs-on: docker native arm64 buildx --load (nothing pushed), then scripts/ci/e2e.sh +# Lint and unit tests stay on GitHub build.yaml; this gate proves the casa image boots and onboards. +# +# Variables: REGISTRY_IMAGE +# Secrets: REGISTRY_USERNAME, REGISTRY_TOKEN, +# OPERATOR_LICENSE_ROOT_KEYS (dev root only), OPERATOR_LICENSE_ISSUER, OPERATOR_PURCHASE_URL, +# OPERATOR_E2E_LICENSE_TOKEN (optional; the license suite skips without it), OPERATOR_E2E_CA_CERT (optional) + +concurrency: + group: pr-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + arm64: + runs-on: docker + steps: + - name: License secrets present + run: | + missing=0 + for name in OPERATOR_LICENSE_ROOT_KEYS OPERATOR_LICENSE_ISSUER; do + if [ -z "$(printenv "$name")" ]; then + echo "::error::repository secret $name is unset or empty" + missing=1 + fi + done + exit "$missing" + env: + OPERATOR_LICENSE_ROOT_KEYS: ${{ secrets.OPERATOR_LICENSE_ROOT_KEYS }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + + - uses: https://forgejo.kube.untra.casa/actions/checkout@v4 + + - id: meta + run: echo "tag=pr-$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Registry login + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY_IMAGE%%/*}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} + + # Reads main's cache but never writes it: PR layers stay off the registry and clear of the ingress upload limit. + - name: Build + run: | + docker buildx build \ + --file Dockerfile.local \ + --build-arg BASE_REGISTRY="${REGISTRY_IMAGE%/*}/mirror/" \ + --build-arg OPERATOR_RELEASE=1 \ + --build-arg OPERATOR_LICENSE_ISSUER="$OPERATOR_LICENSE_ISSUER" \ + --build-arg OPERATOR_LICENSE_ROOT_KEYS="$OPERATOR_LICENSE_ROOT_KEYS" \ + --build-arg OPERATOR_PURCHASE_URL="$OPERATOR_PURCHASE_URL" \ + --cache-from "type=registry,ref=$REGISTRY_IMAGE:buildcache" \ + --tag "operator:${{ steps.meta.outputs.tag }}" \ + --load \ + . + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + OPERATOR_LICENSE_ROOT_KEYS: ${{ secrets.OPERATOR_LICENSE_ROOT_KEYS }} + OPERATOR_PURCHASE_URL: ${{ secrets.OPERATOR_PURCHASE_URL }} + + - name: E2E + run: | + export BASE_REGISTRY="${REGISTRY_IMAGE%/*}/mirror/" + scripts/ci/e2e.sh "operator:${{ steps.meta.outputs.tag }}" + env: + REGISTRY_IMAGE: ${{ vars.REGISTRY_IMAGE }} + E2E_RUN_ID: ${{ github.run_id }} + OPERATOR_E2E_ISSUER_URL: https://webhook.operator.untra.casa + OPERATOR_E2E_LICENSE_TOKEN: ${{ secrets.OPERATOR_E2E_LICENSE_TOKEN }} + OPERATOR_E2E_CA_CERT: ${{ secrets.OPERATOR_E2E_CA_CERT }} + + - name: Remove image + if: always() + run: docker rmi "operator:${{ steps.meta.outputs.tag }}" || true diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3063403c..07699a54 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -74,6 +74,7 @@ jobs: (cd webcomponents && bun install --frozen-lockfile && bun run copy-types) (cd ui && bun install --frozen-lockfile) (cd vscode-extension && npm ci && npm run copy-types) + (cd e2e && bun install --frozen-lockfile) - name: Build shared web components run: | @@ -91,6 +92,7 @@ jobs: bun run lint:vscode bun run lint:agnt bun run lint:coder-module + bun run lint:e2e - name: Webview unit tests run: bun run test:webview @@ -364,6 +366,11 @@ jobs: OPERATOR_LAUNCH_TEST_ENABLED: 'true' run: cargo test --locked --test launch_integration -- --test-threads=1 --nocapture + - name: Run campaign launch integration tests + env: + OPERATOR_LAUNCH_TEST_ENABLED: 'true' + run: cargo test --locked --test campaign_launch_integration -- --test-threads=1 --nocapture + - name: Cleanup sessions if: always() run: | @@ -397,6 +404,78 @@ jobs: OPERATOR_RELAY_INTEGRATION_TEST_ENABLED: 'true' run: cargo test --locked --test relay_integration -- --nocapture --test-threads=1 + # Playwright against a release binary on x64. The license suite asks the prod issuer for a + # test-mode key, so it skips until OPERATOR_E2E_LICENSE_TOKEN is set; the arm64 twin is .forgejo/workflows/pr.yaml. + e2e: + needs: lint-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install toolchain + uses: dtolnay/rust-toolchain@1.95 + + - name: Cache cargo + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-e2e-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-e2e- + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Build UI for embedding + run: | + (cd webcomponents && bun install --frozen-lockfile && bun run build) + (cd ui && bun install --frozen-lockfile && bun run build) + + - name: Build operator + run: cargo build --release --locked --bin operator + env: + OPERATOR_LICENSE_ROOT_KEYS: ${{ secrets.OPERATOR_LICENSE_ROOT_KEYS }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + + - name: Start operator + run: | + workspace="$(mktemp -d)" + od -An -tx1 -N16 /dev/urandom | tr -d ' \n' > "$RUNNER_TEMP/bootstrap" + echo "OPERATOR_E2E_BOOTSTRAP_PASSWORD=$(cat "$RUNNER_TEMP/bootstrap")" >> "$GITHUB_ENV" + cd "$workspace" + OPERATOR_BOOTSTRAP_PASSWORD_FILE="$RUNNER_TEMP/bootstrap" \ + nohup "$GITHUB_WORKSPACE/target/release/operator" api > "$RUNNER_TEMP/operator.log" 2>&1 & + + - name: Install Playwright + run: | + cd e2e + bun install --frozen-lockfile + bunx playwright install --with-deps chromium + + - name: Run e2e + run: cd e2e && bunx playwright test + env: + CI: 'true' + OPERATOR_E2E_ISSUER_URL: https://webhook.operator.untra.app + OPERATOR_E2E_LICENSE_TOKEN: ${{ secrets.OPERATOR_E2E_LICENSE_TOKEN }} + + - name: Operator log + if: always() + run: cat "$RUNNER_TEMP/operator.log" || true + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/test-results + build: needs: [lint-test, version] if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -470,16 +549,16 @@ jobs: cargo update --workspace # Compile license material into the release binary. These are not - # runtime settings. build.rs refuses an empty root keyring when - # OPERATOR_RELEASE=1, so a missing repository variable fails this job + # runtime settings. build.rs refuses an empty root keyring or issuer when + # OPERATOR_RELEASE=1, so a missing repository secret fails this job # instead of publishing a binary that rejects every license. - name: Build release run: cargo build --locked --release --features embed-ui --target ${{ matrix.target }} env: OPERATOR_RELEASE: "1" - OPERATOR_LICENSE_ISSUER: operator-licensing - OPERATOR_LICENSE_ROOT_KEYS: ${{ vars.OPERATOR_LICENSE_ROOT_KEYS }} - OPERATOR_PURCHASE_URL: ${{ vars.OPERATOR_PURCHASE_URL }} + OPERATOR_LICENSE_ISSUER: ${{ secrets.OPERATOR_LICENSE_ISSUER }} + OPERATOR_LICENSE_ROOT_KEYS: ${{ secrets.OPERATOR_LICENSE_ROOT_KEYS }} + OPERATOR_PURCHASE_URL: ${{ secrets.OPERATOR_PURCHASE_URL }} - name: Rename binary shell: bash diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index fdbc54cc..6774f248 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -45,7 +45,9 @@ "shared/types.ts", "scripts/**", "zed-extension/**", - "opr8r/**" + "opr8r/**", + "e2e/playwright-report/**", + "e2e/test-results/**" ], "rules": { diff --git a/AGENTS.md b/AGENTS.md index f0765677..abd3b2a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ If any of these fail, fix the issues before proceeding. Do NOT use `#[allow(...) Linting is strictly enforced; the rules are tighter than other software. Linting warnings are errors; address them as part of design. Always running lint step when finished working in a directory. Fix all found linting problems before declaring work done. +Don't hand-roll encodings or hashes with bitwise operators; use a crate or std API. ### Subproject Validation diff --git a/Cargo.lock b/Cargo.lock index 58982097..90cbeca9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2580,9 +2580,11 @@ dependencies = [ "crossterm", "dirs 6.0.0", "flate2", + "fnv", "futures-util", "glob", "handlebars", + "hex", "http-body-util", "jsonwebtoken", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 3a9a4bc0..2d71e297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,8 @@ jsonwebtoken = "9" ring = "0.17" rand = "0.9" base64 = "0.22" +hex = "0.4" +fnv = "1" subtle = "2" url = "2" diff --git a/Dockerfile.local b/Dockerfile.local index a13b8173..efaa5793 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,7 +1,11 @@ # Cargo.toml sets default = ["embed-ui"], and build.rs silently substitutes a # placeholder index.html when ui/dist is missing, which would produce an image # with no dashboard. Building the SPA here is required, not optional. -FROM oven/bun:1.3.14 AS web + +# Registry mirror prefix for the base images, e.g. registry.example/org/mirror/; empty pulls from Docker Hub. +ARG BASE_REGISTRY="" + +FROM ${BASE_REGISTRY}oven/bun:1.3.14 AS web # webcomponents' own build script shells out to `npm run copy-types`, which runs # `node scripts/copy-types.mjs`. The oven/bun image ships bun only; CI gets away @@ -22,12 +26,18 @@ COPY ui/ ./ui/ RUN cd ui && bun install --frozen-lockfile && bun run build # rusqlite is features = ["bundled"], so a C toolchain is required. -FROM rust:1.98 AS build +FROM ${BASE_REGISTRY}rust:1.98 AS build WORKDIR /src COPY . . COPY --from=web /src/ui/dist ./ui/dist +# Compile-time license material; defaults match a plain source build. +ARG OPERATOR_RELEASE="" +ARG OPERATOR_LICENSE_ISSUER=operator-licensing +ARG OPERATOR_LICENSE_ROOT_KEYS={} +ARG OPERATOR_PURCHASE_URL="" + RUN cargo build --release --locked --bin operator # opr8r is a separate cargo project with its own Cargo.lock, not a workspace member, so it needs its own invocation. @@ -35,7 +45,7 @@ RUN cd opr8r && cargo build --release --locked # Mirrors Dockerfile; only the operator/opr8r binary source differs. # glibc 2.41 >= the ubuntu-24.04 build runners' 2.39, so the GNU binary runs. -FROM debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 +FROM ${BASE_REGISTRY}debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 LABEL org.opencontainers.image.title="Operator" \ org.opencontainers.image.description="Agent orchestration server and CLI (local build)" \ diff --git a/Makefile b/Makefile index 10a0d28d..91bb4741 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # hook, which runs the fast gate (root fmt-check + clippy, no tests). .PHONY: check fmt fmt-check lint test clippy build run install-hooks bindings \ - webcomponents storybook ui docs vscode-extension relay opr8r \ + webcomponents storybook ui docs vscode-extension relay opr8r e2e \ fmt-rust fmt-ts fmt-tf fmt-check-rust fmt-check-ts fmt-check-tf \ lint-rust lint-ts lint-shell lint-helm lint-tf test-rust test-ts @@ -73,6 +73,8 @@ lint-ts: bun run lint:vscode bun run lint:agnt bun run lint:coder-module + cd e2e && bun install --frozen-lockfile + bun run lint:e2e lint-shell: shellcheck -S warning scripts/*.sh scripts/ci/*.sh @@ -111,6 +113,12 @@ opr8r: cd opr8r && cargo clippy --locked --all-targets --all-features -- -D warnings cd opr8r && cargo test --locked --all-features +# Playwright suites against a fresh Dockerfile.local image, run the way the Forgejo PR check runs them. +# The license suite skips unless OPERATOR_E2E_ISSUER_URL and OPERATOR_E2E_LICENSE_TOKEN are set. +e2e: + docker build --file Dockerfile.local --tag operator-e2e:local . + scripts/ci/e2e.sh operator-e2e:local + # Optimized release binary at target/release/operator. build: cargo build --release diff --git a/bindings/ActiveAgentResponse.ts b/bindings/ActiveAgentResponse.ts index 91c58205..770cb900 100644 --- a/bindings/ActiveAgentResponse.ts +++ b/bindings/ActiveAgentResponse.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Attention } from "./Attention"; +import type { ConnectPlan } from "./ConnectPlan"; /** * A single active agent @@ -51,4 +53,16 @@ session_context_ref: string | null, /** * Session pane reference (e.g. cmux surface, zellij pane) */ -session_pane_ref: string | null, }; +session_pane_ref: string | null, +/** + * Last status message from the agent + */ +last_message: string | null, +/** + * Ways to open the agent's session + */ +connect: ConnectPlan, +/** + * Set when the agent needs the operator + */ +attention: Attention | null, }; diff --git a/bindings/AgentDetailResponse.ts b/bindings/AgentDetailResponse.ts index ce288b6a..d79410ac 100644 --- a/bindings/AgentDetailResponse.ts +++ b/bindings/AgentDetailResponse.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Attention } from "./Attention"; +import type { ConnectPlan } from "./ConnectPlan"; /** * Full details for a single agent @@ -75,4 +77,32 @@ worktree_path: string | null, /** * Whether this is a paired (interactive) agent */ -paired: boolean, }; +paired: boolean, +/** + * Terminal session name + */ +session_name: string | null, +/** + * Remote host the agent runs on (None = local) + */ +remote_host: string | null, +/** + * Execution target the agent launched on + */ +target_name: string | null, +/** + * Last status message from the agent + */ +last_message: string | null, +/** + * Last time the session output changed (ISO 8601) + */ +last_content_change: string | null, +/** + * Ways to open the agent's session + */ +connect: ConnectPlan, +/** + * Set when the agent needs the operator + */ +attention: Attention | null, }; diff --git a/bindings/AssignCampaignRequest.ts b/bindings/AssignCampaignRequest.ts new file mode 100644 index 00000000..2591d0ed --- /dev/null +++ b/bindings/AssignCampaignRequest.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. + +/** + * Move a ticket into or out of a campaign. + */ +export type AssignCampaignRequest = { +/** + * Campaign id; null removes the ticket from its campaign. + */ +campaign: string | null, +/** + * Replaces the ticket's `depends_on` when present. + */ +depends_on: Array | null, }; diff --git a/bindings/AssignCampaignResponse.ts b/bindings/AssignCampaignResponse.ts new file mode 100644 index 00000000..72177d08 --- /dev/null +++ b/bindings/AssignCampaignResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A ticket's campaign membership after an assignment. + */ +export type AssignCampaignResponse = { id: string, campaign: string | null, depends_on: Array, }; diff --git a/bindings/Attention.ts b/bindings/Attention.ts new file mode 100644 index 00000000..9d25555e --- /dev/null +++ b/bindings/Attention.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. +import type { AttentionReason } from "./AttentionReason"; + +/** + * Attention nudge for an agent + */ +export type Attention = { +/** + * Why attention is needed + */ +reason: AttentionReason, +/** + * Short context (review state or last message) + */ +detail?: string, +/** + * Since when (ISO 8601) + */ +since: string, }; diff --git a/bindings/AttentionReason.ts b/bindings/AttentionReason.ts new file mode 100644 index 00000000..32dfb623 --- /dev/null +++ b/bindings/AttentionReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why an agent needs the operator + */ +export type AttentionReason = "awaiting_input" | "review" | "orphaned" | "idle"; diff --git a/bindings/BatchCreated.ts b/bindings/BatchCreated.ts new file mode 100644 index 00000000..b49dec34 --- /dev/null +++ b/bindings/BatchCreated.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CreatedTicket } from "./CreatedTicket"; + +/** + * Result of creating a batch. + */ +export type BatchCreated = { tickets: Array, waves: Array>, }; diff --git a/bindings/BatchItem.ts b/bindings/BatchItem.ts new file mode 100644 index 00000000..c25d75f5 --- /dev/null +++ b/bindings/BatchItem.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. + +/** + * One ticket to create; `ref` names it within the batch. + */ +export type BatchItem = { +/** + * Batch-local name other items use in `depends_on`. + */ +ref: string, +/** + * Template key (FEAT, FIX, TASK, SPIKE, INV, ...). + */ +template: string, project: string | null, summary: string | null, +/** + * Extra Handlebars values; `project`/`summary` above take precedence. + */ +values: { [key in string]: string }, +/** + * Batch refs, existing ticket ids, or `provider:key` refs. + */ +depends_on: Array, }; diff --git a/bindings/BatchPreview.ts b/bindings/BatchPreview.ts new file mode 100644 index 00000000..6622aca7 --- /dev/null +++ b/bindings/BatchPreview.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PlannedTicket } from "./PlannedTicket"; + +/** + * What a batch would create; nothing is written. + */ +export type BatchPreview = { tickets: Array, +/** + * Allocated ids in dependency waves. + */ +waves: Array>, +/** + * Batch-wide errors (cycles, unknown campaign). + */ +errors: Array, valid: boolean, }; diff --git a/bindings/BatchRequest.ts b/bindings/BatchRequest.ts new file mode 100644 index 00000000..bb03c174 --- /dev/null +++ b/bindings/BatchRequest.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BatchItem } from "./BatchItem"; + +/** + * A batch of tickets, optionally created inside a campaign. + */ +export type BatchRequest = { campaign: string | null, tickets: Array, }; diff --git a/bindings/BlockReason.ts b/bindings/BlockReason.ts new file mode 100644 index 00000000..9aeeeecf --- /dev/null +++ b/bindings/BlockReason.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Why a pending member is not launching. + */ +export type BlockReason = { "kind": "dependencies" } | { "kind": "gate", "refs": Array } | { "kind": "unsynced", "refs": Array } | { "kind": "license" } | { "kind": "capacity" }; diff --git a/bindings/Campaign.ts b/bindings/Campaign.ts new file mode 100644 index 00000000..f9cecf39 --- /dev/null +++ b/bindings/Campaign.ts @@ -0,0 +1,37 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CampaignStatus } from "./CampaignStatus"; +import type { CampaignWorkspace } from "./CampaignWorkspace"; +import type { GovernancePolicy } from "./GovernancePolicy"; + +/** + * A campaign, stored as frontmatter plus a markdown body under `/campaigns/`. + */ +export type Campaign = { +/** + * Campaign id (e.g. `CAMP-0003`); member tickets reference it as `campaign:`. + */ +id: string, +/** + * Human-readable title. + */ +title: string, status: CampaignStatus, +/** + * Creation date (`YYYY-MM-DD`). + */ +created: string, +/** + * Name of a `[[governance]]` policy layered over each delegator's own. + */ +governance?: string | null, +/** + * Inline policy layered over `governance`. + */ +governance_overrides?: GovernancePolicy | null, +/** + * Shared execution environment; absent runs each ticket in its own workspace. + */ +workspace?: CampaignWorkspace | null, +/** + * Upstream container this campaign was synced from (e.g. `jira:PROJ-1`). + */ +external_ref?: string | null, }; diff --git a/bindings/CampaignCounters.ts b/bindings/CampaignCounters.ts new file mode 100644 index 00000000..5a5cc8f4 --- /dev/null +++ b/bindings/CampaignCounters.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. + +/** + * Usage counted against the campaign's caps. + */ +export type CampaignCounters = { launched: number, max_launches: number | null, +/** + * Members in progress. + */ +active: number, max_parallel: number | null, +/** + * Most relaunches of any one member. + */ +retries: number, max_retries: number | null, }; diff --git a/bindings/CampaignDetail.ts b/bindings/CampaignDetail.ts new file mode 100644 index 00000000..0264dab2 --- /dev/null +++ b/bindings/CampaignDetail.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. +import type { Campaign } from "./Campaign"; +import type { CampaignMember } from "./CampaignMember"; + +/** + * A campaign with its members and plan. + */ +export type CampaignDetail = { campaign: Campaign, members: Array, waves: Array>, +/** + * Member id → local tickets outside the campaign it waits on. + */ +gates: { [key in string]: Array }, +/** + * Member id → unsynced upstream refs it waits on. + */ +unsynced: { [key in string]: Array }, plan_error?: string | null, }; diff --git a/bindings/CampaignLaunchOutcome.ts b/bindings/CampaignLaunchOutcome.ts new file mode 100644 index 00000000..8c617586 --- /dev/null +++ b/bindings/CampaignLaunchOutcome.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CampaignStatus } from "./CampaignStatus"; +import type { GovernancePolicy } from "./GovernancePolicy"; +import type { TickReport } from "./TickReport"; + +/** + * Result of a launch or dry run. + */ +export type CampaignLaunchOutcome = { id: string, dry_run: boolean, status: CampaignStatus, waves: Array>, policy: GovernancePolicy, +/** + * Reasons the campaign cannot start; empty when it can. + */ +violations: Array, +/** + * The first tick, run or (for a dry run) assessed. + */ +report: TickReport, }; diff --git a/bindings/CampaignListResponse.ts b/bindings/CampaignListResponse.ts new file mode 100644 index 00000000..0f1fbdff --- /dev/null +++ b/bindings/CampaignListResponse.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CampaignSummary } from "./CampaignSummary"; + +export type CampaignListResponse = { campaigns: Array, total: number, }; diff --git a/bindings/CampaignMember.ts b/bindings/CampaignMember.ts new file mode 100644 index 00000000..2ecc0c1c --- /dev/null +++ b/bindings/CampaignMember.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 member ticket as seen from its campaign. + */ +export type CampaignMember = { id: string, summary: string, project: string, +/** + * `queue`, `in-progress` or `completed`. + */ +column: string, depends_on: Array, +/** + * Launches made by the runner. + */ +launches: number, }; diff --git a/bindings/CampaignRun.ts b/bindings/CampaignRun.ts new file mode 100644 index 00000000..3ddf8501 --- /dev/null +++ b/bindings/CampaignRun.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. + +/** + * Runner bookkeeping for one campaign. + */ +export type CampaignRun = { +/** + * Ticket id → launches made by the runner + */ +launches: { [key in string]: number }, started_at?: string | null, last_error?: string | null, +/** + * Why the run paused, if it did + */ +pause_reason?: string | null, }; diff --git a/bindings/CampaignStatus.ts b/bindings/CampaignStatus.ts new file mode 100644 index 00000000..cb4c51e8 --- /dev/null +++ b/bindings/CampaignStatus.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Lifecycle of a campaign. + */ +export type CampaignStatus = "draft" | "ready" | "running" | "paused" | "completed" | "cancelled"; diff --git a/bindings/CampaignStatusView.ts b/bindings/CampaignStatusView.ts new file mode 100644 index 00000000..e69fd577 --- /dev/null +++ b/bindings/CampaignStatusView.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. +import type { CampaignCounters } from "./CampaignCounters"; +import type { CampaignMember } from "./CampaignMember"; +import type { CampaignStatus } from "./CampaignStatus"; +import type { GovernancePolicy } from "./GovernancePolicy"; +import type { TickReport } from "./TickReport"; + +/** + * Live state of a campaign run. + */ +export type CampaignStatusView = { id: string, status: CampaignStatus, tickets: Array, waves: Array>, +/** + * What the next tick would do. + */ +report: TickReport, counters: CampaignCounters, +/** + * The policy the campaign layers over each delegator's own. + */ +policy: GovernancePolicy, pause_reason?: string | null, }; diff --git a/bindings/CampaignSummary.ts b/bindings/CampaignSummary.ts new file mode 100644 index 00000000..5f711f28 --- /dev/null +++ b/bindings/CampaignSummary.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Campaign } from "./Campaign"; + +/** + * A campaign with its member count. + */ +export type CampaignSummary = { campaign: Campaign, members: number, }; diff --git a/bindings/CampaignWorkspace.ts b/bindings/CampaignWorkspace.ts new file mode 100644 index 00000000..d4977b1b --- /dev/null +++ b/bindings/CampaignWorkspace.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One execution environment shared by every ticket in a campaign. + */ +export type CampaignWorkspace = { "kind": "coder", template: string, } | { "kind": "docker", image: string, }; diff --git a/bindings/Config.ts b/bindings/Config.ts index 9881497e..5886e6b7 100644 --- a/bindings/Config.ts +++ b/bindings/Config.ts @@ -4,6 +4,7 @@ import type { AgentsConfig } from "./AgentsConfig"; import type { ApiConfig } from "./ApiConfig"; import type { Delegator } from "./Delegator"; import type { GitConfig } from "./GitConfig"; +import type { GovernancePolicy } from "./GovernancePolicy"; import type { KanbanConfig } from "./KanbanConfig"; import type { LaunchConfig } from "./LaunchConfig"; import type { LlmToolsConfig } from "./LlmToolsConfig"; @@ -60,6 +61,10 @@ hosts: Array, * Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. */ targets: Array, +/** + * Named launch policies referenced by `Delegator.governance`. + */ +governance: Array, /** * Relay MCP injection configuration */ diff --git a/bindings/ConnectInterface.ts b/bindings/ConnectInterface.ts new file mode 100644 index 00000000..2b305843 --- /dev/null +++ b/bindings/ConnectInterface.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Interface used to open a launched agent's session + */ +export type ConnectInterface = "vscode" | "cursor" | "zed" | "coder" | "cmux" | "tmux" | "zellij" | "ssh" | "terminal"; diff --git a/bindings/ConnectKind.ts b/bindings/ConnectKind.ts new file mode 100644 index 00000000..ddd2b3fb --- /dev/null +++ b/bindings/ConnectKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How a client acts on a connect option + */ +export type ConnectKind = "ide" | "focus_api" | "command"; diff --git a/bindings/ConnectOption.ts b/bindings/ConnectOption.ts new file mode 100644 index 00000000..53d9551a --- /dev/null +++ b/bindings/ConnectOption.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. +import type { ConnectInterface } from "./ConnectInterface"; +import type { ConnectKind } from "./ConnectKind"; + +/** + * One way to reach an agent's session + */ +export type ConnectOption = { +/** + * Interface this option opens + */ +interface: ConnectInterface, +/** + * How the client acts on it + */ +kind: ConnectKind, +/** + * Button label + */ +label: string, +/** + * Deep link for `ide` options + */ +uri?: string, +/** + * Shell command for `command` options + */ +command?: string, +/** + * Whether the option can be used right now + */ +available: boolean, +/** + * Why the option is unavailable + */ +unavailable_reason?: string, }; diff --git a/bindings/ConnectPlan.ts b/bindings/ConnectPlan.ts new file mode 100644 index 00000000..d3b8d3be --- /dev/null +++ b/bindings/ConnectPlan.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConnectOption } from "./ConnectOption"; +import type { SessionPhase } from "./SessionPhase"; + +/** + * Every connect option for an agent, with the preferred one called out + */ +export type ConnectPlan = { +/** + * Attach vs resume + */ +phase: SessionPhase, +/** + * Primary option (configured preference, else the session wrapper's) + */ +preferred?: ConnectOption, +/** + * All applicable options, preferred included + */ +options: Array, }; diff --git a/bindings/ConnectTerminal.ts b/bindings/ConnectTerminal.ts new file mode 100644 index 00000000..44b489cf --- /dev/null +++ b/bindings/ConnectTerminal.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. +import type { SessionPhase } from "./SessionPhase"; + +/** + * What an IDE extension runs after opening the agent's workspace + */ +export type ConnectTerminal = { +/** + * Attach vs resume + */ +phase: SessionPhase, +/** + * Ticket ID, for naming the terminal + */ +ticket_id: string, +/** + * Existing terminal or session name + */ +terminal_name: string, +/** + * Directory to start in + */ +cwd?: string, +/** + * Command to run inside the agent's environment; absent means focus `terminal_name` + */ +command?: string, }; diff --git a/bindings/CreateDelegatorRequest.ts b/bindings/CreateDelegatorRequest.ts index 84b55b9b..1d799cdf 100644 --- a/bindings/CreateDelegatorRequest.ts +++ b/bindings/CreateDelegatorRequest.ts @@ -35,6 +35,10 @@ model_properties: { [key in string]: string }, * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ model_server: string | null, +/** + * Name of a `[[governance]]` policy enforced on this delegator's launches. + */ +governance: string | null, /** * Optional launch configuration */ diff --git a/bindings/CreatedTicket.ts b/bindings/CreatedTicket.ts new file mode 100644 index 00000000..4e75abc8 --- /dev/null +++ b/bindings/CreatedTicket.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A ticket written by a batch. + */ +export type CreatedTicket = { ref: string, id: string, filename: string, }; diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index fbfc8e83..5d50a0bc 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -44,6 +44,10 @@ launch_config: DelegatorLaunchConfig | null, * (claude → anthropic-api, codex → openai-api, gemini → google-api). */ model_server: string | null, +/** + * Name of a `[[governance]]` policy enforced on every launch by this delegator. + */ +governance?: string | null, /** * Declarative reference to a remote, named agent on another platform * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). diff --git a/bindings/DelegatorResponse.ts b/bindings/DelegatorResponse.ts index cf975182..23d87ef6 100644 --- a/bindings/DelegatorResponse.ts +++ b/bindings/DelegatorResponse.ts @@ -35,6 +35,10 @@ model_properties: { [key in string]: string }, * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ model_server: string | null, +/** + * Name of a `[[governance]]` policy enforced on this delegator's launches. + */ +governance: string | null, /** * Optional launch configuration */ diff --git a/bindings/GovernancePolicy.ts b/bindings/GovernancePolicy.ts new file mode 100644 index 00000000..b53945d3 --- /dev/null +++ b/bindings/GovernancePolicy.ts @@ -0,0 +1,50 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A named launch policy. Every constraint is optional; an absent one is unconstrained. + */ +export type GovernancePolicy = { +/** + * Unique name, referenced by `Delegator.governance`. + */ +name: string, +/** + * Delegator names allowed to launch. Absent = any. + */ +allowed_delegators?: Array | null, +/** + * LLM tool names allowed to launch (e.g. `claude`). Absent = any. + */ +allowed_llm_tools?: Array | null, +/** + * Model server names allowed (declared or implicit, e.g. `anthropic-api`). Absent = any. + */ +allowed_model_servers?: Array | null, +/** + * Execution target names allowed (e.g. `local`, `docker`, a `[[targets]]` name). Absent = any. + */ +allowed_targets?: Array | null, +/** + * Maximum concurrently running agents under this policy. + */ +max_parallel?: number | null, +/** + * Maximum total launches under this policy (counted per campaign run). + */ +max_launches?: number | null, +/** + * Maximum relaunches of a failed ticket (counted per campaign run). + */ +max_retries?: number | null, +/** + * Maximum wall-clock minutes per agent. + */ +max_runtime_minutes?: number | null, +/** + * Spend ceiling in USD. Reserved: accepted and reported, not yet enforced. + */ +budget_usd?: number | null, +/** + * Require operator confirmation before each launch. + */ +require_confirmation: boolean, }; diff --git a/bindings/JiraIssueFields.ts b/bindings/JiraIssueFields.ts index b74a0367..e574c4d2 100644 --- a/bindings/JiraIssueFields.ts +++ b/bindings/JiraIssueFields.ts @@ -1,6 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JiraDescription } from "./JiraDescription"; +import type { JiraIssueLink } from "./JiraIssueLink"; import type { JiraIssueTypeRef } from "./JiraIssueTypeRef"; +import type { JiraLinkedIssue } from "./JiraLinkedIssue"; import type { JiraPriority } from "./JiraPriority"; import type { JiraStatusRef } from "./JiraStatusRef"; import type { JiraUser } from "./JiraUser"; @@ -32,4 +34,16 @@ assignee: JiraUser | null, /** * Issue priority (if set) */ -priority: JiraPriority | null, }; +priority: JiraPriority | null, +/** + * Links to other issues (blocks, relates, ...) + */ +issuelinks: Array, +/** + * Parent issue or epic + */ +parent: JiraLinkedIssue | null, +/** + * Sub-task issues + */ +subtasks: Array, }; diff --git a/bindings/JiraIssueLink.ts b/bindings/JiraIssueLink.ts new file mode 100644 index 00000000..38e702cf --- /dev/null +++ b/bindings/JiraIssueLink.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JiraIssueLinkType } from "./JiraIssueLinkType"; +import type { JiraLinkedIssue } from "./JiraLinkedIssue"; + +/** + * A link between two Jira issues + */ +export type JiraIssueLink = { +/** + * Link type, e.g. `Blocks` with inward text `is blocked by` + */ +type: JiraIssueLinkType, +/** + * Present when the other issue is on the inward side (e.g. the blocker) + */ +inwardIssue: JiraLinkedIssue | null, +/** + * Present when the other issue is on the outward side (e.g. the blocked) + */ +outwardIssue: JiraLinkedIssue | null, }; diff --git a/bindings/JiraIssueLinkType.ts b/bindings/JiraIssueLinkType.ts new file mode 100644 index 00000000..69257dd0 --- /dev/null +++ b/bindings/JiraIssueLinkType.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. + +/** + * Jira issue link type + */ +export type JiraIssueLinkType = { +/** + * Link type name (e.g. "Blocks") + */ +name: string, +/** + * Inward description (e.g. "is blocked by") + */ +inward: string, }; diff --git a/bindings/JiraLinkedFields.ts b/bindings/JiraLinkedFields.ts new file mode 100644 index 00000000..5625aba4 --- /dev/null +++ b/bindings/JiraLinkedFields.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JiraIssueTypeRef } from "./JiraIssueTypeRef"; + +/** + * Partial fields of a related issue + */ +export type JiraLinkedFields = { +/** + * Issue type of the related issue + */ +issuetype: JiraIssueTypeRef | null, }; diff --git a/bindings/JiraLinkedIssue.ts b/bindings/JiraLinkedIssue.ts new file mode 100644 index 00000000..b98aec45 --- /dev/null +++ b/bindings/JiraLinkedIssue.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JiraLinkedFields } from "./JiraLinkedFields"; + +/** + * A related issue as embedded in links, parent and subtasks + */ +export type JiraLinkedIssue = { +/** + * Issue key (e.g. "PROJ-12") + */ +key: string, +/** + * Partial fields; only the issue type is read + */ +fields: JiraLinkedFields | null, }; diff --git a/bindings/JiraSearchResponse.ts b/bindings/JiraSearchResponse.ts index 70c3dde5..31d8ad7e 100644 --- a/bindings/JiraSearchResponse.ts +++ b/bindings/JiraSearchResponse.ts @@ -9,4 +9,8 @@ export type JiraSearchResponse = { /** * List of issues matching the JQL query */ -issues: Array, }; +issues: Array, +/** + * Token for the next page; absent on the last page + */ +nextPageToken: string | null, }; diff --git a/bindings/KanbanSyncResponse.ts b/bindings/KanbanSyncResponse.ts index ed4ac31c..8bc2d03d 100644 --- a/bindings/KanbanSyncResponse.ts +++ b/bindings/KanbanSyncResponse.ts @@ -12,6 +12,14 @@ created: Array, * Ticket IDs that were skipped (already exist) */ skipped: Array, +/** + * Existing tickets whose synced relationships were refreshed + */ +updated: Array, +/** + * Campaign the synced tickets were stamped with (scoped syncs) + */ +campaign?: string | null, /** * Error messages for failed syncs */ diff --git a/bindings/KanbanTicketCard.ts b/bindings/KanbanTicketCard.ts index dd8a263d..ec61e858 100644 --- a/bindings/KanbanTicketCard.ts +++ b/bindings/KanbanTicketCard.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 { AttentionReason } from "./AttentionReason"; import type { TicketPriority } from "./TicketPriority"; import type { TicketStatus } from "./TicketStatus"; @@ -46,4 +47,8 @@ timestamp: string, * Ticket markdown filename (joins with the tickets dir + status folder * for co-located clients; remote clients treat tickets as API-only) */ -filename: string, }; +filename: string, +/** + * Set when the ticket's agent needs the operator + */ +attention?: AttentionReason, }; diff --git a/bindings/LaunchCampaignRequest.ts b/bindings/LaunchCampaignRequest.ts new file mode 100644 index 00000000..413f554e --- /dev/null +++ b/bindings/LaunchCampaignRequest.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. + +/** + * Launch a campaign, or preview the launch. + */ +export type LaunchCampaignRequest = { +/** + * Report the plan, policy and violations without starting. + */ +dry_run: boolean, }; diff --git a/bindings/NewCampaignInput.ts b/bindings/NewCampaignInput.ts new file mode 100644 index 00000000..409deb22 --- /dev/null +++ b/bindings/NewCampaignInput.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CampaignWorkspace } from "./CampaignWorkspace"; +import type { GovernancePolicy } from "./GovernancePolicy"; + +/** + * Fields for a new campaign. + */ +export type NewCampaignInput = { title: string, +/** + * Markdown goal written as the campaign body. + */ +goal: string | null, +/** + * Name of a `[[governance]]` policy. + */ +governance: string | null, +/** + * Inline policy layered over `governance`. + */ +governance_overrides: GovernancePolicy | null, workspace: CampaignWorkspace | null, }; diff --git a/bindings/PlannedTicket.ts b/bindings/PlannedTicket.ts new file mode 100644 index 00000000..de5e867f --- /dev/null +++ b/bindings/PlannedTicket.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One item as it would be written. + */ +export type PlannedTicket = { ref: string, +/** + * Allocated id; empty when the template is unknown. + */ +id: string, template: string, +/** + * Filename without the date prefix: `TYPE-project-slug`. + */ +filename_stem: string, +/** + * Dependencies with batch refs resolved to allocated ids. + */ +depends_on: Array, errors: Array, }; diff --git a/bindings/PremiumFeature.ts b/bindings/PremiumFeature.ts index 67399fde..de926e32 100644 --- a/bindings/PremiumFeature.ts +++ b/bindings/PremiumFeature.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PremiumFeature = "remote_targets"; +export type PremiumFeature = "remote_targets" | "campaigns"; diff --git a/bindings/ProjectSyncConfig.ts b/bindings/ProjectSyncConfig.ts index 2bc2c809..abf078bb 100644 --- a/bindings/ProjectSyncConfig.ts +++ b/bindings/ProjectSyncConfig.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { KanbanStatusMapping } from "./KanbanStatusMapping"; +import type { SyncScope } from "./SyncScope"; /** * Per-project/team sync configuration for a kanban provider @@ -36,4 +37,9 @@ bidirectional: boolean, * Operator project name stamped on tickets created from this source. * Defaults to the external project key when unset. */ -ticket_project?: string | null, }; +ticket_project?: string | null, +/** + * Sync one upstream container into a campaign instead of the whole project (Premium). + * Scoped syncs pull every member regardless of assignee. + */ +scope?: SyncScope | null, }; diff --git a/bindings/SessionPhase.ts b/bindings/SessionPhase.ts new file mode 100644 index 00000000..b1ddf0c7 --- /dev/null +++ b/bindings/SessionPhase.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Whether the agent's session can be attached or only resumed + */ +export type SessionPhase = "live" | "resumable" | "gone"; diff --git a/bindings/SessionsConfig.ts b/bindings/SessionsConfig.ts index 19642e8c..7759eda9 100644 --- a/bindings/SessionsConfig.ts +++ b/bindings/SessionsConfig.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SessionWrapperType } from "./SessionWrapperType"; import type { SessionsCmuxConfig } from "./SessionsCmuxConfig"; +import type { SessionsConnectConfig } from "./SessionsConnectConfig"; import type { SessionsTmuxConfig } from "./SessionsTmuxConfig"; import type { SessionsVSCodeConfig } from "./SessionsVSCodeConfig"; import type { SessionsZellijConfig } from "./SessionsZellijConfig"; @@ -35,4 +36,8 @@ cmux: SessionsCmuxConfig, /** * Zellij-specific configuration */ -zellij: SessionsZellijConfig, }; +zellij: SessionsZellijConfig, +/** + * How the UI connects you to a launched agent's session + */ +connect: SessionsConnectConfig, }; diff --git a/bindings/SessionsConnectConfig.ts b/bindings/SessionsConnectConfig.ts new file mode 100644 index 00000000..3b773920 --- /dev/null +++ b/bindings/SessionsConnectConfig.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConnectInterface } from "./ConnectInterface"; + +/** + * Connect preferences; the offered interfaces are derived from what is configured and installed + */ +export type SessionsConnectConfig = { +/** + * Primary interface for connect links; unset follows the agent's session wrapper + */ +preferred: ConnectInterface | null, +/** + * Command that opens a native terminal running `{command}` (e.g. `open -na Ghostty --args -e {command}`); unset disables server-side terminal opening + */ +terminal_command: string | null, }; diff --git a/bindings/State.ts b/bindings/State.ts index a3f4c674..5da884ab 100644 --- a/bindings/State.ts +++ b/bindings/State.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AgentState } from "./AgentState"; +import type { CampaignRun } from "./CampaignRun"; import type { CompletedTicket } from "./CompletedTicket"; import type { MultiAgentGroup } from "./MultiAgentGroup"; import type { ProjectLlmStats } from "./ProjectLlmStats"; @@ -16,4 +17,8 @@ project_collection_prefs: { [key in string]: string }, /** * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) */ -multi_agent_groups: Array, }; +multi_agent_groups: Array, +/** + * Campaign id → runner bookkeeping + */ +campaign_runs: { [key in string]: CampaignRun }, }; diff --git a/bindings/SyncScope.ts b/bindings/SyncScope.ts new file mode 100644 index 00000000..2d146f6e --- /dev/null +++ b/bindings/SyncScope.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An upstream container synced as one campaign. + */ +export type SyncScope = { "jira_epic": string } | { "linear_project": string } | { "github_parent": string } | { "openspec_change": string }; diff --git a/bindings/TickReport.ts b/bindings/TickReport.ts new file mode 100644 index 00000000..e9daabce --- /dev/null +++ b/bindings/TickReport.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BlockReason } from "./BlockReason"; +import type { CampaignStatus } from "./CampaignStatus"; + +/** + * What one tick did, or would do when assessed. + */ +export type TickReport = { status: CampaignStatus, launched: Array, awaiting_operator: Array, stalled: Array, blocked: { [key in string]: BlockReason }, pause_reason: string | null, }; diff --git a/bindings/TicketAgent.ts b/bindings/TicketAgent.ts new file mode 100644 index 00000000..061985e3 --- /dev/null +++ b/bindings/TicketAgent.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The agent working a ticket, as shown on the ticket page. + */ +export type TicketAgent = { id: string, +/** + * running, `awaiting_input`, completing or orphaned + */ +status: string, paired: boolean, +/** + * RFC 3339 start time + */ +started_at: string, current_step: string | null, completed_steps: Array, review_state: string | null, +/** + * tmux, vscode, cmux or zellij + */ +session_wrapper: string | null, session_name: string | null, session_window_ref: string | null, session_context_ref: string | null, session_pane_ref: string | null, worktree_path: string | null, pr_url: string | null, pr_number: bigint | null, pr_status: string | null, +/** + * SSH host the agent runs on, when remote + */ +remote_host: string | null, target_name: string | null, llm_tool: string | null, llm_model: string | null, last_message: string | null, +/** + * Shell command that attaches to the session (tmux) + */ +attach_command: string | null, }; diff --git a/bindings/TicketDependency.ts b/bindings/TicketDependency.ts new file mode 100644 index 00000000..9bbcaec2 --- /dev/null +++ b/bindings/TicketDependency.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. + +/** + * One `depends_on` reference and where it points. + */ +export type TicketDependency = { +/** + * The reference as written (local id or `provider:key`) + */ +reference: string, +/** + * Local ticket it resolves to; absent while unsynced + */ +ticket_id: string | null, +/** + * Column of the resolved ticket + */ +column: string | null, +/** + * Whether the dependency is completed + */ +satisfied: boolean, }; diff --git a/bindings/TicketDetailResponse.ts b/bindings/TicketDetailResponse.ts index fcee9d0c..08402ca8 100644 --- a/bindings/TicketDetailResponse.ts +++ b/bindings/TicketDetailResponse.ts @@ -1,4 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LlmTask } from "./LlmTask"; +import type { TicketAgent } from "./TicketAgent"; +import type { TicketDependency } from "./TicketDependency"; /** * Full ticket details including content and metadata @@ -79,4 +82,44 @@ external_url: string | null, /** * Provider name (e.g., "jira", "linear") */ -external_provider: string | null, }; +external_provider: string | null, +/** + * Markdown after the frontmatter + */ +body: string, +/** + * Board column: `queue`, `in-progress` or `completed` + */ +column: string, +/** + * Campaign this ticket belongs to (e.g. `CAMP-0001`) + */ +campaign: string | null, +/** + * Raw `depends_on` references + */ +depends_on: Array, +/** + * `depends_on` resolved against local tickets + */ +dependencies: Array, +/** + * Local tickets whose `depends_on` names this ticket + */ +dependents: Array, +/** + * Upstream epic, project or parent (`provider:key`) + */ +external_parent: string | null, +/** + * Collection the ticket's issue type comes from + */ +collection: string | null, +/** + * Delegate-mode task metadata + */ +llm_task: LlmTask, +/** + * The agent working this ticket, if any + */ +agent: TicketAgent | null, }; diff --git a/bindings/XOperator.ts b/bindings/XOperator.ts index 46b26356..d22ff072 100644 --- a/bindings/XOperator.ts +++ b/bindings/XOperator.ts @@ -23,6 +23,10 @@ model_properties?: { [key in string]: string }, * Name of a declared `ModelServer` (`None` = implicit vendor default). */ model_server?: string | null, +/** + * Name of a `[[governance]]` policy. + */ +governance?: string | null, /** * Launch configuration (permission mode, flags, worktree/docker, prompt * wrapping, ...). diff --git a/charts/operator/README.md b/charts/operator/README.md index c7ad064b..93e9a9a0 100644 --- a/charts/operator/README.md +++ b/charts/operator/README.md @@ -30,6 +30,15 @@ Open `http://127.0.0.1:7008/setup`, set the admin password, and complete the wor `terminationGracePeriodSeconds` must be greater than the sum of the two shutdown intervals. A custom `lifecycle` hook consumes the same Kubernetes grace period. +## Upgrading + +Charts up to 0.2.14 put version labels on the StatefulSet's immutable `volumeClaimTemplates`, so Kubernetes rejects an upgrade from them to any other chart version. Orphan-delete the StatefulSet once before that upgrade; the pod and the workspace PVC stay in place and the new StatefulSet adopts them: + +```bash +kubectl -n operator delete statefulset operator --cascade=orphan +helm upgrade operator oci://ghcr.io/untra/charts/operator --namespace operator --reuse-values +``` + ## Custom trust and SSH material Mount a CA bundle that contains both public and internal roots, then configure each client that needs it: diff --git a/charts/operator/templates/statefulset.yaml b/charts/operator/templates/statefulset.yaml index 8022aa30..f2fe06c5 100644 --- a/charts/operator/templates/statefulset.yaml +++ b/charts/operator/templates/statefulset.yaml @@ -134,7 +134,7 @@ spec: - metadata: name: workspace labels: - {{- include "operator.labels" . | nindent 10 }} + {{- include "operator.selectorLabels" . | nindent 10 }} spec: accessModes: - ReadWriteOnce diff --git a/crates/relay/src/socket_path.rs b/crates/relay/src/socket_path.rs index f7f7ce74..39a79f7b 100644 --- a/crates/relay/src/socket_path.rs +++ b/crates/relay/src/socket_path.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; /// Returns the Unix socket path for the relay hub. /// -/// Priority: `$RELAY_HUB_SOCKET` → `$CLAUDE_PLUGIN_DATA/hub.sock` → `~/.claude-relay/hub.sock` +/// Priority: `$RELAY_HUB_SOCKET` > `$CLAUDE_PLUGIN_DATA/hub.sock` > `~/.claude-relay/hub.sock` /// /// This matches claude-relay's `data-dir.ts` so existing deployments find the hub /// at the same path regardless of whether the TS or Rust hub is running. diff --git a/docs/_data/navigation.yml b/docs/_data/navigation.yml index 70155210..a221676f 100644 --- a/docs/_data/navigation.yml +++ b/docs/_data/navigation.yml @@ -16,6 +16,10 @@ docs: url: /getting-started/tickets/ - title: Kanban url: /getting-started/concepts/kanban/ + - title: Campaigns + url: /getting-started/concepts/campaigns/ + - title: Governance + url: /getting-started/concepts/governance/ - title: Supported Session Management url: /getting-started/sessions/ children: diff --git a/docs/cli/index.md b/docs/cli/index.md index b9132dda..3e04f6f6 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -88,6 +88,7 @@ Import tickets from configured kanban providers (jira, linear, github, openspec) | --- | --- | | `` | Provider slug (e.g. openspec). Omit to sync every configured provider | | `` | Project/change reference (e.g. an `OpenSpec` change id, a Jira project key). Omit to sync all of the provider's configured collections | +| `--campaign` | Import an `OpenSpec` change as one campaign (Premium). Other providers set `scope` on the project's sync config instead | ### `create` @@ -154,6 +155,12 @@ Local authentication administration and recovery No additional arguments. +### `campaign` + +Inspect campaigns: epics of tickets planned into dependency waves + +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. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 2fed45e7..7e45e60e 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -46,9 +46,9 @@ macOS notification preferences | Field | Type | Default | Description | | --- | --- | --- | --- | | `enabled` * | `boolean` | true | Global enabled flag for all notifications | -| `os` | `OsNotificationConfig` | - | OS notification configuration | +| `os` | → `OsNotificationConfig` | - | OS notification configuration | | `webhook` | `any` | - | Single webhook configuration (for simple setups) | -| `webhooks` | `array`[`WebhookConfig`] | - | Multiple webhook configurations | +| `webhooks` | `array`[→ `WebhookConfig`] | - | Multiple webhook configurations | ## `[queue]` @@ -80,7 +80,7 @@ Terminal UI appearance and behavior | `refresh_rate_ms` * | `integer` | 250 | | | `completed_history_hours` * | `integer` | 24 | | | `summary_max_length` * | `integer` | 40 | | -| `panel_names` | `PanelNamesConfig` | - | | +| `panel_names` | → `PanelNamesConfig` | - | | ## `[launch]` @@ -92,8 +92,8 @@ Agent launch behavior and confirmations | `confirm_paired` * | `boolean` | true | | | `launch_delay_ms` * | `integer` | 2000 | | | `target` | `string` \| `null` | - | Default named execution target. Per-launch and per-delegator choices take precedence. | -| `docker` | `DockerConfig` | - | Docker execution configuration | -| `yolo` | `YoloConfig` | - | YOLO (auto-accept) mode configuration | +| `docker` | → `DockerConfig` | - | Docker execution configuration | +| `yolo` | → `YoloConfig` | - | YOLO (auto-accept) mode configuration | ## `[templates]` @@ -101,7 +101,7 @@ Issue type collections and presets | Field | Type | Default | Description | | --- | --- | --- | --- | -| `preset` | `CollectionPreset` | - | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | +| `preset` | → `CollectionPreset` | - | Named preset for issue type collection Options: simple, `dev_kanban`, `devops_kanban`, custom | | `collection` | `array`[`string`] | - | Custom issuetype collection (only used when preset = custom) List of issue type keys: TASK, FEAT, FIX, SPIKE, INV | | `active_collection` | `string` \| `null` | - | Active collection name (overrides preset if set) Can be a builtin preset name or a user-defined collection | | `collections_fetch_enabled` | `boolean` | - | Enable fetching hosted issuetype collections during setup. When disabled, only the embedded (offline) collections are offered. | @@ -141,8 +141,8 @@ LLM CLI tool detection and providers | Field | Type | Default | Description | | --- | --- | --- | --- | -| `detected` | `array`[`DetectedTool`] | - | Detected CLI tools (populated on first startup) | -| `providers` | `array`[`LlmProvider`] | - | Available {tool, model} pairs for launching tickets Built from detected tools + their model aliases | +| `detected` | `array`[→ `DetectedTool`] | - | Detected CLI tools (populated on first startup) | +| `providers` | `array`[→ `LlmProvider`] | - | Available {tool, model} pairs for launching tickets Built from detected tools + their model aliases | | `detection_complete` | `boolean` | - | Whether detection has been completed | | `default_tool` | `string` \| `null` | - | User's preferred default LLM tool (e.g., "claude") | | `default_model` | `string` \| `null` | - | User's preferred default model alias (e.g., "opus") | @@ -156,6 +156,7 @@ delegators = [] model_servers = [] hosts = [] targets = [] +governance = [] [profile] id = "00000000-0000-0000-0000-000000000000" @@ -201,7 +202,7 @@ poll_interval_ms = 1000 tickets = ".tickets" projects = "." state = ".tickets/operator" -worktrees = "/Users/samuelvolin/.operator/worktrees" +worktrees = "/Users/untra/.operator/worktrees" [ui] refresh_rate_ms = 250 @@ -267,6 +268,8 @@ placement = "auto" [sessions.zellij] require_in_zellij = true +[sessions.connect] + [llm_tools] detected = [] providers = [] diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 73f80f41..3514a3ab 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -43,6 +43,7 @@ A delegator bridges a tool (the binary) to a model server (the backend). If `mod | `model` | Yes | Model alias passed to the tool (e.g. `"opus"`, `"sonnet"`, `"gpt-4o"`) | | `display_name` | No | Human-readable label shown in the UI | | `model_server` | No | Name of a declared `[[model_servers]]` entry; omit to use the tool's vendor default | +| `governance` | No | Name of a `[[governance]]` policy checked on every launch by this delegator | | `model_properties` | No | Arbitrary key-value pairs forwarded to the model (e.g. `reasoning_effort = "high"`) | | `launch_config` | No | Per-delegator launch overrides (see below) | @@ -155,6 +156,25 @@ operator_relay = true # coordination-heavy delegators: enable relay operator_relay = false # single-agent task delegators: disable relay ``` +## Governance + +A delegator can name a [governance policy](/getting-started/concepts/governance/) that every one of its launches must pass - which tools, model servers and targets it may use, and how many of its tickets may run at once: + +```toml +[[governance]] +name = "local-only" +allowed_targets = ["local", "docker"] +max_parallel = 2 + +[[delegators]] +name = "claude-sonnet-auto" +llm_tool = "claude" +model = "sonnet" +governance = "local-only" +``` + +A refused launch returns `governance_denied` and starts nothing. + ## Using a custom model server To route a delegator through a local Ollama instance: diff --git a/docs/design-system/index.md b/docs/design-system/index.md index 082d0e1b..4253c316 100644 --- a/docs/design-system/index.md +++ b/docs/design-system/index.md @@ -90,6 +90,7 @@ helpers in `src/templates/mod.rs` (documented below). It follows the same |---------------|:----:|---------|:---:|:----:| | dashboard | | `dashboard` | ✓ | | | queue | | `list-ordered` | ✓ | | +| campaigns | | `milestone` | ✓ | | | config (Configuration) | | `settings-gear` | ✓ | ✓ | | connections | | `plug` | ✓ | | | kanban | | `layout` | ✓ | ✓ | @@ -100,7 +101,7 @@ helpers in `src/templates/mod.rs` (documented below). It follows the same | delegators | | `rocket` | ✓ | | | projects (Managed Projects) | | `project` | ✓ | | | agents | | `robot` | | ✓ | -| tickets | | `note` | | ✓ | +| tickets | | `note` | ✓ | ✓ | | taxonomy | | `type-hierarchy` | | ✓ | | schemas | | `bracket` | | ✓ | | shortcuts | | `keyboard` | | ✓ | diff --git a/docs/getting-started/concepts/campaigns.md b/docs/getting-started/concepts/campaigns.md new file mode 100644 index 00000000..157204c6 --- /dev/null +++ b/docs/getting-started/concepts/campaigns.md @@ -0,0 +1,176 @@ +--- +title: "Campaigns" +description: "Group many tickets into one planned body of work, ordered by their dependencies into waves that run in parallel." +layout: doc +--- + +A **campaign** is an epic of work: many [tickets](/getting-started/tickets/) planned together. Each ticket's `depends_on` links form a dependency graph, and Operator orders that graph into **waves** - every ticket in a wave can run at the same time, and each wave waits only on earlier ones. + +Campaigns are part of [Operator Premium](/getting-started/premium/). + +## A campaign file + +Campaigns live next to the ticket queue, one markdown file each: + +``` +.tickets/campaigns/CAMP-0003-pricing-overhaul.md +``` + +```yaml +--- +id: CAMP-0003 +title: Pricing overhaul +status: draft +created: 2026-10-05 +governance: team-default +workspace: + kind: coder + template: operator-agent +external_ref: jira:PROJ-1 +--- +# Pricing overhaul + +What this campaign is for, written for the agents that will work it. +``` + +| Field | Description | +|-------|-------------| +| `id` | `CAMP-NNNN`, assigned in sequence | +| `title` | Human-readable name | +| `status` | `draft`, `ready`, `running`, `paused`, `completed` or `cancelled` | +| `governance` | A [governance](/getting-started/concepts/governance/) policy layered over every delegator's own | +| `workspace` | Optional shared environment - see [Workspaces](#workspaces) | +| `external_ref` | The epic, project or parent issue the campaign was synced from | + +## Membership + +A ticket joins a campaign by naming it in its own frontmatter: + +```yaml +campaign: CAMP-0003 +depends_on: + - FEAT-0012 + - jira:PROJ-12 +``` + +The campaign file never lists its tickets - the tickets are the source of truth. + +## Waves + +Operator plans the campaign's open tickets into waves: + +- a dependency on another ticket in the campaign orders it into a later wave; +- a dependency on a ticket **outside** the campaign holds the ticket until that ticket is completed; +- a dependency on an upstream issue that isn't synced keeps the ticket blocked until it is synced, or the entry is removed; +- a dependency cycle is reported and nothing in it is planned; +- within a wave, tickets follow the same order as the [kanban board](/getting-started/concepts/kanban/#card-order). + +## Lifecycle + +| Status | Meaning | +|--------|---------| +| `draft` | Being planned. Tickets can join and leave. | +| `ready` | Planned and waiting to start. Set it by hand when a campaign is signed off. | +| `running` | Operator launches each wave as the one before it completes. | +| `paused` | No new launches. Agents already running keep running. | +| `completed` | Every member ticket is completed. | +| `cancelled` | Abandoned. Set it by hand; a cancelled campaign never starts again. | + +A campaign starts from `draft`, `ready` or `paused`. Starting it launches the first wave at once; `operator api` then launches each later wave as tickets complete. Starting a running, completed or cancelled campaign is refused. + +Operator pauses a running campaign on its own, with a reason, when: + +- its `max_launches` or `max_retries` cap is used up; +- a launch fails; +- its tickets form a dependency cycle. + +Resuming a paused campaign clears the reason. [Paired](/getting-started/agents/#autonomous-and-paired-modes) tickets are never launched automatically - the campaign reports them as **awaiting operator** until you launch them yourself. + +Campaigns launch agents unattended, so they need a session wrapper that runs without a client: tmux, zellij or cmux. A VS Code terminal wrapper is refused. + +## Planning with an agent over MCP + +An agent connected to the [Operator MCP server](/getting-started/integrations/) can plan and start a campaign on its own. The tools are meant to be called in this order: + +1. `operator_list_issue_types` - learn which templates exist (`FEAT`, `FIX`, `TASK`, ...). +2. `operator_preview_ticket_batch` - check a batch: allocated ids, waves, and any cycle or missing dependency. Nothing is written. +3. `operator_create_campaign` - create a `draft` campaign. +4. `operator_create_tickets_bulk` - write the batch inside the campaign. Every ticket is written, or none is. +5. `operator_campaign_status` - inspect the waves and what would launch first. +6. `operator_launch_campaign` - with `dry_run: true` to see violations, then for real. + +Tickets in a batch name each other by `ref`, so dependencies can be wired before any id exists. A diamond - one ticket, two that build on it, and one that joins them: + +```json +{ + "campaign": "CAMP-0003", + "tickets": [ + { "ref": "schema", "template": "TASK", "project": "api", "summary": "Add price tiers table", "values": {}, "depends_on": [] }, + { "ref": "api", "template": "FEAT", "project": "api", "summary": "Serve price tiers", "values": {}, "depends_on": ["schema"] }, + { "ref": "ui", "template": "FEAT", "project": "web", "summary": "Show price tiers", "values": {}, "depends_on": ["schema"] }, + { "ref": "launch", "template": "TASK", "project": "web", "summary": "Enable new pricing", "values": {}, "depends_on": ["api", "ui"] } + ] +} +``` + +That plans into three waves: `schema`, then `api` and `ui` together, then `launch`. `depends_on` also accepts existing ticket ids and `provider:key` references. + +Previewing and bulk-creating tickets are free. Bulk creation is off unless `[mcp].expose_ticket_write_tools = true`. Creating, launching and inspecting campaigns need Premium. + +## Watching a campaign + +- **Web UI** - the **Campaigns** page lists every campaign; each one shows its waves, what is blocked and why, and its usage against the governance caps, with Launch and Pause. +- **VS Code** - the **Campaigns** view in the Operator sidebar shows each campaign, its waves and their tickets, with launch, pause and open-in-browser actions. +- **Command line:** + +```bash +operator campaign list +operator campaign show CAMP-0003 +operator campaign launch CAMP-0003 +``` + +## Workspaces + +By default each ticket runs in its own workspace, exactly as it would outside a campaign. A campaign may instead name one shared environment for all of its tickets: + +```yaml +workspace: + kind: coder + template: operator-agent # one Coder workspace from this template +``` + +```yaml +workspace: + kind: docker + image: untra/operator:latest # one container from this image +``` + +Tickets in the same wave run in that environment in parallel, each on its own git worktree and branch. A shared Coder workspace is remote execution, so it also follows the rules for [remote targets](/getting-started/remote-targets/). + +## Governance + +A campaign's `governance` policy is stacked over the policy of each delegator that launches its tickets. The strictest rule wins, so a campaign can narrow what its tickets may use but never widen it. See [Governance](/getting-started/concepts/governance/). + +## Syncing an epic or project + +A kanban project can be synced as one campaign by giving its sync entry a `scope`. Every issue in that container is pulled in - whoever it is assigned to - and stamped with the campaign: + +```toml +[kanban.jira."acme.atlassian.net".projects."PROJ-1"] +scope = { jira_epic = "PROJ-1" } +``` + +| Scope | Members | +|-------|---------| +| `{ jira_epic = "PROJ-1" }` | The epic's child issues | +| `{ linear_project = "" }` | The project's issues | +| `{ github_parent = "owner/repo#3" }` | The parent issue's open sub-issues | +| `{ openspec_change = "" }` | The change's task groups | + +An OpenSpec change can also be imported as a campaign without any config: + +```bash +operator import openspec add-dark-mode --campaign +``` + +The first sync creates the campaign, titled after the epic, project or parent; later syncs reuse it. A ticket you move to another campaign stays where you put it. diff --git a/docs/getting-started/concepts/governance.md b/docs/getting-started/concepts/governance.md new file mode 100644 index 00000000..551049da --- /dev/null +++ b/docs/getting-started/concepts/governance.md @@ -0,0 +1,63 @@ +--- +title: "Governance" +description: "Named launch policies that limit which delegators, models and hosts an agent may use, and how many may run." +layout: doc +--- + +**Governance** is a named policy that decides whether a launch may happen. A delegator references a policy by name, and every launch through that delegator - from the TUI, web UI, VS Code, REST, MCP or CLI - is checked against it before anything is provisioned. + +```toml +[[governance]] +name = "team-default" +allowed_llm_tools = ["claude", "codex"] +allowed_model_servers = ["anthropic-api", "openai-api"] +allowed_targets = ["local", "docker"] +max_parallel = 3 + +[[delegators]] +name = "claude-opus-auto" +llm_tool = "claude" +model = "opus" +governance = "team-default" +``` + +## Fields + +Every constraint is optional. An absent constraint is unconstrained. + +| Field | Description | +|-------|-------------| +| `name` | Unique policy name, referenced by `governance` on a delegator | +| `allowed_delegators` | Delegator names allowed to launch | +| `allowed_llm_tools` | LLM tools allowed to launch (`claude`, `codex`, `gemini`, ...) | +| `allowed_model_servers` | Model servers allowed, declared or implicit (`anthropic-api`, `ollama-local`, ...) | +| `allowed_targets` | Execution targets allowed (`local`, `docker`, or a `[[targets]]` name) | +| `max_parallel` | Maximum tickets running at once under this policy | +| `max_launches` | Maximum total launches, counted per campaign run | +| `max_retries` | Maximum relaunches of a failed ticket, counted per campaign run | +| `max_runtime_minutes` | Maximum wall-clock minutes per agent | +| `budget_usd` | Spend ceiling. Reserved: accepted, not yet enforced | +| `require_confirmation` | Require operator confirmation before each launch (default `false`) | + +An empty list allows nothing: `allowed_targets = []` blocks every launch through that delegator. + +## Layering + +Policies stack. When more than one applies to a launch, they combine into one effective policy and the most restrictive rule wins: + +- allow-lists intersect, +- caps take the lowest value, +- `require_confirmation` is on if any layer turns it on. + +A delegator's own policy is always the innermost layer. Campaigns add their policy on top of it. + +## When a launch is refused + +A refused launch starts nothing: no ticket is claimed, no session or worktree is created. The REST API answers `403` with error code `governance_denied` and a message naming the policy and the rule that failed. MCP tools return the same `governance_denied: ...` text. + +A delegator that names a policy that does not exist is refused the same way - an unknown name is never a silent pass. + +## See also + +- [Delegators](/delegators/) - where a policy is attached +- [Remote Targets](/getting-started/remote-targets/) - target names a policy can allow diff --git a/docs/getting-started/concepts/index.md b/docs/getting-started/concepts/index.md index 30986b17..df450828 100644 --- a/docs/getting-started/concepts/index.md +++ b/docs/getting-started/concepts/index.md @@ -1,12 +1,14 @@ --- title: "Operator Concepts" -description: "The core ideas behind Operator: tickets, kanban, and agents." +description: "The core ideas behind Operator: tickets, kanban, campaigns, and governance." layout: doc --- -Operator! runs on two ideas: work is written down as **tickets**, and tickets move across a **kanban** board as agents pick them up and finish them. +Operator! runs on four ideas: work is written down as **tickets**, tickets move across a **kanban** board as agents pick them up and finish them, related tickets are planned together as **campaigns**, and **governance** limits what any launch may use. - **[Tickets](/getting-started/tickets/)** - the unit of work. A markdown file describing one task for an agent, carrying an issue type that decides how the work is done. - **[Kanban](/getting-started/concepts/kanban/)** - the board. Where tickets wait, get worked, and land when done. New to kanban? Start here. +- **[Campaigns](/getting-started/concepts/campaigns/)** - the plan. Many tickets grouped into one body of work, ordered by their dependencies into waves that run in parallel. +- **[Governance](/getting-started/concepts/governance/)** - the rules. Named policies that limit which delegators, models and hosts agents may use, and how many may run at once. -Everything else - [agents](/getting-started/agents/), [providers](/getting-started/kanban/), [workflows](/workflows/) - builds on these two. +Everything else - [agents](/getting-started/agents/), [providers](/getting-started/kanban/), [workflows](/workflows/) - builds on these. diff --git a/docs/getting-started/kanban/github.md b/docs/getting-started/kanban/github.md index dccda2eb..22525e69 100644 --- a/docs/getting-started/kanban/github.md +++ b/docs/getting-started/kanban/github.md @@ -226,6 +226,18 @@ The `key` field on the synced ticket follows these formats: | Pull request | `octocat/hello!42` | | Draft issue | `draft:PVTI_lAHO_xxxxxxx` | +### Relationships + +| GitHub | Ticket frontmatter | +|--------|--------------------| +| Issue dependency ("blocked by") | `depends_on: ["github:octocat/hello#9"]` | +| Sub-issues | `depends_on` - the parent waits for its sub-issues | +| Parent issue | `external_parent: github:octocat/hello#3` | + +Pull requests and draft issues carry no relationships. + +Relationships are pull-only: Operator never writes links or parents back to GitHub. On every sync, the `github:` entries in a ticket's `depends_on` are replaced with what GitHub reports now; ticket ids you added yourself are kept. A blocker that isn't synced stays in `depends_on` as `github:` - the ticket waits on it until it is synced and completed, or the entry is removed. See [Dependencies](/getting-started/tickets/#dependencies-and-campaigns). + ## Creating New Issues For v1, the GitHub Projects provider creates **draft issues only** via the `addProjectV2DraftIssue` mutation. Draft issues live inside the project (not in any repo) and can be promoted to real issues later from the GitHub UI. diff --git a/docs/getting-started/kanban/jira-api.md b/docs/getting-started/kanban/jira-api.md index 4af77586..c12873a1 100644 --- a/docs/getting-started/kanban/jira-api.md +++ b/docs/getting-started/kanban/jira-api.md @@ -25,6 +25,7 @@ Response from the JQL search endpoint. | Property | Type | Description | | --- | --- | --- | | `issues` | `JiraIssue`[] | List of issues matching the JQL query | +| `nextPageToken` | `string` (optional) | Token for the next page; absent on the last page | ## Type Definitions @@ -66,6 +67,28 @@ Fields of a Jira issue | `status` | `JiraStatusRef` | Current workflow status | | `assignee` | `JiraUser` (optional) | Assigned user (if any) | | `priority` | `JiraPriority` (optional) | Issue priority (if set) | +| `issuelinks` | `JiraIssueLink`[] | Links to other issues (blocks, relates, ...) | +| `parent` | `JiraLinkedIssue` (optional) | Parent issue or epic | +| `subtasks` | `JiraLinkedIssue`[] | Sub-task issues | + +### JiraIssueLink + +A link between two Jira issues + +| Property | Type | Description | +| --- | --- | --- | +| `type` | `JiraIssueLinkType` | Link type, e.g. `Blocks` with inward text `is blocked by` | +| `inwardIssue` | `JiraLinkedIssue` (optional) | Present when the other issue is on the inward side (e.g. the blocker) | +| `outwardIssue` | `JiraLinkedIssue` (optional) | Present when the other issue is on the outward side (e.g. the blocked) | + +### JiraIssueLinkType + +Jira issue link type + +| Property | Type | Description | +| --- | --- | --- | +| `name` | `string` | Link type name (e.g. "Blocks") | +| `inward` | `string` | Inward description (e.g. "is blocked by") | ### JiraIssueTypeRef @@ -76,6 +99,23 @@ Reference to an issue type | `id` | `string` (optional) | Issue type ID (e.g., "10001") | | `name` | `string` | Issue type name (e.g., "Bug", "Story", "Task") | +### JiraLinkedFields + +Partial fields of a related issue + +| Property | Type | Description | +| --- | --- | --- | +| `issuetype` | `JiraIssueTypeRef` (optional) | Issue type of the related issue | + +### JiraLinkedIssue + +A related issue as embedded in links, parent and subtasks + +| Property | Type | Description | +| --- | --- | --- | +| `key` | `string` | Issue key (e.g. "PROJ-12") | +| `fields` | `JiraLinkedFields` (optional) | Partial fields; only the issue type is read | + ### JiraPriority Issue priority level diff --git a/docs/getting-started/kanban/jira.md b/docs/getting-started/kanban/jira.md index 26b7bc2e..841be7f9 100644 --- a/docs/getting-started/kanban/jira.md +++ b/docs/getting-started/kanban/jira.md @@ -70,6 +70,18 @@ Operator maps Jira issue types to ticket types: | Task | FEAT | | Spike | SPIKE | +## Relationships + +| Jira | Ticket frontmatter | +|------|--------------------| +| "is blocked by" issue link (`Blocks` type) | `depends_on: ["jira:PROJ-12"]` | +| Sub-tasks | `depends_on` - the parent waits for its sub-tasks | +| Parent epic or parent issue | `external_parent: jira:PROJ-1` | + +Sync requests these fields explicitly (`issuelinks`, `parent`, `subtasks` alongside the summary, status and type), since Jira's search API returns only ids unless fields are named. + +Relationships are pull-only: Operator never writes links or parents back to Jira. On every sync, the `jira:` entries in a ticket's `depends_on` are replaced with what Jira reports now; ticket ids you added yourself are kept. A blocker that isn't synced stays in `depends_on` as `jira:` - the ticket waits on it until it is synced and completed, or the entry is removed. See [Dependencies](/getting-started/tickets/#dependencies-and-campaigns). + ## Syncing Issues Pull issues from Jira: diff --git a/docs/getting-started/kanban/linear.md b/docs/getting-started/kanban/linear.md index d24bad62..95362a1d 100644 --- a/docs/getting-started/kanban/linear.md +++ b/docs/getting-started/kanban/linear.md @@ -75,6 +75,17 @@ Operator maps Linear labels to ticket types: | improvement | FEAT | | spike | SPIKE | +## Relationships + +| Linear | Ticket frontmatter | +|--------|--------------------| +| Issue blocked by another (`blocks` relation) | `depends_on: ["linear:ENG-3"]` | +| Sub-issues | `depends_on` - the parent waits for its sub-issues | +| Project | `external_parent: linear:` | +| Parent issue (when not in a project) | `external_parent: linear:ENG-1` | + +Relationships are pull-only: Operator never writes links or parents back to Linear. On every sync, the `linear:` entries in a ticket's `depends_on` are replaced with what Linear reports now; ticket ids you added yourself are kept. A blocker that isn't synced stays in `depends_on` as `linear:` - the ticket waits on it until it is synced and completed, or the entry is removed. See [Dependencies](/getting-started/tickets/#dependencies-and-campaigns). + ## Syncing Issues Pull issues from Linear: @@ -103,7 +114,7 @@ done = "Done" # State pushed when a ticket completes `status_mapping` declares which Linear workflow state corresponds to each of operator's strict todo/doing/done states. Issues are pulled from the `todo` (and `doing`) states; with `bidirectional = true`, ticket transitions are -pushed back to the mapped states (requeue → `todo` only fires when `todo` is +pushed back to the mapped states (requeue > `todo` only fires when `todo` is mapped; unmapped `doing`/`done` fall back to `"In Progress"`/`"Done"`). Discover the team's real state names via `POST /api/v1/kanban/statuses` diff --git a/docs/getting-started/kanban/openspec.md b/docs/getting-started/kanban/openspec.md index 8685fb76..be2ced4f 100644 --- a/docs/getting-started/kanban/openspec.md +++ b/docs/getting-started/kanban/openspec.md @@ -20,6 +20,8 @@ OpenSpec stores each proposed change at `openspec/changes//` with a ` | `## 1. Group` heading in `tasks.md` | One ticket per task group | | Checklist items under the group | The ticket's task list (embedded in the body) | | All items checked | Group counts as `done` and is skipped on import | +| Group order | Each ticket `depends_on` the nearest earlier group that is still open | +| The change itself | `external_parent: openspec:` | Each imported ticket carries `external_provider: openspec` and `external_id: #` in its frontmatter, so re-running an import skips everything already in your queue - imports are idempotent. diff --git a/docs/getting-started/premium/index.md b/docs/getting-started/premium/index.md index 041e6405..06003020 100644 --- a/docs/getting-started/premium/index.md +++ b/docs/getting-started/premium/index.md @@ -18,10 +18,9 @@ Operator Premium is a paid tier that includes features ideal for larger teams or | External model APIs | Yes | Yes | | [SSH hosts](/getting-started/remote-targets/ssh/) | - | Yes | | [Coder workspaces](/getting-started/remote-targets/coder/) | - | Yes | +| [Campaigns](/getting-started/concepts/campaigns/) | - | Yes | -Reaching the dashboard over a network, and calling a model provider's API over the -internet, are not remote execution. Only running an **agent process** on another -machine requires Premium. +Running an **agent process** on another machine requires Premium. ## Installing a license @@ -31,15 +30,11 @@ A license is a single text key. Install it from either surface: section of the dashboard. - **Browser** - *License* under Premium in the sidebar. -Installing validates the key before storing it, so a rejected key leaves any -existing license in place. The key is stored in the configuration's state -directory with owner-only permissions and is never returned by the API or -written to logs. +Installing validates the key before storing it. The key is stored in the configuration's state directory with owner-only permissions and is never returned by the API or written to logs. ## How verification works -Verification is **entirely offline**. Operator never contacts a licensing -service, at install time or afterwards, and there is no activation step. +Verification is **entirely offline**. Operator never contacts a licensing service, at install time or afterwards, and there is no activation step. A license key is an token plus a root-signed attestation for the key that signed it. The token identifies customer it was issued to, its license id, the configuration it belongs to, and validity dates. @@ -56,22 +51,3 @@ Operator checks against a root public keyring compiled into the binary, then che | Invalid | Signature, issuer, tier, configuration or format rejected | Only **Premium** grants remote execution. An unrecognised tier grants nothing. - -## When a license expires - -Nothing is killed. Running agents keep running, and a completion report from an agent already in flight is still accepted and recorded. What stops is *starting* further remote work: the next launch is refused before anything is provisioned. - -Configured remote targets stay visible and readable without a license, and removing one always works. Registering, editing or probing a target requires Premium. - -## Building from source - -Verification keys are supplied at build time and are **not** in this repository, so a build from source carries none and rejects every license - Premium is unreachable in such a build, by design. This repository contains no signing key, -issuer service, checkout, or revocation service; it only *consumes* licenses issued elsewhere. - -A release binary compiles three values in, and none of them are runtime settings: - -- the issuer, the literal `operator-licensing` -- the root public keyring, from the `OPERATOR_LICENSE_ROOT_KEYS` repository variable -- the purchase URL, from the `OPERATOR_PURCHASE_URL` repository variable - -The keyring is a JSON object of key id to standard-base64 of the raw 32-byte key, for example `{"root-2026":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}`. The release build sets `OPERATOR_RELEASE=1`. If that keyring variable is empty, the build fails instead of publishing a binary that rejects every license. A source build leaves `OPERATOR_RELEASE` unset, carries no roots, and rejects every license. diff --git a/docs/getting-started/tickets/index.md b/docs/getting-started/tickets/index.md index bf36227d..96e4de91 100644 --- a/docs/getting-started/tickets/index.md +++ b/docs/getting-started/tickets/index.md @@ -76,6 +76,41 @@ cargo run -- launch Tickets can also be synced from Jira, Linear, or GitHub Projects rather than authored by hand. See [Supported Kanban Providers](/getting-started/kanban/). +## Dependencies and campaigns + +A ticket can wait on other tickets with `depends_on` in its frontmatter: + +```yaml +depends_on: + - FEAT-0012 # a local ticket id + - jira:PROJ-12 # an issue from a kanban provider +``` + +Each entry is either a local ticket id or a `provider:key` reference (`jira`, `linear`, `github`, `openspec`). Provider references match a synced ticket by its `external_id`, whatever local id it was given - so they keep working if issue type mappings change. + +Kanban sync fills `depends_on` from the provider's own relationships (blockers and sub-tasks) and records the epic, project or parent as `external_parent`. Sync only rewrites its own provider's entries; ids you add by hand are kept. A reference to an issue that isn't synced is kept, and the ticket waits on it. + +A ticket joins a [campaign](/getting-started/concepts/campaigns/) - a group of tickets planned and launched together - by naming it: + +```yaml +campaign: CAMP-0003 +``` + +Within a campaign, `depends_on` orders the tickets into waves that run in parallel. + +## Viewing a ticket + +Every ticket has a page in the web UI at `/#/tickets/` - follow a ticket id from the dashboard, the queue, a campaign or an agent. It shows: + +- the ticket's markdown, including its history; +- its steps, with the current one marked; +- what it depends on and what depends on it, each linked to its own page; +- the agent working it, with its pull request. + +From the page you can launch a queued ticket, move it to another column, and approve or reject a review the agent is waiting on. A ticket synced from Jira, Linear or GitHub links back to its issue. + +**Open in** opens the ticket file, or the agent's worktree, in VS Code, Cursor, Zed or a JetBrains IDE. VS Code and Cursor open the ticket through the [Operator extension](/getting-started/ides/vscode/); a worktree on an SSH host opens over remote SSH. **Session** reaches the agent's terminal: VS Code and Cursor reveal it through the extension, cmux and tmux focus it, tmux also offers the command to attach, and zellij names the tab to switch to. + ## Ticket Directories ``` diff --git a/docs/maturity/index.md b/docs/maturity/index.md index 11de352b..4c07fcd5 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -116,6 +116,13 @@ Operator integrates with many providers and tools across several **verticals**. | Coder | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [Coder](https://operator.untra.io/getting-started/remote-targets/coder/) | | SSH Hosts | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [SSH Hosts](https://operator.untra.io/getting-started/remote-targets/ssh/) | +## Operator Feature + +| Integration | Status | Availability | Docs | +|---|---|---|---| +| Governance | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Included | [Governance](https://operator.untra.io/getting-started/concepts/governance/) | +| Campaigns | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | Premium | [Campaigns](https://operator.untra.io/getting-started/concepts/campaigns/) | + ## LLM tool capabilities Advertising status (GA/Beta/Alpha) is not the same as a live connection probe. LLM tool **health** is `path-version`: the binary is on PATH. That is weaker than a model provider's `/models` probe, which proves an API key is accepted. diff --git a/docs/schemas/config.json b/docs/schemas/config.json index 8d470c22..c5230103 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -84,6 +84,10 @@ }, "zellij": { "require_in_zellij": true + }, + "connect": { + "preferred": null, + "terminal_command": null } } }, @@ -190,6 +194,14 @@ }, "default": [] }, + "governance": { + "description": "Named launch policies referenced by `Delegator.governance`.", + "type": "array", + "items": { + "$ref": "#/$defs/GovernancePolicy" + }, + "default": [] + }, "relay": { "description": "Relay MCP injection configuration", "$ref": "#/$defs/RelayConfig", @@ -215,6 +227,13 @@ "default_delegator": null, "max_concurrent_sessions": 8 } + }, + "native_llm": { + "description": "In-daemon LLM calls (judge). Accepted but inert until the `native-llm`\nbuild feature ships; a configured judge falls back to the deterministic rule.", + "$ref": "#/$defs/NativeLlmConfig", + "default": { + "judge": null + } } }, "required": [ @@ -475,7 +494,7 @@ "worktrees": { "description": "Base directory for per-ticket worktrees (default: ~/.operator/worktrees)", "type": "string", - "default": "/Users/samuelvolin/.operator/worktrees" + "default": "/Users/untra/.operator/worktrees" } }, "required": [ @@ -797,6 +816,14 @@ "default": { "require_in_zellij": true } + }, + "connect": { + "description": "How the UI connects you to a launched agent's session", + "$ref": "#/$defs/SessionsConnectConfig", + "default": { + "preferred": null, + "terminal_command": null + } } } }, @@ -914,6 +941,82 @@ } } }, + "SessionsConnectConfig": { + "description": "Connect preferences; the offered interfaces are derived from what is configured and installed", + "type": "object", + "properties": { + "preferred": { + "description": "Primary interface for connect links; unset follows the agent's session wrapper", + "anyOf": [ + { + "$ref": "#/$defs/ConnectInterface" + }, + { + "type": "null" + } + ], + "default": null + }, + "terminal_command": { + "description": "Command that opens a native terminal running `{command}` (e.g. `open -na Ghostty --args -e {command}`); unset disables server-side terminal opening", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "ConnectInterface": { + "description": "Interface used to open a launched agent's session", + "oneOf": [ + { + "description": "VS Code, via the Operator extension", + "type": "string", + "const": "vscode" + }, + { + "description": "Cursor, via the Operator extension", + "type": "string", + "const": "cursor" + }, + { + "description": "Zed (opens the folder only)", + "type": "string", + "const": "zed" + }, + { + "description": "Coder's VS Code extension", + "type": "string", + "const": "coder" + }, + { + "description": "cmux workspace focus", + "type": "string", + "const": "cmux" + }, + { + "description": "tmux attach", + "type": "string", + "const": "tmux" + }, + { + "description": "zellij attach", + "type": "string", + "const": "zellij" + }, + { + "description": "Plain SSH into the remote session", + "type": "string", + "const": "ssh" + }, + { + "description": "Native terminal running the attach or resume command", + "type": "string", + "const": "terminal" + } + ] + }, "LlmToolsConfig": { "description": "LLM CLI tools configuration", "type": "object", @@ -1516,6 +1619,17 @@ "string", "null" ] + }, + "scope": { + "description": "Sync one upstream container into a campaign instead of the whole project (Premium).\nScoped syncs pull every member regardless of assignee.", + "anyOf": [ + { + "$ref": "#/$defs/SyncScope" + }, + { + "type": "null" + } + ] } } }, @@ -1546,6 +1660,63 @@ } } }, + "SyncScope": { + "description": "An upstream container synced as one campaign.", + "oneOf": [ + { + "description": "A Jira epic (or any parent issue) by key, e.g. `PROJ-1`; children are its members.", + "type": "object", + "properties": { + "jira_epic": { + "type": "string" + } + }, + "required": [ + "jira_epic" + ], + "additionalProperties": false + }, + { + "description": "A Linear project by id; its issues are the members.", + "type": "object", + "properties": { + "linear_project": { + "type": "string" + } + }, + "required": [ + "linear_project" + ], + "additionalProperties": false + }, + { + "description": "A GitHub parent issue as `owner/repo#N`; its sub-issues are the members.", + "type": "object", + "properties": { + "github_parent": { + "type": "string" + } + }, + "required": [ + "github_parent" + ], + "additionalProperties": false + }, + { + "description": "An OpenSpec change by id; its task groups are the members.", + "type": "object", + "properties": { + "openspec_change": { + "type": "string" + } + }, + "required": [ + "openspec_change" + ], + "additionalProperties": false + } + ] + }, "LinearConfig": { "description": "Linear provider configuration\n\nThe workspace slug is specified as the `HashMap` key in KanbanConfig.linear", "type": "object", @@ -1644,7 +1815,7 @@ } }, "Delegator": { - "description": "Agent delegator configuration for autonomous ticket launching\n\nA delegator is a named {tool, model} pairing with optional launch configuration\nthat can be used to launch agents for tickets.", + "description": "Agent delegator configuration for autonomous ticket launching\n\nA delegator is a named {tool, model} pairing with optional launch configuration.", "type": "object", "properties": { "git": { @@ -1706,6 +1877,13 @@ ], "default": null }, + "governance": { + "description": "Name of a `[[governance]]` policy enforced on every launch by this delegator.", + "type": [ + "string", + "null" + ] + }, "remote_agent": { "description": "Declarative reference to a remote, named agent on another platform\n(e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]).\n\nExport-only: Operator has no runtime client for those platforms, so a\ndelegator carrying this CANNOT be launched locally - resolution errors out\n(see `delegator_resolution`). It is stored, listed, serialized into an\n`AgentProfile`, and - for `platform == \"agnt\"` - surfaced in the\n`--format agnt` workflow export as a native AGNT `agnt-agent` node, whose\n`agentId` is this reference's `id` (AGNT identifies agents by UUID, so the\n`id` must be the agent's UUID, not its display name). `None` = ordinary,\nlocally launchable delegator.", "anyOf": [ @@ -1911,7 +2089,7 @@ ] }, "ModelServer": { - "description": "A named host that serves models via an inference API.\n\nModel servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI\n(`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint\n(`model_server`, e.g. ollama-local, openai-api, a custom vllm host).\n\nImplicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are\nreturned by [`implicit_model_server_for_tool`] and do not need to be declared\nin config.", + "description": "A named host that serves models via an inference API.\n\nModel servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI\n(`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint\n(`model_server`, e.g. ollama-local, openai-api, a custom vllm host).\n\nImplicit builtin servers are returned by [`implicit_model_server_for_tool`] for shipped tools.", "type": "object", "properties": { "name": { @@ -2161,6 +2339,108 @@ "workdir" ] }, + "GovernancePolicy": { + "description": "A named launch policy. Every constraint is optional; an absent one is unconstrained.", + "type": "object", + "properties": { + "name": { + "description": "Unique name, referenced by `Delegator.governance`.", + "type": "string" + }, + "allowed_delegators": { + "description": "Delegator names allowed to launch. Absent = any.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowed_llm_tools": { + "description": "LLM tool names allowed to launch (e.g. `claude`). Absent = any.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowed_model_servers": { + "description": "Model server names allowed (declared or implicit, e.g. `anthropic-api`). Absent = any.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowed_targets": { + "description": "Execution target names allowed (e.g. `local`, `docker`, a `[[targets]]` name). Absent = any.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "max_parallel": { + "description": "Maximum concurrently running agents under this policy.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "max_launches": { + "description": "Maximum total launches under this policy (counted per campaign run).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "max_retries": { + "description": "Maximum relaunches of a failed ticket (counted per campaign run).", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "max_runtime_minutes": { + "description": "Maximum wall-clock minutes per agent.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "budget_usd": { + "description": "Spend ceiling in USD. Reserved: accepted and reported, not yet enforced.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "require_confirmation": { + "description": "Require operator confirmation before each launch.", + "type": "boolean", + "default": false + } + }, + "required": [ + "name" + ] + }, "RelayConfig": { "description": "Relay MCP injection configuration", "type": "object", @@ -2275,6 +2555,48 @@ } }, "additionalProperties": false + }, + "NativeLlmConfig": { + "description": "In-daemon LLM calls (built with the `native-llm` feature). Distinct from\ndelegators: these are single typed API calls, not agent CLI sessions.", + "type": "object", + "properties": { + "judge": { + "description": "Model that picks the winner of `multi_model` (`voting_mode = single_judge`)\nand `multi_prompt` (`selection_strategy = model_choice`) steps. Unset keeps\nthe deterministic first/longest rule.", + "anyOf": [ + { + "$ref": "#/$defs/JudgeConfig" + }, + { + "type": "null" + } + ], + "default": null + } + } + }, + "JudgeConfig": { + "type": "object", + "properties": { + "model_server": { + "description": "Name of a declared or implicit model server (e.g. \"anthropic-api\")", + "type": "string" + }, + "model": { + "description": "Full API model id (e.g. \"claude-sonnet-5\"), not a CLI alias like \"sonnet\"", + "type": "string" + }, + "timeout_secs": { + "description": "Seconds before the judge is abandoned and the deterministic rule applies", + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 120 + } + }, + "required": [ + "model_server", + "model" + ] } } } \ No newline at end of file diff --git a/docs/schemas/config.md b/docs/schemas/config.md index cc639493..28076e10 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -49,9 +49,11 @@ JSON Schema for the Operator configuration file (`config.toml`). | `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. | | `hosts` | `array` | No | Remote machines agents can be launched on over SSH, referenced by name from `DelegatorLaunchConfig.host`. | | `targets` | `array` | No | Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. | +| `governance` | `array` | No | Named launch policies referenced by `Delegator.governance`. | | `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 | +| `native_llm` | → `NativeLlmConfig` | No | In-daemon LLM calls (judge). Accepted but inert until the `native-llm` build feature ships; a configured judge falls back to the deterministic rule. | ## Type Definitions @@ -242,6 +244,7 @@ Four modes are supported: | `vscode` | → `SessionsVSCodeConfig` | No | VS Code-specific configuration | | `cmux` | → `SessionsCmuxConfig` | No | cmux-specific configuration | | `zellij` | → `SessionsZellijConfig` | No | Zellij-specific configuration | +| `connect` | → `SessionsConnectConfig` | No | How the UI connects you to a launched agent's session | ### SessionWrapperType @@ -300,6 +303,31 @@ Zellij terminal workspace manager session configuration | --- | --- | --- | --- | | `require_in_zellij` | `boolean` | No | Require running inside Zellij (ZELLIJ env var present) | +### SessionsConnectConfig + +Connect preferences; the offered interfaces are derived from what is configured and installed + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `preferred` | object | No | Primary interface for connect links; unset follows the agent's session wrapper | +| `terminal_command` | `string` \| `null` | No | Command that opens a native terminal running `{command}` (e.g. `open -na Ghostty --args -e {command}`); unset disables server-side terminal opening | + +### ConnectInterface + +Interface used to open a launched agent's session + +**Allowed Values:** + +- `vscode` - VS Code, via the Operator extension +- `cursor` - Cursor, via the Operator extension +- `zed` - Zed (opens the folder only) +- `coder` - Coder's VS Code extension +- `cmux` - cmux workspace focus +- `tmux` - tmux attach +- `zellij` - zellij attach +- `ssh` - Plain SSH into the remote session +- `terminal` - Native terminal running the attach or resume command + ### LlmToolsConfig LLM CLI tools configuration @@ -494,6 +522,7 @@ Per-project/team sync configuration for a kanban provider | `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. | | `ticket_project` | `string` \| `null` | No | Operator project name stamped on tickets created from this source. Defaults to the external project key when unset. | +| `scope` | object | No | Sync one upstream container into a campaign instead of the whole project (Premium). Scoped syncs pull every member regardless of assignee. | ### KanbanStatusMapping @@ -512,6 +541,13 @@ back to `todo` when requeued. Unset fields fall back per-transition | `doing` | `string` \| `null` | No | External column for operator "doing" (claimed/launched tickets) | | `done` | `string` \| `null` | No | External column for operator "done" (completed tickets) | +### SyncScope + +An upstream container synced as one campaign. + +**Allowed Values:** + + ### LinearConfig Linear provider configuration @@ -572,8 +608,7 @@ Version check configuration for automatic update notifications Agent delegator configuration for autonomous ticket launching -A delegator is a named {tool, model} pairing with optional launch configuration -that can be used to launch agents for tickets. +A delegator is a named {tool, model} pairing with optional launch configuration. | Property | Type | Required | Description | | --- | --- | --- | --- | @@ -585,6 +620,7 @@ that can be used to launch agents for tickets. | `model_properties` | `object` | No | Arbitrary model properties (e.g., `reasoning_effort`, sandbox) | | `launch_config` | object | No | Optional launch configuration | | `model_server` | `string` \| `null` | No | Name of a declared `ModelServer` (from `Config.model_servers`). `None` means use the `llm_tool`'s implicit vendor default (claude → anthropic-api, codex → openai-api, gemini → google-api). | +| `governance` | `string` \| `null` | No | Name of a `[[governance]]` policy enforced on every launch by this delegator. | | `remote_agent` | object | No | Declarative reference to a remote, named agent on another platform (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). Export-only: Operator has no runtime client for those platforms, so a delegator carrying this CANNOT be launched locally - resolution errors out (see `delegator_resolution`). It is stored, listed, serialized into an `AgentProfile`, and - for `platform == "agnt"` - surfaced in the `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the `id` must be the agent's UUID, not its display name). `None` = ordinary, locally launchable delegator. | | `x_agnt` | object | No | Opaque AGNT-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, `assignedWorkflows`, `creditLimit`). Operator never interprets this. | | `x_openai` | object | No | Opaque OpenAI-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. | @@ -661,9 +697,7 @@ Model servers are orthogonal to `llm_tools`: a delegator pairs an agentic CLI (`llm_tool`, e.g. claude/codex/gemini) with a model-serving endpoint (`model_server`, e.g. ollama-local, openai-api, a custom vllm host). -Implicit builtin servers (`anthropic-api`, `openai-api`, `google-api`) are -returned by [`implicit_model_server_for_tool`] and do not need to be declared -in config. +Implicit builtin servers are returned by [`implicit_model_server_for_tool`] for shipped tools. | Property | Type | Required | Description | | --- | --- | --- | --- | @@ -732,6 +766,24 @@ connection shape (`RemoteHost` minus identity). | `workdir` | `string` | Yes | Absolute project root on the remote machine | | `ssh_config_path` | `string` \| `null` | No | SSH config fragment passed with `-F` (used by provisioned coder aliases) | +### GovernancePolicy + +A named launch policy. Every constraint is optional; an absent one is unconstrained. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | Unique name, referenced by `Delegator.governance`. | +| `allowed_delegators` | `array` \| `null` | No | Delegator names allowed to launch. Absent = any. | +| `allowed_llm_tools` | `array` \| `null` | No | LLM tool names allowed to launch (e.g. `claude`). Absent = any. | +| `allowed_model_servers` | `array` \| `null` | No | Model server names allowed (declared or implicit, e.g. `anthropic-api`). Absent = any. | +| `allowed_targets` | `array` \| `null` | No | Execution target names allowed (e.g. `local`, `docker`, a `[[targets]]` name). Absent = any. | +| `max_parallel` | `integer` \| `null` | No | Maximum concurrently running agents under this policy. | +| `max_launches` | `integer` \| `null` | No | Maximum total launches under this policy (counted per campaign run). | +| `max_retries` | `integer` \| `null` | No | Maximum relaunches of a failed ticket (counted per campaign run). | +| `max_runtime_minutes` | `integer` \| `null` | No | Maximum wall-clock minutes per agent. | +| `budget_usd` | `number` \| `null` | No | Spend ceiling in USD. Reserved: accepted and reported, not yet enforced. | +| `require_confirmation` | `boolean` | No | Require operator confirmation before each launch. | + ### RelayConfig Relay MCP injection configuration @@ -786,3 +838,20 @@ maps to an in-progress ACP ticket and a delegator subprocess. | `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. | +### NativeLlmConfig + +In-daemon LLM calls (built with the `native-llm` feature). Distinct from +delegators: these are single typed API calls, not agent CLI sessions. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `judge` | object | No | Model that picks the winner of `multi_model` (`voting_mode = single_judge`) and `multi_prompt` (`selection_strategy = model_choice`) steps. Unset keeps the deterministic first/longest rule. | + +### JudgeConfig + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `model_server` | `string` | Yes | Name of a declared or implicit model server (e.g. "anthropic-api") | +| `model` | `string` | Yes | Full API model id (e.g. "claude-sonnet-5"), not a CLI alias like "sonnet" | +| `timeout_secs` | `integer` | No | Seconds before the judge is abandoned and the deterministic rule applies | + diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md index 6fc5a0fe..07ad7367 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -410,7 +410,9 @@ step; iteration is an intra-step concern, never a step-to-step edge. ### Definition: ItemSource -Where a pipeline's iterated items come from. The variant determines *when* the list resolves: export-time vs runtime. +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 diff --git a/docs/schemas/jira-api.json b/docs/schemas/jira-api.json index fb904c10..41b5fdaa 100644 --- a/docs/schemas/jira-api.json +++ b/docs/schemas/jira-api.json @@ -14,6 +14,14 @@ "items": { "$ref": "#/$defs/JiraIssue" } + }, + "nextPageToken": { + "description": "Token for the next page; absent on the last page", + "type": [ + "string", + "null" + ], + "default": null } }, "required": [ @@ -91,6 +99,34 @@ "type": "null" } ] + }, + "issuelinks": { + "description": "Links to other issues (blocks, relates, ...)", + "type": "array", + "items": { + "$ref": "#/$defs/JiraIssueLink" + }, + "default": [] + }, + "parent": { + "description": "Parent issue or epic", + "anyOf": [ + { + "$ref": "#/$defs/JiraLinkedIssue" + }, + { + "type": "null" + } + ], + "default": null + }, + "subtasks": { + "description": "Sub-task issues", + "type": "array", + "items": { + "$ref": "#/$defs/JiraLinkedIssue" + }, + "default": [] } }, "required": [ @@ -208,6 +244,104 @@ "required": [ "name" ] + }, + "JiraIssueLink": { + "description": "A link between two Jira issues", + "type": "object", + "properties": { + "type": { + "description": "Link type, e.g. `Blocks` with inward text `is blocked by`", + "$ref": "#/$defs/JiraIssueLinkType" + }, + "inwardIssue": { + "description": "Present when the other issue is on the inward side (e.g. the blocker)", + "anyOf": [ + { + "$ref": "#/$defs/JiraLinkedIssue" + }, + { + "type": "null" + } + ], + "default": null + }, + "outwardIssue": { + "description": "Present when the other issue is on the outward side (e.g. the blocked)", + "anyOf": [ + { + "$ref": "#/$defs/JiraLinkedIssue" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "type" + ] + }, + "JiraIssueLinkType": { + "description": "Jira issue link type", + "type": "object", + "properties": { + "name": { + "description": "Link type name (e.g. \"Blocks\")", + "type": "string" + }, + "inward": { + "description": "Inward description (e.g. \"is blocked by\")", + "type": "string", + "default": "" + } + }, + "required": [ + "name" + ] + }, + "JiraLinkedIssue": { + "description": "A related issue as embedded in links, parent and subtasks", + "type": "object", + "properties": { + "key": { + "description": "Issue key (e.g. \"PROJ-12\")", + "type": "string" + }, + "fields": { + "description": "Partial fields; only the issue type is read", + "anyOf": [ + { + "$ref": "#/$defs/JiraLinkedFields" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "key" + ] + }, + "JiraLinkedFields": { + "description": "Partial fields of a related issue", + "type": "object", + "properties": { + "issuetype": { + "description": "Issue type of the related issue", + "anyOf": [ + { + "$ref": "#/$defs/JiraIssueTypeRef" + }, + { + "type": "null" + } + ], + "default": null + } + } } } } \ No newline at end of file diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index a303680a..f0ad09ee 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -34,6 +34,9 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc | `external_id` | `string` | No | External issue ID from the kanban provider (e.g., PROJ-123 for Jira, ENG-456 for Linear) | | `external_url` | `string` (uri) | No | Full URL to the issue in the external provider's web UI | | `external_provider` | `string` | No | Provider name for the external issue (e.g., jira, linear) | +| `campaign` | `string` | No | Campaign this ticket belongs to. A campaign groups tickets into one planned body of work. | +| `depends_on` | `array` | No | Tickets that must complete before this one can launch. Each entry is a local ticket id, or a `provider:key` reference to an upstream issue (kept even when that issue is not synced). Provider references are refreshed on every kanban sync; local ids are never touched by sync. | +| `external_parent` | `string` | No | Upstream epic, project, or parent issue as a `provider:key` reference. Pulled from the kanban provider on sync. | | `step_delegators` | `object` | No | Step name to delegator name mapping. Populated when a step launches; used for bidirectional kanban activity logs. | | `sessions` | `object` | No | Step name to LLM session UUID mapping. Each step gets its own session ID for continuity. | | `llm_task` | `object` | No | LLM task metadata for delegate mode integration | @@ -109,6 +112,25 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc - **Type**: `string` - **Examples**: `jira`, `linear` +### campaign + +- **Description**: Campaign this ticket belongs to. A campaign groups tickets into one planned body of work. +- **Type**: `string` +- **Pattern**: `^CAMP-\d+$` +- **Examples**: `CAMP-0003` + +### depends_on + +- **Description**: Tickets that must complete before this one can launch. Each entry is a local ticket id, or a `provider:key` reference to an upstream issue (kept even when that issue is not synced). Provider references are refreshed on every kanban sync; local ids are never touched by sync. +- **Type**: `array` +- **Examples**: `["FEAT-0012","jira:PROJ-12","linear:ENG-7","github:octo/hello#12"]` + +### external_parent + +- **Description**: Upstream epic, project, or parent issue as a `provider:key` reference. Pulled from the kanban provider on sync. +- **Type**: `string` +- **Examples**: `jira:PROJ-1`, `linear:ENG-40`, `openspec:add-dark-mode` + ### step_delegators - **Description**: Step name to delegator name mapping. Populated when a step launches; used for bidirectional kanban activity logs. diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 60c84734..3d441f2b 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -175,13 +175,71 @@ "x-operator-scope": "write" } }, + "/api/v1/agents/{agent_id}/connect/terminal": { + "get": { + "tags": [ + "Agents" + ], + "summary": "What an IDE extension runs after opening an agent's workspace", + "description": "Returns the attach command while the session is live, or the LLM resume\ncommand once it is gone. Deep links never carry commands; this is the only source.", + "operationId": "agents_connect_terminal", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "description": "The agent ID to connect to", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Terminal to open", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectTerminal" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, "/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.", + "description": "cmux focuses the agent's workspace; tmux switches the attached client to the\nagent's session and window. zellij can't be focused from outside, and VS Code\nfocuses through its extension's URI handler instead.", "operationId": "agents_focus_session", "parameters": [ { @@ -232,6 +290,16 @@ } } } + }, + "409": { + "description": "Session not reachable; attach to it instead", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -1199,23 +1267,19 @@ "security": [] } }, - "/api/v1/collections": { + "/api/v1/campaigns": { "get": { "tags": [ - "Collections" + "Campaigns" ], - "summary": "List all collections", - "operationId": "collections_list", + "operationId": "campaigns_list", "responses": { "200": { - "description": "List of all collections", + "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CollectionResponse" - } + "$ref": "#/components/schemas/CampaignListResponse" } } } @@ -1223,6 +1287,16 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "description": "Premium required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "403": { "$ref": "#/components/responses/Forbidden" } @@ -1236,22 +1310,40 @@ } ], "x-operator-scope": "read" - } - }, - "/api/v1/collections/active": { - "get": { + }, + "post": { "tags": [ - "Collections" + "Campaigns" ], - "summary": "Get the currently active collection", - "operationId": "collections_get_active", + "operationId": "campaigns_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewCampaignInput" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Active collection", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionResponse" + "$ref": "#/components/schemas/Campaign" } } } @@ -1259,11 +1351,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "description": "No active collection", + "402": { + "description": "Premium required", "content": { "application/json": { "schema": { @@ -1271,6 +1360,9 @@ } } } + }, + "403": { + "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -1281,21 +1373,19 @@ "sessionCookie": [] } ], - "x-operator-scope": "read" + "x-operator-scope": "write" } }, - "/api/v1/collections/{name}": { + "/api/v1/campaigns/{id}": { "get": { "tags": [ - "Collections" + "Campaigns" ], - "summary": "Get a single collection by name", - "operationId": "collections_get_one", + "operationId": "campaigns_get", "parameters": [ { - "name": "name", + "name": "id", "in": "path", - "description": "Collection name", "required": true, "schema": { "type": "string" @@ -1304,11 +1394,11 @@ ], "responses": { "200": { - "description": "Collection details", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionResponse" + "$ref": "#/components/schemas/CampaignDetail" } } } @@ -1316,11 +1406,8 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "description": "Collection not found", + "402": { + "description": "Premium required", "content": { "application/json": { "schema": { @@ -1328,6 +1415,9 @@ } } } + }, + "403": { + "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -1341,18 +1431,17 @@ "x-operator-scope": "read" } }, - "/api/v1/collections/{name}/activate": { - "put": { + "/api/v1/campaigns/{id}/launch": { + "post": { "tags": [ - "Collections" + "Campaigns" ], - "summary": "Activate a collection", - "operationId": "collections_activate", + "summary": "Start a campaign and launch its first wave; `dry_run` only reports.", + "operationId": "campaigns_launch", "parameters": [ { - "name": "name", + "name": "id", "in": "path", - "description": "Collection name to activate", "required": true, "schema": { "type": "string" @@ -1368,13 +1457,23 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchCampaignRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Collection activated", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectionResponse" + "$ref": "#/components/schemas/CampaignLaunchOutcome" } } } @@ -1382,11 +1481,21 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "description": "Premium required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "description": "Collection not found", + "409": { + "description": "Campaign cannot start from its current status", "content": { "application/json": { "schema": { @@ -1404,22 +1513,42 @@ "sessionCookie": [] } ], - "x-operator-scope": "write" + "x-operator-scope": "execute" } }, - "/api/v1/configuration": { - "get": { + "/api/v1/campaigns/{id}/pause": { + "post": { "tags": [ - "Configuration" + "Campaigns" + ], + "summary": "Stop new launches; running agents continue.", + "operationId": "campaigns_pause", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } ], - "operationId": "configuration_get", "responses": { "200": { - "description": "Supported operational configuration", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfigurationResponse" + "$ref": "#/components/schemas/TickReport" } } } @@ -1427,6 +1556,16 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "description": "Premium required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "403": { "$ref": "#/components/responses/Forbidden" } @@ -1439,47 +1578,41 @@ "sessionCookie": [] } ], - "x-operator-scope": "admin" - }, - "patch": { + "x-operator-scope": "execute" + } + }, + "/api/v1/campaigns/{id}/status": { + "get": { "tags": [ - "Configuration" + "Campaigns" ], - "operationId": "configuration_patch", + "operationId": "campaigns_status", "parameters": [ { - "name": "x-operator-csrf", - "in": "header", - "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", - "required": false, + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateConfigurationRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Updated operational configuration", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConfigurationResponse" + "$ref": "#/components/schemas/CampaignStatusView" } } } }, - "400": { - "description": "Invalid, empty, or null-valued patch", + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "description": "Premium required", "content": { "application/json": { "schema": { @@ -1488,9 +1621,6 @@ } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, "403": { "$ref": "#/components/responses/Forbidden" } @@ -1503,23 +1633,26 @@ "sessionCookie": [] } ], - "x-operator-scope": "admin" + "x-operator-scope": "read" } }, - "/api/v1/delegators": { + "/api/v1/collections": { "get": { "tags": [ - "Delegators" + "Collections" ], - "summary": "List all configured delegators", - "operationId": "delegators_list", + "summary": "List all collections", + "operationId": "collections_list", "responses": { "200": { - "description": "List of delegators", + "description": "List of all collections", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DelegatorsResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionResponse" + } } } } @@ -1540,41 +1673,79 @@ } ], "x-operator-scope": "read" - }, - "post": { + } + }, + "/api/v1/collections/active": { + "get": { "tags": [ - "Delegators" + "Collections" ], - "summary": "Create a new delegator", - "operationId": "delegators_create", + "summary": "Get the currently active collection", + "operationId": "collections_get_active", + "responses": { + "200": { + "description": "Active collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "No active collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/collections/{name}": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a single collection by name", + "operationId": "collections_get_one", "parameters": [ { - "name": "x-operator-csrf", - "in": "header", - "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", - "required": false, + "name": "name", + "in": "path", + "description": "Collection name", + "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDelegatorRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Delegator created", + "description": "Collection details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DelegatorResponse" + "$ref": "#/components/schemas/CollectionResponse" } } } @@ -1585,8 +1756,8 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { - "description": "Delegator already exists", + "404": { + "description": "Collection not found", "content": { "application/json": { "schema": { @@ -1604,18 +1775,26 @@ "sessionCookie": [] } ], - "x-operator-scope": "admin" + "x-operator-scope": "read" } }, - "/api/v1/delegators/from-tool": { - "post": { + "/api/v1/collections/{name}/activate": { + "put": { "tags": [ - "Delegators" + "Collections" ], - "summary": "Create a delegator from a detected LLM tool", - "description": "Pre-populates delegator fields from the detected tool, requiring minimal input.", - "operationId": "delegators_create_from_tool", + "summary": "Activate a collection", + "operationId": "collections_activate", "parameters": [ + { + "name": "name", + "in": "path", + "description": "Collection name to activate", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "x-operator-csrf", "in": "header", @@ -1626,23 +1805,13 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDelegatorFromToolRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Delegator created from tool", + "description": "Collection activated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DelegatorResponse" + "$ref": "#/components/schemas/CollectionResponse" } } } @@ -1654,7 +1823,7 @@ "$ref": "#/components/responses/Forbidden" }, "404": { - "description": "Tool not detected", + "description": "Collection not found", "content": { "application/json": { "schema": { @@ -1662,16 +1831,41 @@ } } } + } + }, + "security": [ + { + "bearerAuth": [] }, - "409": { - "description": "Delegator already exists", + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/configuration": { + "get": { + "tags": [ + "Configuration" + ], + "operationId": "configuration_get", + "responses": { + "200": { + "description": "Supported operational configuration", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ConfigurationResponse" } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -1683,16 +1877,12 @@ } ], "x-operator-scope": "admin" - } - }, - "/api/v1/delegators/import-profile": { - "post": { + }, + "patch": { "tags": [ - "Delegators" + "Configuration" ], - "summary": "Import an `AgentProfile` as a new delegator.", - "description": "The shared-core fields Operator can't model and the opaque `x_agnt` bag are\npreserved on the created delegator so a later export round-trips losslessly.", - "operationId": "delegators_import_profile", + "operationId": "configuration_patch", "parameters": [ { "name": "x-operator-csrf", @@ -1708,7 +1898,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentProfile" + "$ref": "#/components/schemas/UpdateConfigurationRequest" } } }, @@ -1716,23 +1906,17 @@ }, "responses": { "200": { - "description": "Delegator created from profile", + "description": "Updated operational configuration", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DelegatorResponse" + "$ref": "#/components/schemas/ConfigurationResponse" } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "409": { - "description": "Delegator already exists", + "400": { + "description": "Invalid, empty, or null-valued patch", "content": { "application/json": { "schema": { @@ -1740,6 +1924,12 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -1753,31 +1943,20 @@ "x-operator-scope": "admin" } }, - "/api/v1/delegators/{name}": { + "/api/v1/delegators": { "get": { "tags": [ "Delegators" ], - "summary": "Get a single delegator by name", - "operationId": "delegators_get_one", - "parameters": [ - { - "name": "name", - "in": "path", - "description": "Delegator name", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "List all configured delegators", + "operationId": "delegators_list", "responses": { "200": { - "description": "Delegator details", + "description": "List of delegators", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DelegatorResponse" + "$ref": "#/components/schemas/DelegatorsResponse" } } } @@ -1787,16 +1966,6 @@ }, "403": { "$ref": "#/components/responses/Forbidden" - }, - "404": { - "description": "Delegator not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } }, "security": [ @@ -1809,22 +1978,13 @@ ], "x-operator-scope": "read" }, - "put": { + "post": { "tags": [ "Delegators" ], - "summary": "Update an existing delegator", - "operationId": "delegators_update", + "summary": "Create a new delegator", + "operationId": "delegators_create", "parameters": [ - { - "name": "name", - "in": "path", - "description": "Delegator name", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "x-operator-csrf", "in": "header", @@ -1847,7 +2007,7 @@ }, "responses": { "200": { - "description": "Delegator updated", + "description": "Delegator created", "content": { "application/json": { "schema": { @@ -1862,8 +2022,8 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "description": "Delegator not found", + "409": { + "description": "Delegator already exists", "content": { "application/json": { "schema": { @@ -1882,23 +2042,17 @@ } ], "x-operator-scope": "admin" - }, - "delete": { + } + }, + "/api/v1/delegators/from-tool": { + "post": { "tags": [ "Delegators" ], - "summary": "Delete a delegator by name", - "operationId": "delegators_delete", + "summary": "Create a delegator from a detected LLM tool", + "description": "Pre-populates delegator fields from the detected tool, requiring minimal input.", + "operationId": "delegators_create_from_tool", "parameters": [ - { - "name": "name", - "in": "path", - "description": "Delegator name", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "x-operator-csrf", "in": "header", @@ -1909,9 +2063,19 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDelegatorFromToolRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Delegator deleted", + "description": "Delegator created from tool", "content": { "application/json": { "schema": { @@ -1927,7 +2091,17 @@ "$ref": "#/components/responses/Forbidden" }, "404": { - "description": "Delegator not found", + "description": "Tool not detected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Delegator already exists", "content": { "application/json": { "schema": { @@ -1948,32 +2122,42 @@ "x-operator-scope": "admin" } }, - "/api/v1/delegators/{name}/profile": { - "get": { + "/api/v1/delegators/import-profile": { + "post": { "tags": [ "Delegators" ], - "summary": "Export a delegator as a portable `AgentProfile` (`agent-profile.json`).", - "description": "The shared core plus the Operator-namespaced `x_operator` bag; any opaque\nAGNT (`x_agnt`) and shared-core carry the delegator holds are restored so the\nprofile is a lossless interchange artifact.", - "operationId": "delegators_export_profile", + "summary": "Import an `AgentProfile` as a new delegator.", + "description": "The shared-core fields Operator can't model and the opaque `x_agnt` bag are\npreserved on the created delegator so a later export round-trips losslessly.", + "operationId": "delegators_import_profile", "parameters": [ { - "name": "name", - "in": "path", - "description": "Delegator name", - "required": true, + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentProfile" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Agent profile", + "description": "Delegator created from profile", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentProfile" + "$ref": "#/components/schemas/DelegatorResponse" } } } @@ -1984,8 +2168,8 @@ "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { - "description": "Delegator not found", + "409": { + "description": "Delegator already exists", "content": { "application/json": { "schema": { @@ -2003,22 +2187,34 @@ "sessionCookie": [] } ], - "x-operator-scope": "read" + "x-operator-scope": "admin" } }, - "/api/v1/execution-targets": { + "/api/v1/delegators/{name}": { "get": { "tags": [ - "Configuration" + "Delegators" + ], + "summary": "Get a single delegator by name", + "operationId": "delegators_get_one", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Delegator name", + "required": true, + "schema": { + "type": "string" + } + } ], - "operationId": "configuration_execution_targets", "responses": { "200": { - "description": "Safe execution-target summaries", + "description": "Delegator details", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExecutionTargetsResponse" + "$ref": "#/components/schemas/DelegatorResponse" } } } @@ -2028,6 +2224,16 @@ }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -2039,15 +2245,23 @@ } ], "x-operator-scope": "read" - } - }, - "/api/v1/git/config": { + }, "put": { "tags": [ - "Git" + "Delegators" ], - "operationId": "git_write_config", + "summary": "Update an existing delegator", + "operationId": "delegators_update", "parameters": [ + { + "name": "name", + "in": "path", + "description": "Delegator name", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "x-operator-csrf", "in": "header", @@ -2062,7 +2276,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WriteGitConfigRequest" + "$ref": "#/components/schemas/CreateDelegatorRequest" } } }, @@ -2070,11 +2284,11 @@ }, "responses": { "200": { - "description": "", + "description": "Delegator updated", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WriteGitConfigResponse" + "$ref": "#/components/schemas/DelegatorResponse" } } } @@ -2084,44 +2298,16 @@ }, "403": { "$ref": "#/components/responses/Forbidden" - } - }, - "security": [ - { - "bearerAuth": [] }, - { - "sessionCookie": [] - } - ], - "x-operator-scope": "admin" - } - }, - "/api/v1/git/providers": { - "get": { - "tags": [ - "Git" - ], - "operationId": "git_providers", - "responses": { - "200": { - "description": "", + "404": { + "description": "Delegator not found", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitProviderOnboardingResponse" - } + "$ref": "#/components/schemas/ErrorResponse" } } } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -2132,16 +2318,24 @@ "sessionCookie": [] } ], - "x-operator-scope": "read" - } - }, - "/api/v1/git/session-env": { - "post": { + "x-operator-scope": "admin" + }, + "delete": { "tags": [ - "Git" + "Delegators" ], - "operationId": "git_set_session_env", + "summary": "Delete a delegator by name", + "operationId": "delegators_delete", "parameters": [ + { + "name": "name", + "in": "path", + "description": "Delegator name", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "x-operator-csrf", "in": "header", @@ -2152,23 +2346,13 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetGitSessionEnvRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "", + "description": "Delegator deleted", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetGitSessionEnvResponse" + "$ref": "#/components/schemas/DelegatorResponse" } } } @@ -2178,6 +2362,16 @@ }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } }, "security": [ @@ -2191,40 +2385,32 @@ "x-operator-scope": "admin" } }, - "/api/v1/git/validate": { - "post": { + "/api/v1/delegators/{name}/profile": { + "get": { "tags": [ - "Git" + "Delegators" ], - "operationId": "git_validate", + "summary": "Export a delegator as a portable `AgentProfile` (`agent-profile.json`).", + "description": "The shared core plus the Operator-namespaced `x_operator` bag; any opaque\nAGNT (`x_agnt`) and shared-core carry the delegator holds are restored so the\nprofile is a lossless interchange artifact.", + "operationId": "delegators_export_profile", "parameters": [ { - "name": "x-operator-csrf", - "in": "header", - "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", - "required": false, + "name": "name", + "in": "path", + "description": "Delegator name", + "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidateGitTokenRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "", + "description": "Agent profile", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidateGitTokenResponse" + "$ref": "#/components/schemas/AgentProfile" } } } @@ -2234,33 +2420,42 @@ }, "403": { "$ref": "#/components/responses/Forbidden" - } - }, - "security": [ - { - "bearerAuth": [] }, - { + "404": { + "description": "Delegator not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { "sessionCookie": [] } ], - "x-operator-scope": "execute" + "x-operator-scope": "read" } }, - "/api/v1/health": { + "/api/v1/execution-targets": { "get": { "tags": [ - "Health" + "Configuration" ], - "summary": "Health check endpoint", - "operationId": "health_check", + "operationId": "configuration_execution_targets", "responses": { "200": { - "description": "Service is healthy", + "description": "Safe execution-target summaries", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HealthResponse" + "$ref": "#/components/schemas/ExecutionTargetsResponse" } } } @@ -2283,23 +2478,77 @@ "x-operator-scope": "read" } }, - "/api/v1/integrations": { + "/api/v1/git/config": { + "put": { + "tags": [ + "Git" + ], + "operationId": "git_write_config", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteGitConfigRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteGitConfigResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "admin" + } + }, + "/api/v1/git/providers": { "get": { "tags": [ - "Status" + "Git" ], - "summary": "GET `/api/v1/integrations`", - "description": "Returns the catalog of advertised integrations across every vertical, each\nwith its docs link, Premium availability, and support status (`alpha` | `beta` |\n`ga`). Prototype entries are not advertised.", - "operationId": "integrations_catalog", + "operationId": "git_providers", "responses": { "200": { - "description": "Vertical integration catalog with support status", + "description": "", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/IntegrationCatalogEntryDto" + "$ref": "#/components/schemas/GitProviderOnboardingResponse" } } } @@ -2323,37 +2572,40 @@ "x-operator-scope": "read" } }, - "/api/v1/issuetypes": { - "get": { + "/api/v1/git/session-env": { + "post": { "tags": [ - "Issue Types" + "Git" ], - "summary": "List issue types (all collections deduped, or one collection via `?collection=`)", - "operationId": "issuetypes_list", + "operationId": "git_set_session_env", "parameters": [ { - "name": "collection", - "in": "query", - "description": "Collection to scope the lookup to (defaults to resolution-order lookup)", + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetGitSessionEnvRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of issue types", + "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IssueTypeSummary" - } + "$ref": "#/components/schemas/SetGitSessionEnvResponse" } } } @@ -2363,16 +2615,6 @@ }, "403": { "$ref": "#/components/responses/Forbidden" - }, - "404": { - "description": "Unknown collection", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } }, "security": [ @@ -2383,14 +2625,15 @@ "sessionCookie": [] } ], - "x-operator-scope": "read" - }, + "x-operator-scope": "admin" + } + }, + "/api/v1/git/validate": { "post": { "tags": [ - "Issue Types" + "Git" ], - "summary": "Create a new issue type", - "operationId": "issuetypes_create", + "operationId": "git_validate", "parameters": [ { "name": "x-operator-csrf", @@ -2406,7 +2649,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateIssueTypeRequest" + "$ref": "#/components/schemas/ValidateGitTokenRequest" } } }, @@ -2414,21 +2657,47 @@ }, "responses": { "200": { - "description": "Issue type created", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueTypeResponse" + "$ref": "#/components/schemas/ValidateGitTokenResponse" } } } }, - "400": { - "description": "Validation error", + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "execute" + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "Health" + ], + "summary": "Health check endpoint", + "operationId": "health_check", + "responses": { + "200": { + "description": "Service is healthy", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/HealthResponse" } } } @@ -2438,16 +2707,46 @@ }, "403": { "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] }, - "409": { - "description": "Issue type already exists", + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/integrations": { + "get": { + "tags": [ + "Status" + ], + "summary": "GET `/api/v1/integrations`", + "description": "Returns the catalog of advertised integrations across every vertical, each\nwith its docs link, Premium availability, and support status (`alpha` | `beta` |\n`ga`). Prototype entries are not advertised.", + "operationId": "integrations_catalog", + "responses": { + "200": { + "description": "Vertical integration catalog with support status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/IntegrationCatalogEntryDto" + } } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -2458,26 +2757,17 @@ "sessionCookie": [] } ], - "x-operator-scope": "write" + "x-operator-scope": "read" } }, - "/api/v1/issuetypes/{key}": { + "/api/v1/issuetypes": { "get": { "tags": [ "Issue Types" ], - "summary": "Get a single issue type by key", - "operationId": "issuetypes_get_one", + "summary": "List issue types (all collections deduped, or one collection via `?collection=`)", + "operationId": "issuetypes_list", "parameters": [ - { - "name": "key", - "in": "path", - "description": "Issue type key (e.g., FEAT, FIX)", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "collection", "in": "query", @@ -2493,11 +2783,14 @@ ], "responses": { "200": { - "description": "Issue type details", + "description": "List of issue types", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueTypeResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueTypeSummary" + } } } } @@ -2509,7 +2802,7 @@ "$ref": "#/components/responses/Forbidden" }, "404": { - "description": "Issue type not found", + "description": "Unknown collection", "content": { "application/json": { "schema": { @@ -2529,7 +2822,151 @@ ], "x-operator-scope": "read" }, - "put": { + "post": { + "tags": [ + "Issue Types" + ], + "summary": "Create a new issue type", + "operationId": "issuetypes_create", + "parameters": [ + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIssueTypeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Issue type created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueTypeResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "description": "Issue type already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/api/v1/issuetypes/{key}": { + "get": { + "tags": [ + "Issue Types" + ], + "summary": "Get a single issue type by key", + "operationId": "issuetypes_get_one", + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Issue type key (e.g., FEAT, FIX)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "collection", + "in": "query", + "description": "Collection to scope the lookup to (defaults to resolution-order lookup)", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Issue type details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueTypeResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Issue type not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + }, + "put": { "tags": [ "Issue Types" ], @@ -5783,44 +6220,47 @@ "x-operator-scope": "write" } }, - "/api/v1/tickets/{id}": { - "get": { + "/api/v1/tickets/batch": { + "post": { "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", + "summary": "Create every ticket in a batch, or none.", + "operationId": "tickets_batch_create", "parameters": [ { - "name": "id", - "in": "path", - "description": "Ticket ID (e.g., FEAT-7598)", - "required": true, + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "Ticket details", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketDetailResponse" + "$ref": "#/components/schemas/BatchCreated" } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "description": "Ticket not found", + "400": { + "description": "Batch invalid; the message carries the preview JSON and nothing is written", "content": { "application/json": { "schema": { @@ -5828,6 +6268,12 @@ } } } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" } }, "security": [ @@ -5838,27 +6284,17 @@ "sessionCookie": [] } ], - "x-operator-scope": "read" + "x-operator-scope": "write" } }, - "/api/v1/tickets/{id}/launch": { + "/api/v1/tickets/batch/preview": { "post": { "tags": [ - "Launch" + "Tickets" ], - "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", + "summary": "Preview a batch: allocated ids, resolved dependencies, waves and errors. Writes nothing.", + "operationId": "tickets_batch_preview", "parameters": [ - { - "name": "id", - "in": "path", - "description": "Ticket ID to launch", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "x-operator-csrf", "in": "header", @@ -5873,7 +6309,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketRequest" + "$ref": "#/components/schemas/BatchRequest" } } }, @@ -5881,21 +6317,59 @@ }, "responses": { "200": { - "description": "Ticket launched successfully", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LaunchTicketResponse" + "$ref": "#/components/schemas/BatchPreview" } } } }, - "400": { - "description": "Invalid request", + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/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/ErrorResponse" + "$ref": "#/components/schemas/TicketDetailResponse" } } } @@ -5903,11 +6377,172 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "description": "Premium required for the selected execution target", - "content": { - "application/json": { - "schema": { + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "description": "Ticket not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "read" + } + }, + "/api/v1/tickets/{id}/campaign": { + "put": { + "tags": [ + "Campaigns" + ], + "operationId": "tickets_assign_campaign", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignCampaignRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignCampaignResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "description": "Premium required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "x-operator-scope": "write" + } + }, + "/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" + } + }, + { + "name": "x-operator-csrf", + "in": "header", + "description": "Required for cookie-authenticated mutations; omit when using bearer authentication.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchTicketRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ticket launched successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LaunchTicketResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "description": "Premium required for the selected execution target", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } } @@ -6393,9 +7028,25 @@ "project", "status", "mode", - "started_at" + "started_at", + "connect" ], "properties": { + "attention": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Attention", + "description": "Set when the agent needs the operator" + } + ] + }, + "connect": { + "$ref": "#/components/schemas/ConnectPlan", + "description": "Ways to open the agent's session" + }, "current_step": { "type": [ "string", @@ -6407,6 +7058,13 @@ "type": "string", "description": "Agent ID (e.g., \"op-gamesvc-001\")" }, + "last_message": { + "type": [ + "string", + "null" + ], + "description": "Last status message from the agent" + }, "mode": { "type": "string", "description": "Execution mode: autonomous, paired" @@ -6495,9 +7153,21 @@ "started_at", "last_activity", "completed_steps", - "paired" + "paired", + "connect" ], "properties": { + "attention": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Attention", + "description": "Set when the agent needs the operator" + } + ] + }, "completed_steps": { "type": "array", "items": { @@ -6505,6 +7175,10 @@ }, "description": "Completed steps for this ticket" }, + "connect": { + "$ref": "#/components/schemas/ConnectPlan", + "description": "Ways to open the agent's session" + }, "current_step": { "type": [ "string", @@ -6520,6 +7194,20 @@ "type": "string", "description": "Last activity timestamp (ISO 8601)" }, + "last_content_change": { + "type": [ + "string", + "null" + ], + "description": "Last time the session output changed (ISO 8601)" + }, + "last_message": { + "type": [ + "string", + "null" + ], + "description": "Last status message from the agent" + }, "launch_mode": { "type": [ "string", @@ -6563,6 +7251,13 @@ "type": "string", "description": "Project being worked on" }, + "remote_host": { + "type": [ + "string", + "null" + ], + "description": "Remote host the agent runs on (None = local)" + }, "review_state": { "type": [ "string", @@ -6570,6 +7265,13 @@ ], "description": "Review state for `awaiting_input` agents" }, + "session_name": { + "type": [ + "string", + "null" + ], + "description": "Terminal session name" + }, "session_wrapper": { "type": [ "string", @@ -6585,6 +7287,13 @@ "type": "string", "description": "Agent status: running, `awaiting_input`, completing, orphaned" }, + "target_name": { + "type": [ + "string", + "null" + ], + "description": "Execution target the agent launched on" + }, "ticket_id": { "type": "string", "description": "Associated ticket ID (e.g., \"FEAT-042\")" @@ -6832,53 +7541,363 @@ } } }, - "BootstrapState": { - "type": "string", - "description": "Where the deployment sits in the one-time admin-creation sequence.", - "enum": [ - "uninitialized", - "awaiting_password", - "complete" - ] - }, - "BootstrapStatusResponse": { + "AssignCampaignRequest": { "type": "object", - "description": "Current bootstrap state, readable without authentication so a client can\nroute a first-time visitor to setup rather than to login.", - "required": [ - "state", - "requires_temporary_password" - ], + "description": "Move a ticket into or out of a campaign.", "properties": { - "requires_temporary_password": { - "type": "boolean", - "description": "Whether a temporary password was supplied out of band (a mounted\nbootstrap secret). When true, submission must present it." + "campaign": { + "type": [ + "string", + "null" + ], + "description": "Campaign id; null removes the ticket from its campaign." }, - "state": { - "$ref": "#/components/schemas/BootstrapState", - "description": "The state this deployment is in." + "depends_on": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Replaces the ticket's `depends_on` when present." } } }, - "BootstrapSubmitRequest": { + "AssignCampaignResponse": { "type": "object", - "description": "Claim the admin account and set its password.", + "description": "A ticket's campaign membership after an assignment.", "required": [ - "new_password" + "id", + "depends_on" ], "properties": { - "new_password": { - "type": "string", - "format": "password", - "description": "The admin password to set. Never persisted in plaintext or logged.", - "writeOnly": true, - "maxLength": 1024, - "minLength": 12 - }, - "temporary_password": { + "campaign": { "type": [ "string", "null" - ], + ] + }, + "depends_on": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + } + } + }, + "Attention": { + "type": "object", + "description": "Attention nudge for an agent", + "required": [ + "reason", + "since" + ], + "properties": { + "detail": { + "type": [ + "string", + "null" + ], + "description": "Short context (review state or last message)" + }, + "reason": { + "$ref": "#/components/schemas/AttentionReason", + "description": "Why attention is needed" + }, + "since": { + "type": "string", + "description": "Since when (ISO 8601)" + } + } + }, + "AttentionReason": { + "type": "string", + "description": "Why an agent needs the operator", + "enum": [ + "awaiting_input", + "review", + "orphaned", + "idle" + ] + }, + "BatchCreated": { + "type": "object", + "description": "Result of creating a batch.", + "required": [ + "tickets", + "waves" + ], + "properties": { + "tickets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreatedTicket" + } + }, + "waves": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "BatchItem": { + "type": "object", + "description": "One ticket to create; `ref` names it within the batch.", + "required": [ + "ref", + "template" + ], + "properties": { + "depends_on": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Batch refs, existing ticket ids, or `provider:key` refs." + }, + "project": { + "type": [ + "string", + "null" + ] + }, + "ref": { + "type": "string", + "description": "Batch-local name other items use in `depends_on`." + }, + "summary": { + "type": [ + "string", + "null" + ] + }, + "template": { + "type": "string", + "description": "Template key (FEAT, FIX, TASK, SPIKE, INV, ...)." + }, + "values": { + "type": "object", + "description": "Extra Handlebars values; `project`/`summary` above take precedence.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "BatchPreview": { + "type": "object", + "description": "What a batch would create; nothing is written.", + "required": [ + "tickets", + "waves", + "errors", + "valid" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Batch-wide errors (cycles, unknown campaign)." + }, + "tickets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlannedTicket" + } + }, + "valid": { + "type": "boolean" + }, + "waves": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": "Allocated ids in dependency waves." + } + } + }, + "BatchRequest": { + "type": "object", + "description": "A batch of tickets, optionally created inside a campaign.", + "required": [ + "tickets" + ], + "properties": { + "campaign": { + "type": [ + "string", + "null" + ] + }, + "tickets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchItem" + } + } + } + }, + "BlockReason": { + "oneOf": [ + { + "type": "object", + "description": "Waiting on members in earlier waves.", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "dependencies" + ] + } + } + }, + { + "type": "object", + "description": "Waiting on local tickets outside the campaign.", + "required": [ + "refs", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "gate" + ] + }, + "refs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Waiting on local tickets outside the campaign." + } + } + }, + { + "type": "object", + "description": "Waiting on upstream issues that are not synced.", + "required": [ + "refs", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "unsynced" + ] + }, + "refs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Waiting on upstream issues that are not synced." + } + } + }, + { + "type": "object", + "description": "Ready, but no Premium entitlement.", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "license" + ] + } + } + }, + { + "type": "object", + "description": "Ready, but no free slot this tick.", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "capacity" + ] + } + } + } + ], + "description": "Why a pending member is not launching." + }, + "BootstrapState": { + "type": "string", + "description": "Where the deployment sits in the one-time admin-creation sequence.", + "enum": [ + "uninitialized", + "awaiting_password", + "complete" + ] + }, + "BootstrapStatusResponse": { + "type": "object", + "description": "Current bootstrap state, readable without authentication so a client can\nroute a first-time visitor to setup rather than to login.", + "required": [ + "state", + "requires_temporary_password" + ], + "properties": { + "requires_temporary_password": { + "type": "boolean", + "description": "Whether a temporary password was supplied out of band (a mounted\nbootstrap secret). When true, submission must present it." + }, + "state": { + "$ref": "#/components/schemas/BootstrapState", + "description": "The state this deployment is in." + } + } + }, + "BootstrapSubmitRequest": { + "type": "object", + "description": "Claim the admin account and set its password.", + "required": [ + "new_password" + ], + "properties": { + "new_password": { + "type": "string", + "format": "password", + "description": "The admin password to set. Never persisted in plaintext or logged.", + "writeOnly": true, + "maxLength": 1024, + "minLength": 12 + }, + "temporary_password": { + "type": [ + "string", + "null" + ], "format": "password", "description": "The out-of-band temporary password, when\n`requires_temporary_password` is set. Never persisted or logged.", "writeOnly": true, @@ -6887,24 +7906,430 @@ } } }, - "BootstrapSubmitResponse": { + "BootstrapSubmitResponse": { + "type": "object", + "description": "Result of a successful bootstrap.", + "required": [ + "state", + "username" + ], + "properties": { + "state": { + "$ref": "#/components/schemas/BootstrapState", + "description": "The state after submission - `Complete` on success." + }, + "username": { + "type": "string", + "description": "The account name created by bootstrap." + } + } + }, + "Campaign": { + "type": "object", + "description": "A campaign, stored as frontmatter plus a markdown body under `/campaigns/`.", + "required": [ + "id", + "title" + ], + "properties": { + "created": { + "type": "string", + "description": "Creation date (`YYYY-MM-DD`)." + }, + "external_ref": { + "type": [ + "string", + "null" + ], + "description": "Upstream container this campaign was synced from (e.g. `jira:PROJ-1`)." + }, + "governance": { + "type": [ + "string", + "null" + ], + "description": "Name of a `[[governance]]` policy layered over each delegator's own." + }, + "governance_overrides": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GovernancePolicy", + "description": "Inline policy layered over `governance`." + } + ] + }, + "id": { + "type": "string", + "description": "Campaign id (e.g. `CAMP-0003`); member tickets reference it as `campaign:`." + }, + "status": { + "$ref": "#/components/schemas/CampaignStatus" + }, + "title": { + "type": "string", + "description": "Human-readable title." + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CampaignWorkspace", + "description": "Shared execution environment; absent runs each ticket in its own workspace." + } + ] + } + } + }, + "CampaignCounters": { + "type": "object", + "description": "Usage counted against the campaign's caps.", + "required": [ + "launched", + "active", + "retries" + ], + "properties": { + "active": { + "type": "integer", + "format": "int32", + "description": "Members in progress.", + "minimum": 0 + }, + "launched": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "max_launches": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "max_parallel": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "max_retries": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "minimum": 0 + }, + "retries": { + "type": "integer", + "format": "int32", + "description": "Most relaunches of any one member.", + "minimum": 0 + } + } + }, + "CampaignDetail": { + "type": "object", + "description": "A campaign with its members and plan.", + "required": [ + "campaign", + "members", + "waves", + "gates", + "unsynced" + ], + "properties": { + "campaign": { + "$ref": "#/components/schemas/Campaign" + }, + "gates": { + "type": "object", + "description": "Member id → local tickets outside the campaign it waits on.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "propertyNames": { + "type": "string" + } + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignMember" + } + }, + "plan_error": { + "type": [ + "string", + "null" + ] + }, + "unsynced": { + "type": "object", + "description": "Member id → unsynced upstream refs it waits on.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "propertyNames": { + "type": "string" + } + }, + "waves": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "CampaignLaunchOutcome": { "type": "object", - "description": "Result of a successful bootstrap.", + "description": "Result of a launch or dry run.", "required": [ - "state", - "username" + "id", + "dry_run", + "status", + "waves", + "policy", + "violations", + "report" ], "properties": { - "state": { - "$ref": "#/components/schemas/BootstrapState", - "description": "The state after submission - `Complete` on success." + "dry_run": { + "type": "boolean" }, - "username": { + "id": { + "type": "string" + }, + "policy": { + "$ref": "#/components/schemas/GovernancePolicy" + }, + "report": { + "$ref": "#/components/schemas/TickReport", + "description": "The first tick, run or (for a dry run) assessed." + }, + "status": { + "$ref": "#/components/schemas/CampaignStatus" + }, + "violations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Reasons the campaign cannot start; empty when it can." + }, + "waves": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "CampaignListResponse": { + "type": "object", + "required": [ + "campaigns", + "total" + ], + "properties": { + "campaigns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignSummary" + } + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + }, + "CampaignMember": { + "type": "object", + "description": "A member ticket as seen from its campaign.", + "required": [ + "id", + "summary", + "project", + "column", + "depends_on", + "launches" + ], + "properties": { + "column": { "type": "string", - "description": "The account name created by bootstrap." + "description": "`queue`, `in-progress` or `completed`." + }, + "depends_on": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "launches": { + "type": "integer", + "format": "int32", + "description": "Launches made by the runner.", + "minimum": 0 + }, + "project": { + "type": "string" + }, + "summary": { + "type": "string" + } + } + }, + "CampaignStatus": { + "type": "string", + "description": "Lifecycle of a campaign.", + "enum": [ + "draft", + "ready", + "running", + "paused", + "completed", + "cancelled" + ] + }, + "CampaignStatusView": { + "type": "object", + "description": "Live state of a campaign run.", + "required": [ + "id", + "status", + "tickets", + "waves", + "report", + "counters", + "policy" + ], + "properties": { + "counters": { + "$ref": "#/components/schemas/CampaignCounters" + }, + "id": { + "type": "string" + }, + "pause_reason": { + "type": [ + "string", + "null" + ] + }, + "policy": { + "$ref": "#/components/schemas/GovernancePolicy", + "description": "The policy the campaign layers over each delegator's own." + }, + "report": { + "$ref": "#/components/schemas/TickReport", + "description": "What the next tick would do." + }, + "status": { + "$ref": "#/components/schemas/CampaignStatus" + }, + "tickets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignMember" + } + }, + "waves": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "CampaignSummary": { + "type": "object", + "description": "A campaign with its member count.", + "required": [ + "campaign", + "members" + ], + "properties": { + "campaign": { + "$ref": "#/components/schemas/Campaign" + }, + "members": { + "type": "integer", + "minimum": 0 } } }, + "CampaignWorkspace": { + "oneOf": [ + { + "type": "object", + "description": "One Coder workspace created from this template.", + "required": [ + "template", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "coder" + ] + }, + "template": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "One container from this image.", + "required": [ + "image", + "kind" + ], + "properties": { + "image": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "docker" + ] + } + } + } + ], + "description": "One execution environment shared by every ticket in a campaign." + }, "CollectionPreset": { "type": "string", "description": "Predefined issue type collections", @@ -7014,30 +8439,172 @@ } } }, - "ConfigurationResponse": { + "ConfigurationResponse": { + "type": "object", + "description": "The deliberately supported, integration-safe configuration surface.", + "required": [ + "agents", + "queue", + "ui", + "launch" + ], + "properties": { + "agents": { + "$ref": "#/components/schemas/AgentsConfiguration" + }, + "launch": { + "$ref": "#/components/schemas/LaunchConfiguration" + }, + "queue": { + "$ref": "#/components/schemas/QueueConfiguration" + }, + "ui": { + "$ref": "#/components/schemas/UiConfiguration" + } + }, + "additionalProperties": false + }, + "ConnectInterface": { + "type": "string", + "description": "Interface used to open a launched agent's session", + "enum": [ + "vscode", + "cursor", + "zed", + "coder", + "cmux", + "tmux", + "zellij", + "ssh", + "terminal" + ] + }, + "ConnectKind": { + "type": "string", + "description": "How a client acts on a connect option", + "enum": [ + "ide", + "focus_api", + "command" + ] + }, + "ConnectOption": { + "type": "object", + "description": "One way to reach an agent's session", + "required": [ + "interface", + "kind", + "label", + "available" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Whether the option can be used right now" + }, + "command": { + "type": [ + "string", + "null" + ], + "description": "Shell command for `command` options" + }, + "interface": { + "$ref": "#/components/schemas/ConnectInterface", + "description": "Interface this option opens" + }, + "kind": { + "$ref": "#/components/schemas/ConnectKind", + "description": "How the client acts on it" + }, + "label": { + "type": "string", + "description": "Button label" + }, + "unavailable_reason": { + "type": [ + "string", + "null" + ], + "description": "Why the option is unavailable" + }, + "uri": { + "type": [ + "string", + "null" + ], + "description": "Deep link for `ide` options" + } + } + }, + "ConnectPlan": { + "type": "object", + "description": "Every connect option for an agent, with the preferred one called out", + "required": [ + "phase", + "options" + ], + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConnectOption" + }, + "description": "All applicable options, preferred included" + }, + "phase": { + "$ref": "#/components/schemas/SessionPhase", + "description": "Attach vs resume" + }, + "preferred": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ConnectOption", + "description": "Primary option (configured preference, else the session wrapper's)" + } + ] + } + } + }, + "ConnectTerminal": { "type": "object", - "description": "The deliberately supported, integration-safe configuration surface.", + "description": "What an IDE extension runs after opening the agent's workspace", "required": [ - "agents", - "queue", - "ui", - "launch" + "phase", + "ticket_id", + "terminal_name" ], "properties": { - "agents": { - "$ref": "#/components/schemas/AgentsConfiguration" + "command": { + "type": [ + "string", + "null" + ], + "description": "Command to run inside the agent's environment; absent means focus `terminal_name`" }, - "launch": { - "$ref": "#/components/schemas/LaunchConfiguration" + "cwd": { + "type": [ + "string", + "null" + ], + "description": "Directory to start in" }, - "queue": { - "$ref": "#/components/schemas/QueueConfiguration" + "phase": { + "$ref": "#/components/schemas/SessionPhase", + "description": "Attach vs resume" }, - "ui": { - "$ref": "#/components/schemas/UiConfiguration" + "terminal_name": { + "type": "string", + "description": "Existing terminal or session name" + }, + "ticket_id": { + "type": "string", + "description": "Ticket ID, for naming the terminal" } - }, - "additionalProperties": false + } }, "CreateAccessKeyRequest": { "type": "object", @@ -7232,6 +8799,13 @@ } ] }, + "governance": { + "type": [ + "string", + "null" + ], + "description": "Name of a `[[governance]]` policy enforced on this delegator's launches." + }, "launch_config": { "oneOf": [ { @@ -7547,6 +9121,26 @@ } } }, + "CreatedTicket": { + "type": "object", + "description": "A ticket written by a batch.", + "required": [ + "ref", + "id", + "filename" + ], + "properties": { + "filename": { + "type": "string" + }, + "id": { + "type": "string" + }, + "ref": { + "type": "string" + } + } + }, "CsrfTokenResponse": { "type": "object", "description": "A freshly minted CSRF token for the current session.", @@ -7802,6 +9396,13 @@ } ] }, + "governance": { + "type": [ + "string", + "null" + ], + "description": "Name of a `[[governance]]` policy enforced on this delegator's launches." + }, "launch_config": { "oneOf": [ { @@ -8519,6 +10120,107 @@ } } }, + "GovernancePolicy": { + "type": "object", + "description": "A named launch policy. Every constraint is optional; an absent one is unconstrained.", + "required": [ + "name" + ], + "properties": { + "allowed_delegators": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Delegator names allowed to launch. Absent = any." + }, + "allowed_llm_tools": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "LLM tool names allowed to launch (e.g. `claude`). Absent = any." + }, + "allowed_model_servers": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Model server names allowed (declared or implicit, e.g. `anthropic-api`). Absent = any." + }, + "allowed_targets": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Execution target names allowed (e.g. `local`, `docker`, a `[[targets]]` name). Absent = any." + }, + "budget_usd": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Spend ceiling in USD. Reserved: accepted and reported, not yet enforced." + }, + "max_launches": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Maximum total launches under this policy (counted per campaign run).", + "minimum": 0 + }, + "max_parallel": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Maximum concurrently running agents under this policy.", + "minimum": 0 + }, + "max_retries": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Maximum relaunches of a failed ticket (counted per campaign run).", + "minimum": 0 + }, + "max_runtime_minutes": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Maximum wall-clock minutes per agent.", + "minimum": 0 + }, + "name": { + "type": "string", + "description": "Unique name, referenced by `Delegator.governance`." + }, + "require_confirmation": { + "type": "boolean", + "description": "Require operator confirmation before each launch." + } + } + }, "HealthResponse": { "type": "object", "description": "Health check response", @@ -9024,6 +10726,13 @@ "total_processed" ], "properties": { + "campaign": { + "type": [ + "string", + "null" + ], + "description": "Campaign the synced tickets were stamped with (scoped syncs)" + }, "created": { "type": "array", "items": { @@ -9049,6 +10758,13 @@ "type": "integer", "description": "Total count of issues processed", "minimum": 0 + }, + "updated": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Existing tickets whose synced relationships were refreshed" } } }, @@ -9066,6 +10782,17 @@ "timestamp" ], "properties": { + "attention": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/AttentionReason", + "description": "Set when the ticket's agent needs the operator" + } + ] + }, "filename": { "type": "string", "description": "Ticket markdown filename (joins with the tickets dir + status folder\nfor co-located clients; remote clients treat tickets as API-only)" @@ -9111,6 +10838,16 @@ } } }, + "LaunchCampaignRequest": { + "type": "object", + "description": "Launch a campaign, or preview the launch.", + "properties": { + "dry_run": { + "type": "boolean", + "description": "Report the plan, policy and violations without starting." + } + } + }, "LaunchConfiguration": { "type": "object", "required": [ @@ -9688,6 +11425,33 @@ } } }, + "LlmTask": { + "type": "object", + "description": "LLM task metadata for delegate mode integration", + "properties": { + "blocked_by": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of task IDs that must resolve before this task" + }, + "id": { + "type": [ + "string", + "null" + ], + "description": "LLM task ID (e.g., Claude delegate mode task UUID)" + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "LLM task status: \"open\" or \"resolved\"" + } + } + }, "LlmToolsResponse": { "type": "object", "description": "Response listing detected LLM tools", @@ -10031,6 +11795,53 @@ } } }, + "NewCampaignInput": { + "type": "object", + "description": "Fields for a new campaign.", + "required": [ + "title" + ], + "properties": { + "goal": { + "type": [ + "string", + "null" + ], + "description": "Markdown goal written as the campaign body." + }, + "governance": { + "type": [ + "string", + "null" + ], + "description": "Name of a `[[governance]]` policy." + }, + "governance_overrides": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GovernancePolicy", + "description": "Inline policy layered over `governance`." + } + ] + }, + "title": { + "type": "string" + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/CampaignWorkspace" + } + ] + } + } + }, "NextStepInfo": { "type": "object", "description": "Information about the next step in the workflow", @@ -10251,27 +12062,69 @@ ], "default": null }, - "queue": { - "type": [ - "string", - "null" - ], - "default": null + "queue": { + "type": [ + "string", + "null" + ], + "default": null + }, + "status": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "additionalProperties": false + }, + "PlannedTicket": { + "type": "object", + "description": "One item as it would be written.", + "required": [ + "ref", + "id", + "template", + "filename_stem", + "depends_on", + "errors" + ], + "properties": { + "depends_on": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies with batch refs resolved to allocated ids." + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "filename_stem": { + "type": "string", + "description": "Filename without the date prefix: `TYPE-project-slug`." + }, + "id": { + "type": "string", + "description": "Allocated id; empty when the template is unknown." + }, + "ref": { + "type": "string" }, - "status": { - "type": [ - "string", - "null" - ], - "default": null + "template": { + "type": "string" } - }, - "additionalProperties": false + } }, "PremiumFeature": { "type": "string", "enum": [ - "remote_targets" + "remote_targets", + "campaigns" ] }, "PrincipalKind": { @@ -10832,6 +12685,15 @@ } } }, + "SessionPhase": { + "type": "string", + "description": "Whether the agent's session can be attached or only resumed", + "enum": [ + "live", + "resumable", + "gone" + ] + }, "SessionSummary": { "type": "object", "description": "An active or expired browser session. Carries no session identifier - the\ncookie value is never readable back out, only the session's `id` for\nrevocation.", @@ -11714,6 +13576,227 @@ } } }, + "TickReport": { + "type": "object", + "description": "What one tick did, or would do when assessed.", + "required": [ + "status", + "launched", + "awaiting_operator", + "stalled", + "blocked" + ], + "properties": { + "awaiting_operator": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/BlockReason" + }, + "propertyNames": { + "type": "string" + } + }, + "launched": { + "type": "array", + "items": { + "type": "string" + } + }, + "pause_reason": { + "type": [ + "string", + "null" + ] + }, + "stalled": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "$ref": "#/components/schemas/CampaignStatus" + } + } + }, + "TicketAgent": { + "type": "object", + "description": "The agent working a ticket, as shown on the ticket page.", + "required": [ + "id", + "status", + "paired", + "started_at", + "completed_steps" + ], + "properties": { + "attach_command": { + "type": [ + "string", + "null" + ], + "description": "Shell command that attaches to the session (tmux)" + }, + "completed_steps": { + "type": "array", + "items": { + "type": "string" + } + }, + "current_step": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "last_message": { + "type": [ + "string", + "null" + ] + }, + "llm_model": { + "type": [ + "string", + "null" + ] + }, + "llm_tool": { + "type": [ + "string", + "null" + ] + }, + "paired": { + "type": "boolean" + }, + "pr_number": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "pr_status": { + "type": [ + "string", + "null" + ] + }, + "pr_url": { + "type": [ + "string", + "null" + ] + }, + "remote_host": { + "type": [ + "string", + "null" + ], + "description": "SSH host the agent runs on, when remote" + }, + "review_state": { + "type": [ + "string", + "null" + ] + }, + "session_context_ref": { + "type": [ + "string", + "null" + ] + }, + "session_name": { + "type": [ + "string", + "null" + ] + }, + "session_pane_ref": { + "type": [ + "string", + "null" + ] + }, + "session_window_ref": { + "type": [ + "string", + "null" + ] + }, + "session_wrapper": { + "type": [ + "string", + "null" + ], + "description": "tmux, vscode, cmux or zellij" + }, + "started_at": { + "type": "string", + "description": "RFC 3339 start time" + }, + "status": { + "type": "string", + "description": "running, `awaiting_input`, completing or orphaned" + }, + "target_name": { + "type": [ + "string", + "null" + ] + }, + "worktree_path": { + "type": [ + "string", + "null" + ] + } + } + }, + "TicketDependency": { + "type": "object", + "description": "One `depends_on` reference and where it points.", + "required": [ + "reference", + "satisfied" + ], + "properties": { + "column": { + "type": [ + "string", + "null" + ], + "description": "Column of the resolved ticket" + }, + "reference": { + "type": "string", + "description": "The reference as written (local id or `provider:key`)" + }, + "satisfied": { + "type": "boolean", + "description": "Whether the dependency is completed" + }, + "ticket_id": { + "type": [ + "string", + "null" + ], + "description": "Local ticket it resolves to; absent while unsynced" + } + } + }, "TicketDetailResponse": { "type": "object", "description": "Full ticket details including content and metadata", @@ -11730,9 +13813,30 @@ "filename", "filepath", "sessions", - "step_delegators" + "step_delegators", + "body", + "column", + "depends_on", + "dependencies", + "dependents", + "llm_task" ], "properties": { + "agent": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TicketAgent", + "description": "The agent working this ticket, if any" + } + ] + }, + "body": { + "type": "string", + "description": "Markdown after the frontmatter" + }, "branch": { "type": [ "string", @@ -11740,10 +13844,49 @@ ], "description": "Git branch name" }, + "campaign": { + "type": [ + "string", + "null" + ], + "description": "Campaign this ticket belongs to (e.g. `CAMP-0001`)" + }, + "collection": { + "type": [ + "string", + "null" + ], + "description": "Collection the ticket's issue type comes from" + }, + "column": { + "type": "string", + "description": "Board column: `queue`, `in-progress` or `completed`" + }, "content": { "type": "string", "description": "Full markdown content of the ticket" }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketDependency" + }, + "description": "`depends_on` resolved against local tickets" + }, + "dependents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Local tickets whose `depends_on` names this ticket" + }, + "depends_on": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Raw `depends_on` references" + }, "external_id": { "type": [ "string", @@ -11751,6 +13894,13 @@ ], "description": "External issue ID from kanban provider" }, + "external_parent": { + "type": [ + "string", + "null" + ], + "description": "Upstream epic, project or parent (`provider:key`)" + }, "external_provider": { "type": [ "string", @@ -11777,6 +13927,10 @@ "type": "string", "description": "Ticket ID (e.g., \"FEAT-7598\")" }, + "llm_task": { + "$ref": "#/components/schemas/LlmTask", + "description": "Delegate-mode task metadata" + }, "priority": { "type": "string", "description": "Priority: P0-critical, P1-high, P2-medium, P3-low" @@ -12877,6 +15031,13 @@ } ] }, + "governance": { + "type": [ + "string", + "null" + ], + "description": "Name of a `[[governance]]` policy." + }, "launch_config": { "oneOf": [ { @@ -12982,6 +15143,10 @@ "name": "Launch", "description": "Ticket launch operations" }, + { + "name": "Campaigns", + "description": "Campaigns: epics of tickets planned into waves and launched together (Premium)" + }, { "name": "Workflow", "description": "Export tickets to Claude dynamic workflows" diff --git a/docs/schemas/state.json b/docs/schemas/state.json index a304ae79..da94bb52 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -45,6 +45,14 @@ "$ref": "#/$defs/MultiAgentGroup" }, "default": [] + }, + "campaign_runs": { + "description": "Campaign id → runner bookkeeping", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/CampaignRun" + }, + "default": {} } }, "required": [ @@ -713,6 +721,18 @@ "type": "string" }, "default": {} + }, + "judge_attempt": { + "description": "The in-flight LLM judge call (set when phase = Voting). Its verdict\narrives as a side file keyed by `attempt_id`, never through `State`.", + "anyOf": [ + { + "$ref": "#/$defs/JudgeAttempt" + }, + { + "type": "null" + } + ], + "default": null } }, "required": [ @@ -771,6 +791,65 @@ "prompt", "variant_key" ] + }, + "JudgeAttempt": { + "type": "object", + "properties": { + "attempt_id": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "timeout_secs": { + "description": "Judge timeout copied at start, so a config edit can't strand the attempt", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "attempt_id", + "started_at", + "timeout_secs" + ] + }, + "CampaignRun": { + "description": "Runner bookkeeping for one campaign.", + "type": "object", + "properties": { + "launches": { + "description": "Ticket id → launches made by the runner", + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "default": {} + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "pause_reason": { + "description": "Why the run paused, if it did", + "type": [ + "string", + "null" + ] + } + } } } } \ No newline at end of file diff --git a/docs/schemas/state.md b/docs/schemas/state.md index 2733fa21..cf399958 100644 --- a/docs/schemas/state.md +++ b/docs/schemas/state.md @@ -31,6 +31,7 @@ This file tracks the current state of agents, completed tickets, and system stat | `project_llm_stats` | `object` | No | Per-project LLM usage statistics | | `project_collection_prefs` | `object` | No | Per-project issue type collection preferences (`project_name` -> `collection_name`) | | `multi_agent_groups` | `array` | No | Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) | +| `campaign_runs` | `object` | No | Campaign id → runner bookkeeping | ## Type Definitions @@ -197,6 +198,7 @@ Tracks a group of agents working on a single multi-agent step | `expected_total` | `integer` | No | Total sub-agents expected (`agent_ids.len() + pending_launches.len()`). | | `pending_launches` | `array` | No | Sub-agents that still need launching (waiting for a free slot). | | `agent_variant_keys` | `object` | No | Maps launched `agent_id` to the `variant_key` used as the output key. | +| `judge_attempt` | object | No | The in-flight LLM judge call (set when phase = Voting). Its verdict arrives as a side file keyed by `attempt_id`, never through `State`. | ### MultiAgentPhase @@ -212,3 +214,22 @@ A sub-agent that has been planned but not yet launched (slot queue). | `prompt` | `string` | Yes | Fully-rendered prompt text for this sub-agent. | | `variant_key` | `string` | Yes | Key under which this sub-agent's output is recorded (see `individual_outputs`). | +### JudgeAttempt + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `attempt_id` | `string` | Yes | | +| `started_at` | `string` | Yes | | +| `timeout_secs` | `integer` | Yes | Judge timeout copied at start, so a config edit can't strand the attempt | + +### CampaignRun + +Runner bookkeeping for one campaign. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `launches` | `object` | No | Ticket id → launches made by the runner | +| `started_at` | `string` \| `null` | No | | +| `last_error` | `string` \| `null` | No | | +| `pause_reason` | `string` \| `null` | No | Why the run paused, if it did | + diff --git a/e2e/.dockerignore b/e2e/.dockerignore new file mode 100644 index 00000000..a4504b81 --- /dev/null +++ b/e2e/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.auth +playwright-report +test-results diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..b3948d07 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +.auth/ +playwright-report/ +test-results/ diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 00000000..b9c63695 --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,15 @@ +# Registry mirror prefix for the base images, e.g. registry.example/org/mirror/; empty pulls from upstream. +ARG BASE_REGISTRY="" + +FROM ${BASE_REGISTRY}oven/bun:1.3.14 AS bun + +# Tag tracks @playwright/test in package.json; the image ships the matching browsers and node. +FROM ${BASE_REGISTRY}mcr.microsoft.com/playwright:v1.63.0-noble + +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun +WORKDIR /e2e +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . + +ENTRYPOINT ["npx", "playwright", "test"] diff --git a/e2e/bun.lock b/e2e/bun.lock new file mode 100644 index 00000000..bb6057c5 --- /dev/null +++ b/e2e/bun.lock @@ -0,0 +1,27 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@operator/e2e", + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "^22.0.0", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/e2e/fixtures/env.ts b/e2e/fixtures/env.ts new file mode 100644 index 00000000..50f26773 --- /dev/null +++ b/e2e/fixtures/env.ts @@ -0,0 +1,10 @@ +// localhost: the session cookie is `__Host-` + Secure, which Chromium only accepts over plain HTTP on localhost. +export const BASE_URL = process.env.OPERATOR_E2E_BASE_URL || "http://localhost:7008"; + +/** Out-of-band password the server was started with; empty when none was mounted. */ +export const BOOTSTRAP_PASSWORD = process.env.OPERATOR_E2E_BOOTSTRAP_PASSWORD ?? ""; + +export const ISSUER_URL = process.env.OPERATOR_E2E_ISSUER_URL ?? ""; +export const LICENSE_TOKEN = process.env.OPERATOR_E2E_LICENSE_TOKEN ?? ""; + +export const ADMIN_STATE = ".auth/admin.json"; diff --git a/e2e/fixtures/global-setup.ts b/e2e/fixtures/global-setup.ts new file mode 100644 index 00000000..e49861c8 --- /dev/null +++ b/e2e/fixtures/global-setup.ts @@ -0,0 +1,22 @@ +import { BASE_URL } from "./env"; + +const READY_TIMEOUT_MS = 60_000; +const POLL_MS = 500; + +async function healthy(): Promise { + try { + return (await fetch(`${BASE_URL}/readyz`)).ok; + } catch { + return false; + } +} + +export default async function waitForServer(): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS; + while (!(await healthy())) { + if (Date.now() > deadline) { + throw new Error(`operator at ${BASE_URL} not healthy after ${READY_TIMEOUT_MS}ms`); + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + } +} diff --git a/e2e/fixtures/issuer.ts b/e2e/fixtures/issuer.ts new file mode 100644 index 00000000..daede5ea --- /dev/null +++ b/e2e/fixtures/issuer.ts @@ -0,0 +1,25 @@ +import { request } from "@playwright/test"; +import { ISSUER_URL, LICENSE_TOKEN } from "./env"; + +/** Both the issuer URL and its token are required; absent config skips the suite. */ +export const issuerConfigured = Boolean(ISSUER_URL && LICENSE_TOKEN); + +/** + * A short-lived test-mode license bound to `profileId`, from the platform + * webhook's E2E route. Its own empty context: operator cookies never leave home. + */ +export async function requestTestLicense(profileId: string): Promise { + const issuer = await request.newContext({ storageState: { cookies: [], origins: [] } }); + try { + const response = await issuer.get(new URL("/e2e/license", ISSUER_URL).toString(), { + params: { profile_id: profileId }, + headers: { authorization: `Bearer ${LICENSE_TOKEN}`, "x-operator-e2e": "test-mode" }, + }); + if (!response.ok()) { + throw new Error(`test license issuer: HTTP ${response.status()}`); + } + return (await response.text()).trim(); + } finally { + await issuer.dispose(); + } +} diff --git a/e2e/fixtures/operator.ts b/e2e/fixtures/operator.ts new file mode 100644 index 00000000..5eea7917 --- /dev/null +++ b/e2e/fixtures/operator.ts @@ -0,0 +1,41 @@ +import type { APIRequestContext, APIResponse } from "@playwright/test"; + +const CSRF_HEADER = "x-operator-csrf"; + +export type LicenseResponse = { + status: "missing" | "valid" | "expired" | "not_yet_valid" | "invalid"; + profile_id: string; + premium: boolean; + terms: { sub: string; jti: string; profile_id: string; tier: string } | null; +}; + +export type SetupStatus = { initialized: boolean; config_path: string }; + +async function json(response: Promise): Promise { + const resolved = await response; + if (!resolved.ok()) { + throw new Error(`${resolved.url()}: HTTP ${resolved.status()} ${await resolved.text()}`); + } + return (await resolved.json()) as T; +} + +/** Cookie-authenticated client over a page's request context; mutations carry the CSRF token. */ +export async function operatorApi(request: APIRequestContext) { + const csrf = await request.get("/api/v1/auth/csrf"); + if (!csrf.ok()) { + throw new Error(`csrf token: HTTP ${csrf.status()}`); + } + const { csrf_token: token } = (await csrf.json()) as { csrf_token: string }; + const headers = { [CSRF_HEADER]: token }; + + return { + setupStatus: () => json(request.get("/api/v1/setup/status")), + license: () => json(request.get("/api/v1/license")), + installLicense: (key: string) => + request.put("/api/v1/license", { headers, data: { license_key: key } }), + createTarget: (target: Record) => + request.post("/api/v1/targets", { headers, data: target }), + removeTarget: (name: string) => + request.delete(`/api/v1/targets/${encodeURIComponent(name)}`, { headers }), + }; +} diff --git a/e2e/license/license.spec.ts b/e2e/license/license.spec.ts new file mode 100644 index 00000000..50f07b9b --- /dev/null +++ b/e2e/license/license.spec.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "@playwright/test"; +import { issuerConfigured, requestTestLicense } from "../fixtures/issuer"; +import { operatorApi } from "../fixtures/operator"; + +const REMOTE_TARGET = { + name: "e2e-remote", + kind: "ssh", + ssh_alias: "e2e-remote", + workdir: "/srv/e2e", +}; +const PAYMENT_REQUIRED = 402; + +test.skip(!issuerConfigured, "OPERATOR_E2E_ISSUER_URL and OPERATOR_E2E_LICENSE_TOKEN are unset"); + +test("a test license applies to this configuration and unlocks premium", async ({ page }) => { + const operator = await operatorApi(page.request); + const before = await operator.license(); + expect(before.status).toBe("missing"); + expect((await operator.createTarget(REMOTE_TARGET)).status()).toBe(PAYMENT_REQUIRED); + + const foreign = await requestTestLicense(randomUUID()); + expect((await operator.installLicense(foreign)).ok()).toBe(false); + expect((await operator.license()).status).toBe("missing"); + + const key = await requestTestLicense(before.profile_id); + await page.goto("/#/settings/license"); + const panel = page.getByRole("region", { name: "Operator license" }); + const status = panel.locator("dt:text-is('Status') + dd"); + await expect(status).toHaveText("Free"); + await panel.getByLabel("License key").fill(key); + await panel.getByRole("button", { name: "Apply license" }).click(); + await expect(status).toHaveText("Premium"); + + const after = await operator.license(); + expect(after).toMatchObject({ status: "valid", premium: true, profile_id: before.profile_id }); + expect(after.terms?.profile_id).toBe(before.profile_id); + + expect((await operator.createTarget(REMOTE_TARGET)).ok()).toBe(true); + expect((await operator.removeTarget(REMOTE_TARGET.name)).ok()).toBe(true); + + await panel.getByRole("button", { name: "Remove license" }).click(); + await expect(status).toHaveText("Free"); + expect((await operator.license()).status).toBe("missing"); +}); diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..31e1b39a --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "@operator/e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:workspace": "playwright test --project=workspace", + "test:license": "playwright test --project=license" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "^22.0.0", + "typescript": "^5.7.3" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..1d39fd94 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; +import { ADMIN_STATE, BASE_URL } from "./fixtures/env"; + +// One server, one admin session: suites run serially against shared state. +export default defineConfig({ + globalSetup: "./fixtures/global-setup.ts", + fullyParallel: false, + workers: 1, + retries: 0, + forbidOnly: Boolean(process.env.CI), + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL: BASE_URL, + trace: "retain-on-failure", + ...devices["Desktop Chrome"], + }, + projects: [ + { name: "workspace", testDir: "./workspace" }, + { + name: "license", + testDir: "./license", + dependencies: ["workspace"], + use: { storageState: ADMIN_STATE }, + }, + ], +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..56a2eb33 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/e2e/workspace/onboarding.spec.ts b/e2e/workspace/onboarding.spec.ts new file mode 100644 index 00000000..0048b845 --- /dev/null +++ b/e2e/workspace/onboarding.spec.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "@playwright/test"; +import { ADMIN_STATE, BOOTSTRAP_PASSWORD } from "../fixtures/env"; +import { operatorApi } from "../fixtures/operator"; + +const MAX_STEPS = 25; + +test("a fresh server walks admin setup and onboarding to an initialized workspace", async ({ + page, +}) => { + const password = `e2e-${randomUUID()}`; + + await page.goto("/"); + await expect(page).toHaveURL(/#\/setup$/); + if (BOOTSTRAP_PASSWORD) { + await page.getByLabel("Temporary password").fill(BOOTSTRAP_PASSWORD); + } + await page.getByLabel("New admin password").fill(password); + await page.getByLabel("Confirm password").fill(password); + await page.getByRole("button", { name: "Create admin account" }).click(); + + await expect(page).toHaveURL(/#\/onboarding/); + await page.getByLabel("Configuration name").fill("e2e"); + + const initialize = page.getByRole("button", { name: "Initialize workspace" }); + const counter = page.getByText(/^Step \d+ of \d+$/); + for (let step = 0; step < MAX_STEPS && !(await initialize.isVisible()); step += 1) { + const before = await counter.textContent(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(counter).not.toHaveText(before ?? ""); + } + await initialize.click(); + + await expect(page).toHaveURL(/#\/$/); + const status = await (await operatorApi(page.request)).setupStatus(); + expect(status.initialized).toBe(true); + + await page.context().storageState({ path: ADMIN_STATE }); +}); diff --git a/opr8r/src/api.rs b/opr8r/src/api.rs index 9665c732..c15a69fa 100644 --- a/opr8r/src/api.rs +++ b/opr8r/src/api.rs @@ -239,10 +239,7 @@ impl ApiClient { /// Discover the API endpoint: explicit `--api-url`, then the /// `OPERATOR_API_URL` env var (set by remote launches so callbacks route /// through the SSH reverse tunnel), then api-session.json, then default. - pub async fn discover( - api_url: Option<&str>, - profile_id: Option<&str>, - ) -> Result { + pub fn discover(api_url: Option<&str>, profile_id: Option<&str>) -> Result { let env_url = std::env::var("OPERATOR_API_URL").ok(); let env_profile = std::env::var(PROFILE_ID_ENV).ok(); diff --git a/opr8r/src/main.rs b/opr8r/src/main.rs index 9180d684..e6003142 100644 --- a/opr8r/src/main.rs +++ b/opr8r/src/main.rs @@ -162,14 +162,14 @@ async fn main() -> ExitCode { } // Discover and connect to API - let api_client = - match ApiClient::discover(args.api_url.as_deref(), args.profile_id.as_deref()).await { - Ok(client) => client, - Err(e) => { - print_api_unreachable_error(&e.to_string()); - return ExitCode::from(EXIT_API_UNREACHABLE); - } - }; + let api_client = match ApiClient::discover(args.api_url.as_deref(), args.profile_id.as_deref()) + { + Ok(client) => client, + Err(e) => { + print_api_unreachable_error(&e.to_string()); + return ExitCode::from(EXIT_API_UNREACHABLE); + } + }; // Report completion to API with operator output let request = build_step_complete_request( diff --git a/package.json b/package.json index b7b4a2c6..386cd329 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,15 @@ "lint:ui": "oxlint --type-aware ui/src", "lint:webcomponents": "oxlint --type-aware --tsconfig=webcomponents/tsconfig.storybook.json webcomponents/src webcomponents/stories webcomponents/.storybook webcomponents/vitest.config.ts", "lint:vscode": "oxlint --type-aware vscode-extension/src vscode-extension/test vscode-extension/webview-ui", - "fmt": "oxfmt --write \"ui/src/**/*.{ts,tsx}\" \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/stories/**/*.{ts,tsx}\" \"webcomponents/.storybook/**/*.{ts,tsx}\" \"webcomponents/vitest.config.ts\" \"webcomponents/scripts/**/*.mjs\" \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\" \"agnt-plugin/**/*.js\" \"coder-module/*.ts\"", - "fmt:check": "oxfmt --check \"ui/src/**/*.{ts,tsx}\" \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/stories/**/*.{ts,tsx}\" \"webcomponents/.storybook/**/*.{ts,tsx}\" \"webcomponents/vitest.config.ts\" \"webcomponents/scripts/**/*.mjs\" \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\" \"agnt-plugin/**/*.js\" \"coder-module/*.ts\"", + "fmt": "oxfmt --write \"ui/src/**/*.{ts,tsx}\" \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/stories/**/*.{ts,tsx}\" \"webcomponents/.storybook/**/*.{ts,tsx}\" \"webcomponents/vitest.config.ts\" \"webcomponents/scripts/**/*.mjs\" \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\" \"agnt-plugin/**/*.js\" \"coder-module/*.ts\" \"e2e/**/*.ts\"", + "fmt:check": "oxfmt --check \"ui/src/**/*.{ts,tsx}\" \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/stories/**/*.{ts,tsx}\" \"webcomponents/.storybook/**/*.{ts,tsx}\" \"webcomponents/vitest.config.ts\" \"webcomponents/scripts/**/*.mjs\" \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\" \"agnt-plugin/**/*.js\" \"coder-module/*.ts\" \"e2e/**/*.ts\"", "fmt:ui": "oxfmt --write \"ui/src/**/*.{ts,tsx}\"", "fmt:webcomponents": "oxfmt --write \"webcomponents/src/**/*.{ts,tsx}\" \"webcomponents/stories/**/*.{ts,tsx}\" \"webcomponents/.storybook/**/*.{ts,tsx}\" \"webcomponents/vitest.config.ts\" \"webcomponents/scripts/**/*.mjs\"", "fmt:vscode": "oxfmt --write \"vscode-extension/src/**/*.ts\" \"vscode-extension/test/**/*.ts\" \"vscode-extension/webview-ui/**/*.{ts,tsx}\" \"vscode-extension/scripts/**/*.js\"", "test:webview": "bun test vscode-extension/webview-ui", "lint:agnt": "oxlint agnt-plugin", - "lint:coder-module": "oxlint --type-aware coder-module" + "lint:coder-module": "oxlint --type-aware coder-module", + "lint:e2e": "oxlint --type-aware e2e" }, "devDependencies": { "oxfmt": "0.66.0", diff --git a/scripts/ci/e2e.sh b/scripts/ci/e2e.sh new file mode 100755 index 00000000..640c502c --- /dev/null +++ b/scripts/ci/e2e.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run the e2e/ Playwright suites against an operator image in throwaway containers. +# Talks to the daemon only through the API (build, cp, start), never bind mounts, so it works under dind runners. +# The runner shares the server's network namespace: the Secure session cookie needs http://localhost. +# +# Usage: scripts/ci/e2e.sh [playwright args...] +# Env: BASE_REGISTRY (mirror prefix for the Playwright image), E2E_ARTIFACTS (copy the HTML report here), +# OPERATOR_E2E_ISSUER_URL, OPERATOR_E2E_LICENSE_TOKEN (license suite; skipped when unset), +# OPERATOR_E2E_CA_CERT (PEM trusted for the issuer, e.g. the casa internal CA) +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${1:?usage: e2e.sh [playwright args...]}" +shift + +ID="${E2E_RUN_ID:-$$}" +SERVER="operator-e2e-$ID" +RUNNER="operator-e2e-runner-$ID" +RUNNER_IMAGE="operator-e2e-runner:$ID" +SCRATCH="$(mktemp -d)" + +cleanup() { + echo "::group::operator server logs" + docker logs "$SERVER" 2>&1 || true + echo "::endgroup::" + docker rm -f "$RUNNER" "$SERVER" >/dev/null 2>&1 || true + docker rmi "$RUNNER_IMAGE" >/dev/null 2>&1 || true + rm -rf "$SCRATCH" +} +trap cleanup EXIT + +docker build \ + --build-arg BASE_REGISTRY="${BASE_REGISTRY:-}" \ + --tag "$RUNNER_IMAGE" \ + "$ROOT_DIR/e2e" + +od -An -tx1 -N16 /dev/urandom | tr -d ' \n' >"$SCRATCH/bootstrap" +chmod 0644 "$SCRATCH/bootstrap" + +docker create --name "$SERVER" \ + --env OPERATOR_BOOTSTRAP_PASSWORD_FILE=/home/operator/bootstrap \ + "$IMAGE" api >/dev/null +docker cp "$SCRATCH/bootstrap" "$SERVER:/home/operator/bootstrap" +docker start "$SERVER" >/dev/null + +ca_env=() +if [ -n "${OPERATOR_E2E_CA_CERT:-}" ]; then + printf '%s\n' "$OPERATOR_E2E_CA_CERT" >"$SCRATCH/ca.pem" + ca_env=(--env NODE_EXTRA_CA_CERTS=/tmp/issuer-ca.pem) +fi + +docker create --name "$RUNNER" \ + --network "container:$SERVER" \ + --env CI=1 \ + --env OPERATOR_E2E_BASE_URL=http://localhost:7008 \ + --env OPERATOR_E2E_BOOTSTRAP_PASSWORD="$(cat "$SCRATCH/bootstrap")" \ + --env OPERATOR_E2E_ISSUER_URL="${OPERATOR_E2E_ISSUER_URL:-}" \ + --env OPERATOR_E2E_LICENSE_TOKEN="${OPERATOR_E2E_LICENSE_TOKEN:-}" \ + ${ca_env[@]+"${ca_env[@]}"} \ + "$RUNNER_IMAGE" "$@" >/dev/null +if [ -f "$SCRATCH/ca.pem" ]; then + docker cp "$SCRATCH/ca.pem" "$RUNNER:/tmp/issuer-ca.pem" +fi + +status=0 +docker start --attach "$RUNNER" || status=$? + +if [ -n "${E2E_ARTIFACTS:-}" ]; then + mkdir -p "$E2E_ARTIFACTS" + docker cp "$RUNNER:/e2e/playwright-report" "$E2E_ARTIFACTS/" 2>/dev/null || true + docker cp "$RUNNER:/e2e/test-results" "$E2E_ARTIFACTS/" 2>/dev/null || true +fi + +exit "$status" diff --git a/shared/types.ts b/shared/types.ts index f9cfa8f7..d851a0fc 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -299,6 +299,10 @@ hosts: Array, * Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. */ targets: Array, +/** + * Named launch policies referenced by `Delegator.governance`. + */ +governance: Array, /** * Relay MCP injection configuration */ @@ -310,7 +314,12 @@ mcp: McpConfig, /** * Agent Client Protocol (ACP) agent configuration */ -acp: AcpConfig, }; +acp: AcpConfig, +/** + * In-daemon LLM calls (judge). Accepted but inert until the `native-llm` + * build feature ships; a configured judge falls back to the deterministic rule. + */ +native_llm: NativeLlmConfig, }; export type AgentsConfig = { max_parallel: number, cores_reserved: number, /** @@ -605,6 +614,10 @@ launch_config: DelegatorLaunchConfig | null, * (claude → anthropic-api, codex → openai-api, gemini → google-api). */ model_server: string | null, +/** + * Name of a `[[governance]]` policy enforced on every launch by this delegator. + */ +governance?: string | null, /** * Declarative reference to a remote, named agent on another platform * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). @@ -753,6 +766,10 @@ model_properties?: { [key in string]: string }, * Name of a declared `ModelServer` (`None` = implicit vendor default). */ model_server?: string | null, +/** + * Name of a `[[governance]]` policy. + */ +governance?: string | null, /** * Launch configuration (permission mode, flags, worktree/docker, prompt * wrapping, ...). @@ -910,7 +927,11 @@ cmux: SessionsCmuxConfig, /** * Zellij-specific configuration */ -zellij: SessionsZellijConfig, }; +zellij: SessionsZellijConfig, +/** + * How the UI connects you to a launched agent's session + */ +connect: SessionsConnectConfig, }; export type SessionWrapperType = "tmux" | "vscode" | "cmux" | "zellij"; @@ -1259,7 +1280,11 @@ project_collection_prefs: { [key in string]: string }, /** * Active multi-agent step groups (`multi_model`, `multi_prompt`, `matrixed`) */ -multi_agent_groups: Array, }; +multi_agent_groups: Array, +/** + * Campaign id → runner bookkeeping + */ +campaign_runs: { [key in string]: CampaignRun }, }; export type AgentState = { /** @@ -1364,6 +1389,16 @@ target_name: string | null, */ shutdown_recovery?: ShutdownRecovery | null, }; +export type CampaignRun = { +/** + * Ticket id → launches made by the runner + */ +launches: { [key in string]: number }, started_at?: string | null, last_error?: string | null, +/** + * Why the run paused, if it did + */ +pause_reason?: string | null, }; + export type CompletedTicket = { ticket_id: string, ticket_type: string, project: string, summary: string, completed_at: string, pr_url: string | null, output_tickets: Array, }; export type MultiAgentGroup = { @@ -1412,7 +1447,12 @@ pending_launches: Array, /** * Maps launched `agent_id` to the `variant_key` used as the output key. */ -agent_variant_keys: { [key in string]: string }, }; +agent_variant_keys: { [key in string]: string }, +/** + * The in-flight LLM judge call (set when phase = Voting). Its verdict + * arrives as a side file keyed by `attempt_id`, never through `State`. + */ +judge_attempt: JudgeAttempt | null, }; export type MultiAgentPhase = "fan_out" | "voting" | "complete" | "failed"; @@ -1735,6 +1775,224 @@ summary: string | null, */ values: { [key in string]: string }, }; +export type GovernancePolicy = { +/** + * Unique name, referenced by `Delegator.governance`. + */ +name: string, +/** + * Delegator names allowed to launch. Absent = any. + */ +allowed_delegators?: Array | null, +/** + * LLM tool names allowed to launch (e.g. `claude`). Absent = any. + */ +allowed_llm_tools?: Array | null, +/** + * Model server names allowed (declared or implicit, e.g. `anthropic-api`). Absent = any. + */ +allowed_model_servers?: Array | null, +/** + * Execution target names allowed (e.g. `local`, `docker`, a `[[targets]]` name). Absent = any. + */ +allowed_targets?: Array | null, +/** + * Maximum concurrently running agents under this policy. + */ +max_parallel?: number | null, +/** + * Maximum total launches under this policy (counted per campaign run). + */ +max_launches?: number | null, +/** + * Maximum relaunches of a failed ticket (counted per campaign run). + */ +max_retries?: number | null, +/** + * Maximum wall-clock minutes per agent. + */ +max_runtime_minutes?: number | null, +/** + * Spend ceiling in USD. Reserved: accepted and reported, not yet enforced. + */ +budget_usd?: number | null, +/** + * Require operator confirmation before each launch. + */ +require_confirmation: boolean, }; + +export type Campaign = { +/** + * Campaign id (e.g. `CAMP-0003`); member tickets reference it as `campaign:`. + */ +id: string, +/** + * Human-readable title. + */ +title: string, status: CampaignStatus, +/** + * Creation date (`YYYY-MM-DD`). + */ +created: string, +/** + * Name of a `[[governance]]` policy layered over each delegator's own. + */ +governance?: string | null, +/** + * Inline policy layered over `governance`. + */ +governance_overrides?: GovernancePolicy | null, +/** + * Shared execution environment; absent runs each ticket in its own workspace. + */ +workspace?: CampaignWorkspace | null, +/** + * Upstream container this campaign was synced from (e.g. `jira:PROJ-1`). + */ +external_ref?: string | null, }; + +export type CampaignStatus = "draft" | "ready" | "running" | "paused" | "completed" | "cancelled"; + +export type CampaignWorkspace = { "kind": "coder", template: string, } | { "kind": "docker", image: string, }; + +export type BlockReason = { "kind": "dependencies" } | { "kind": "gate", "refs": Array } | { "kind": "unsynced", "refs": Array } | { "kind": "license" } | { "kind": "capacity" }; + +export type TickReport = { status: CampaignStatus, launched: Array, awaiting_operator: Array, stalled: Array, blocked: { [key in string]: BlockReason }, pause_reason: string | null, }; + +export type BatchItem = { +/** + * Batch-local name other items use in `depends_on`. + */ +ref: string, +/** + * Template key (FEAT, FIX, TASK, SPIKE, INV, ...). + */ +template: string, project: string | null, summary: string | null, +/** + * Extra Handlebars values; `project`/`summary` above take precedence. + */ +values: { [key in string]: string }, +/** + * Batch refs, existing ticket ids, or `provider:key` refs. + */ +depends_on: Array, }; + +export type BatchRequest = { campaign: string | null, tickets: Array, }; + +export type PlannedTicket = { ref: string, +/** + * Allocated id; empty when the template is unknown. + */ +id: string, template: string, +/** + * Filename without the date prefix: `TYPE-project-slug`. + */ +filename_stem: string, +/** + * Dependencies with batch refs resolved to allocated ids. + */ +depends_on: Array, errors: Array, }; + +export type BatchPreview = { tickets: Array, +/** + * Allocated ids in dependency waves. + */ +waves: Array>, +/** + * Batch-wide errors (cycles, unknown campaign). + */ +errors: Array, valid: boolean, }; + +export type CreatedTicket = { ref: string, id: string, filename: string, }; + +export type BatchCreated = { tickets: Array, waves: Array>, }; + +export type NewCampaignInput = { title: string, +/** + * Markdown goal written as the campaign body. + */ +goal: string | null, +/** + * Name of a `[[governance]]` policy. + */ +governance: string | null, +/** + * Inline policy layered over `governance`. + */ +governance_overrides: GovernancePolicy | null, workspace: CampaignWorkspace | null, }; + +export type CampaignMember = { id: string, summary: string, project: string, +/** + * `queue`, `in-progress` or `completed`. + */ +column: string, depends_on: Array, +/** + * Launches made by the runner. + */ +launches: number, }; + +export type CampaignDetail = { campaign: Campaign, members: Array, waves: Array>, +/** + * Member id → local tickets outside the campaign it waits on. + */ +gates: { [key in string]: Array }, +/** + * Member id → unsynced upstream refs it waits on. + */ +unsynced: { [key in string]: Array }, plan_error?: string | null, }; + +export type CampaignCounters = { launched: number, max_launches: number | null, +/** + * Members in progress. + */ +active: number, max_parallel: number | null, +/** + * Most relaunches of any one member. + */ +retries: number, max_retries: number | null, }; + +export type CampaignStatusView = { id: string, status: CampaignStatus, tickets: Array, waves: Array>, +/** + * What the next tick would do. + */ +report: TickReport, counters: CampaignCounters, +/** + * The policy the campaign layers over each delegator's own. + */ +policy: GovernancePolicy, pause_reason?: string | null, }; + +export type CampaignLaunchOutcome = { id: string, dry_run: boolean, status: CampaignStatus, waves: Array>, policy: GovernancePolicy, +/** + * Reasons the campaign cannot start; empty when it can. + */ +violations: Array, +/** + * The first tick, run or (for a dry run) assessed. + */ +report: TickReport, }; + +export type AssignCampaignRequest = { +/** + * Campaign id; null removes the ticket from its campaign. + */ +campaign: string | null, +/** + * Replaces the ticket's `depends_on` when present. + */ +depends_on: Array | null, }; + +export type AssignCampaignResponse = { id: string, campaign: string | null, depends_on: Array, }; + +export type LaunchCampaignRequest = { +/** + * Report the plan, policy and violations without starting. + */ +dry_run: boolean, }; + +export type CampaignSummary = { campaign: Campaign, members: number, }; + +export type CampaignListResponse = { campaigns: Array, total: number, }; + export type CreateTicketResponse = { /** * The created ticket's id (e.g. `FEAT-1234`). @@ -1834,6 +2092,10 @@ model_properties: { [key in string]: string }, * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ model_server: string | null, +/** + * Name of a `[[governance]]` policy enforced on this delegator's launches. + */ +governance: string | null, /** * Optional launch configuration */ @@ -1883,6 +2145,10 @@ model_properties: { [key in string]: string }, * Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. */ model_server: string | null, +/** + * Name of a `[[governance]]` policy enforced on this delegator's launches. + */ +governance: string | null, /** * Optional launch configuration */ @@ -1961,7 +2227,11 @@ export type JiraSearchResponse = { /** * List of issues matching the JQL query */ -issues: Array, }; +issues: Array, +/** + * Token for the next page; absent on the last page + */ +nextPageToken: string | null, }; export type JiraIssue = { /** @@ -2001,7 +2271,19 @@ assignee: JiraUser | null, /** * Issue priority (if set) */ -priority: JiraPriority | null, }; +priority: JiraPriority | null, +/** + * Links to other issues (blocks, relates, ...) + */ +issuelinks: Array, +/** + * Parent issue or epic + */ +parent: JiraLinkedIssue | null, +/** + * Sub-task issues + */ +subtasks: Array, }; export type JiraUser = { /** diff --git a/src/agents/activity.rs b/src/agents/activity.rs index 4ab65d08..4c2fdf0b 100644 --- a/src/agents/activity.rs +++ b/src/agents/activity.rs @@ -118,7 +118,7 @@ impl TmuxActivityDetector { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - crate::agents::hex_encode(&hasher.finalize()) + hex::encode(hasher.finalize()) } } @@ -196,7 +196,7 @@ pub struct CmuxActivityDetector { idle_detector: IdleDetector, /// Content hashes for change detection content_hashes: Mutex>, - /// Map of `session_id` → `workspace_ref` (for routing cmux `read_screen` calls) + /// Map of `session_id` > `workspace_ref` (for routing cmux `read_screen` calls) workspace_refs: Mutex>, } @@ -225,7 +225,7 @@ impl CmuxActivityDetector { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - crate::agents::hex_encode(&hasher.finalize()) + hex::encode(hasher.finalize()) } fn get_workspace_ref(&self, session_id: &str) -> Option { @@ -300,7 +300,7 @@ pub struct ZellijActivityDetector { idle_detector: IdleDetector, /// Content hashes for change detection content_hashes: Mutex>, - /// Map of `session_id` → `tab_name` (for routing zellij `read_screen` calls) + /// Map of `session_id` > `tab_name` (for routing zellij `read_screen` calls) tab_names: Mutex>, } @@ -329,7 +329,7 @@ impl ZellijActivityDetector { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - crate::agents::hex_encode(&hasher.finalize()) + hex::encode(hasher.finalize()) } fn get_tab_name(&self, session_id: &str) -> Option { diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index e7488fe5..56f884bb 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -4,7 +4,7 @@ //! //! When a step transition occurs and the next step specifies a different agent //! (delegator), the current agent is gracefully exited using a 3-tier escalation -//! (`/exit` → `Ctrl+C` → `Ctrl+D`) and the new one is launched in the same terminal session. +//! (`/exit` > `Ctrl+C` > `Ctrl+D`) and the new one is launched in the same terminal session. //! //! Supports tmux and cmux backends via the `TerminalOps` trait. @@ -336,6 +336,7 @@ mod tests { fn make_delegator(name: &str, tool: &str, model: &str) -> Delegator { Delegator { + governance: None, git: None, name: name.to_string(), llm_tool: tool.to_string(), diff --git a/src/agents/connect.rs b/src/agents/connect.rs new file mode 100644 index 00000000..7d155ebd --- /dev/null +++ b/src/agents/connect.rs @@ -0,0 +1,960 @@ +//! Connect links and attention signals for launched agents. + +use chrono::{DateTime, Duration, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use url::Url; +use utoipa::ToSchema; + +use crate::agents::delegator_resolution::resolve_named_target; +use crate::agents::launcher::coder::workspace_alias; +use crate::agents::launcher::prompt::shell_escape_if_needed as shell_escape; +use crate::agents::{parse_launch_mode, LaunchModeKind}; +use crate::config::{Config, ConnectInterface, RemoteHost, TargetKind}; +use crate::state::AgentState; + +/// URI authority of the Operator VS Code/Cursor extension. +pub const OPERATOR_EXTENSION: &str = "untra.operator-terminals"; + +/// Minutes without pane output before a running agent asks for attention. +pub const IDLE_ATTENTION_MINUTES: i64 = 10; + +/// How a client acts on a connect option +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum ConnectKind { + /// Open `uri` in the browser host (IDE deep link) + Ide, + /// Call `POST /api/v1/agents/{id}/focus` + FocusApi, + /// Copy `command` to run in a terminal + Command, +} + +/// Whether the agent's session can be attached or only resumed +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum SessionPhase { + /// The terminal session is alive; attach to it + Live, + /// The session is gone; resume the LLM conversation by id + Resumable, + /// Nothing to attach or resume + Gone, +} + +/// One way to reach an agent's session +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ConnectOption { + /// Interface this option opens + pub interface: ConnectInterface, + /// How the client acts on it + pub kind: ConnectKind, + /// Button label + pub label: String, + /// Deep link for `ide` options + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub uri: Option, + /// Shell command for `command` options + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub command: Option, + /// Whether the option can be used right now + pub available: bool, + /// Why the option is unavailable + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub unavailable_reason: Option, +} + +/// Every connect option for an agent, with the preferred one called out +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ConnectPlan { + /// Attach vs resume + pub phase: SessionPhase, + /// Primary option (configured preference, else the session wrapper's) + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub preferred: Option, + /// All applicable options, preferred included + pub options: Vec, +} + +/// Why an agent needs the operator +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export)] +pub enum AttentionReason { + /// The agent is waiting for input + AwaitingInput, + /// A review gate is pending + Review, + /// The session disappeared + Orphaned, + /// No output for a while + Idle, +} + +/// Attention nudge for an agent +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct Attention { + /// Why attention is needed + pub reason: AttentionReason, + /// Short context (review state or last message) + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub detail: Option, + /// Since when (ISO 8601) + pub since: String, +} + +/// What an IDE extension runs after opening the agent's workspace +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ConnectTerminal { + /// Attach vs resume + pub phase: SessionPhase, + /// Ticket ID, for naming the terminal + pub ticket_id: String, + /// Existing terminal or session name + pub terminal_name: String, + /// Directory to start in + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub cwd: Option, + /// Command to run inside the agent's environment; absent means focus `terminal_name` + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub command: Option, +} + +/// Where a remote agent's session runs +#[derive(Debug, Clone, PartialEq)] +pub enum RemoteSession { + /// A `[[hosts]]` entry or ssh target + Ssh(RemoteHost), + /// A Coder workspace reached over its provisioned alias + Coder { + host: RemoteHost, + workspace: String, + url: Option, + }, +} + +impl RemoteSession { + fn host(&self) -> &RemoteHost { + match self { + RemoteSession::Ssh(host) | RemoteSession::Coder { host, .. } => host, + } + } +} + +/// Facts about installed and running interfaces, gathered once per request +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConnectProbes { + pub vscode_extension: bool, + pub code: bool, + pub cursor: bool, + pub zed: bool, +} + +/// Inputs for building connect options +#[derive(Debug, Clone)] +pub struct ConnectContext<'a> { + pub agent: &'a AgentState, + pub remote: Option<&'a RemoteSession>, + pub tmux_socket: Option<&'a str>, + pub preferred: Option, + pub can_open_terminal: bool, + pub probes: &'a ConnectProbes, +} + +/// Interactive resume command for a tool's conversation. +pub fn resume_command(tool: &str, session_id: &str) -> Option { + let id = shell_escape(session_id); + match tool { + "claude" | "gemini" => Some(format!("{tool} --resume {id}")), + "codex" => Some(format!("codex resume {id}")), + _ => None, + } +} + +fn agent_resume_command(agent: &AgentState) -> Option { + let ctx = agent.step_launch_context.as_ref(); + let session_id = ctx.and_then(|c| c.session_id.as_deref())?; + let tool = ctx.map(|c| c.tool.as_str()).or(agent.llm_tool.as_deref())?; + resume_command(tool, session_id) +} + +pub fn session_phase(agent: &AgentState) -> SessionPhase { + if agent.session_name.is_some() && agent.status != "orphaned" { + SessionPhase::Live + } else if agent_resume_command(agent).is_some() { + SessionPhase::Resumable + } else { + SessionPhase::Gone + } +} + +fn workdir(ctx: &ConnectContext) -> Option { + ctx.remote + .map(|r| r.host().workdir.clone()) + .or_else(|| ctx.agent.worktree_path.clone()) +} + +fn wrapper(agent: &AgentState) -> &str { + agent.session_wrapper.as_deref().unwrap_or("tmux") +} + +fn wrapper_interface(agent: &AgentState) -> ConnectInterface { + match wrapper(agent) { + "vscode" => ConnectInterface::Vscode, + "cmux" => ConnectInterface::Cmux, + "zellij" => ConnectInterface::Zellij, + _ => ConnectInterface::Tmux, + } +} + +fn tmux_attach(socket: Option<&str>, session: &str) -> String { + let session = shell_escape(session); + socket.map_or_else( + || format!("tmux attach -t {session}"), + |s| format!("tmux -L {} attach -t {session}", shell_escape(s)), + ) +} + +fn in_dir(cwd: Option<&str>, command: &str) -> String { + cwd.map_or_else( + || command.to_string(), + |dir| format!("cd {} && {command}", shell_escape(dir)), + ) +} + +fn ssh_wrap(host: &RemoteHost, command: &str) -> String { + let f_flag = host + .ssh_config_path + .as_deref() + .map(|p| format!("-F {} ", shell_escape(p))) + .unwrap_or_default(); + format!( + "ssh -t {f_flag}{} {}", + shell_escape(&host.ssh_alias), + shell_escape(command) + ) +} + +/// Command that enters the session from inside the agent's own environment. +pub fn session_command(ctx: &ConnectContext) -> Option { + let agent = ctx.agent; + match session_phase(agent) { + SessionPhase::Live => { + let session = agent.session_name.as_deref()?; + if ctx.remote.is_some() { + return Some(tmux_attach(None, session)); + } + match wrapper(agent) { + "tmux" => Some(tmux_attach(ctx.tmux_socket, session)), + "zellij" => Some(format!("zellij attach {}", shell_escape(session))), + _ => None, + } + } + SessionPhase::Resumable => { + agent_resume_command(agent).map(|cmd| in_dir(workdir(ctx).as_deref(), &cmd)) + } + SessionPhase::Gone => None, + } +} + +/// Command that enters the session from the operator's machine. +pub fn local_command(ctx: &ConnectContext) -> Option { + let inner = session_command(ctx)?; + Some( + ctx.remote + .map_or(inner.clone(), |r| ssh_wrap(r.host(), &inner)), + ) +} + +/// Expand `sessions.connect.terminal_command`, `{command}` becoming `sh -c ''`. +pub fn terminal_invocation(template: &str, command: &str) -> String { + template.replace( + "{command}", + &format!( + "sh -c {}", + crate::agents::launcher::prompt::shell_escape(command) + ), + ) +} + +/// What the IDE extension runs once the workspace is open. +pub fn connect_terminal(ctx: &ConnectContext) -> ConnectTerminal { + let agent = ctx.agent; + ConnectTerminal { + phase: session_phase(agent), + ticket_id: agent.ticket_id.clone(), + terminal_name: agent + .session_name + .clone() + .unwrap_or_else(|| agent.ticket_id.clone()), + cwd: workdir(ctx), + command: session_command(ctx), + } +} + +/// `{scheme}://untra.operator-terminals/connect?agent=..&authority=..&path=..` +pub fn operator_connect_uri( + scheme: &str, + agent_id: &str, + authority: Option<&str>, + path: &str, +) -> Option { + let mut url = Url::parse(&format!("{scheme}://{OPERATOR_EXTENSION}/connect")).ok()?; + { + let mut query = url.query_pairs_mut(); + query.append_pair("agent", agent_id); + if let Some(authority) = authority { + query.append_pair("authority", authority); + } + query.append_pair("path", path); + } + Some(url.to_string()) +} + +fn focus_session_uri(session: &str) -> Option { + let mut url = Url::parse(&format!("vscode://{OPERATOR_EXTENSION}/focus-session")).ok()?; + url.query_pairs_mut().append_pair("name", session); + Some(url.to_string()) +} + +fn zed_uri(remote: Option<&RemoteHost>, path: &str) -> Option { + let raw = remote.map_or_else( + || format!("zed://file{path}"), + |host| format!("zed://ssh/{}{path}", host.ssh_alias), + ); + Url::parse(&raw).ok().map(|u| u.to_string()) +} + +fn coder_uri(workspace: &str, path: &str, url: &str) -> Option { + let mut uri = Url::parse("vscode://coder.coder-remote/open").ok()?; + uri.query_pairs_mut() + .append_pair("workspace", workspace) + .append_pair("folder", path) + .append_pair("url", url); + Some(uri.to_string()) +} + +fn option( + interface: ConnectInterface, + kind: ConnectKind, + label: String, + uri: Option, + command: Option, +) -> ConnectOption { + ConnectOption { + interface, + kind, + label, + uri, + command, + available: true, + unavailable_reason: None, + } +} + +fn host_suffix(remote: Option<&RemoteSession>) -> String { + remote.map_or_else(String::new, |r| { + let host = r.host(); + format!( + " (ssh: {})", + host.display_name.as_deref().unwrap_or(&host.name) + ) + }) +} + +fn ide_options(ctx: &ConnectContext, phase: SessionPhase) -> Vec { + let Some(path) = workdir(ctx) else { + return Vec::new(); + }; + let verb = if phase == SessionPhase::Resumable { + "Resume in" + } else { + "Open in" + }; + + if let Some(RemoteSession::Coder { workspace, url, .. }) = ctx.remote { + let mut coder = option( + ConnectInterface::Coder, + ConnectKind::Ide, + format!("Open coder/{workspace} in VS Code"), + url.as_deref().and_then(|u| coder_uri(workspace, &path, u)), + None, + ); + if coder.uri.is_none() { + coder.available = false; + coder.unavailable_reason = Some("Coder deployment URL is not set".to_string()); + } + return vec![coder]; + } + + let host = ctx.remote.map(RemoteSession::host); + let authority = host.map(|h| format!("ssh-remote+{}", h.ssh_alias)); + let suffix = host_suffix(ctx.remote); + let probes = ctx.probes; + let mut out = Vec::new(); + + let vscode = probes.vscode_extension || probes.code || wrapper(ctx.agent) == "vscode"; + let ides = [ + (vscode, ConnectInterface::Vscode, "vscode"), + (probes.cursor, ConnectInterface::Cursor, "cursor"), + ]; + for (enabled, interface, scheme) in ides { + if enabled { + out.push(option( + interface, + ConnectKind::Ide, + format!("{verb} {}{suffix}", interface.label()), + operator_connect_uri(scheme, &ctx.agent.id, authority.as_deref(), &path), + None, + )); + } + } + if probes.zed { + out.push(option( + ConnectInterface::Zed, + ConnectKind::Ide, + format!("Open folder in Zed{suffix}"), + zed_uri(host, &path), + None, + )); + } + out +} + +fn session_options(ctx: &ConnectContext, phase: SessionPhase) -> Vec { + let agent = ctx.agent; + let mut out = Vec::new(); + let command = local_command(ctx); + + if phase == SessionPhase::Live && ctx.remote.is_none() { + match wrapper(agent) { + "cmux" => out.push(option( + ConnectInterface::Cmux, + ConnectKind::FocusApi, + "Focus in cmux".to_string(), + None, + None, + )), + "tmux" => out.push(option( + ConnectInterface::Tmux, + ConnectKind::FocusApi, + "Switch tmux client".to_string(), + None, + None, + )), + "vscode" => { + if let Some(session) = agent.session_name.as_deref() { + out.push(option( + ConnectInterface::Vscode, + ConnectKind::Ide, + "Focus VS Code terminal".to_string(), + focus_session_uri(session), + None, + )); + } + } + _ => {} + } + } + + if let Some(cmd) = command { + let interface = match (ctx.remote, phase) { + (Some(_), _) => ConnectInterface::Ssh, + (None, SessionPhase::Resumable) => ConnectInterface::Terminal, + (None, _) => wrapper_interface(agent), + }; + let verb = if phase == SessionPhase::Resumable { + "Resume" + } else { + "Attach" + }; + if ctx.can_open_terminal && interface != ConnectInterface::Tmux { + out.push(option( + ConnectInterface::Terminal, + ConnectKind::FocusApi, + format!("{verb} in terminal"), + None, + None, + )); + } + out.push(option( + interface, + ConnectKind::Command, + format!("{verb} with {}", interface.label()), + None, + Some(cmd), + )); + } + out +} + +fn pick_preferred( + options: &[ConnectOption], + preferred: Option, + fallback: ConnectInterface, +) -> Option { + let usable = + |iface: ConnectInterface| options.iter().find(|o| o.interface == iface && o.available); + preferred + .and_then(usable) + .or_else(|| usable(fallback)) + .or_else(|| options.iter().find(|o| o.available)) + .cloned() +} + +/// All connect options for an agent. +pub fn connect_plan(ctx: &ConnectContext) -> ConnectPlan { + let phase = session_phase(ctx.agent); + let mut options = Vec::new(); + if phase != SessionPhase::Gone { + options.extend(ide_options(ctx, phase)); + options.extend(session_options(ctx, phase)); + } + let fallback = if ctx.remote.is_some() { + ConnectInterface::Ssh + } else { + wrapper_interface(ctx.agent) + }; + ConnectPlan { + phase, + preferred: pick_preferred(&options, ctx.preferred, fallback), + options, + } +} + +/// Whether and why an agent needs the operator. +pub fn attention(agent: &AgentState, now: DateTime) -> Option { + let since = agent.last_activity.to_rfc3339(); + match agent.status.as_str() { + "awaiting_input" => Some(match agent.review_state.as_deref() { + Some(review) => Attention { + reason: AttentionReason::Review, + detail: Some(review.to_string()), + since, + }, + None => Attention { + reason: AttentionReason::AwaitingInput, + detail: agent.last_message.clone(), + since, + }, + }), + "orphaned" => Some(Attention { + reason: AttentionReason::Orphaned, + detail: agent.last_message.clone(), + since, + }), + "running" => agent + .last_content_change + .filter(|changed| now - *changed > Duration::minutes(IDLE_ATTENTION_MINUTES)) + .map(|changed| Attention { + reason: AttentionReason::Idle, + detail: None, + since: changed.to_rfc3339(), + }), + _ => None, + } +} + +/// The remote session an agent runs in, from its persisted target and host. +pub fn resolve_remote(config: &Config, agent: &AgentState) -> Option { + let target = agent + .target_name + .as_deref() + .and_then(|name| resolve_named_target(config, name).ok()); + let coder = target.as_ref().and_then(|t| match &t.kind { + TargetKind::Coder(c) => Some(c.clone()), + _ => None, + }); + let is_coder = coder.is_some() + || agent + .launch_mode + .as_deref() + .map(parse_launch_mode) + .is_some_and(|m| m.kind == LaunchModeKind::Coder); + let name = agent.remote_host.as_deref()?; + + if is_coder { + let workdir = coder + .as_ref() + .and_then(|c| c.workdir.clone()) + .unwrap_or_else(|| format!("/home/coder/{}", agent.project)); + let fragment = config + .tickets_path() + .join(format!("operator/ssh/{name}.config")); + return Some(RemoteSession::Coder { + host: RemoteHost { + name: name.to_string(), + ssh_alias: workspace_alias(name), + workdir, + display_name: Some(format!("coder/{name}")), + ssh_config_path: Some(fragment.to_string_lossy().into_owned()), + }, + workspace: name.to_string(), + url: coder.and_then(|c| std::env::var(c.url_env).ok()), + }); + } + + target + .and_then(|t| t.as_remote_host()) + .or_else(|| { + resolve_named_target(config, name) + .ok() + .and_then(|t| t.as_remote_host()) + }) + .map(RemoteSession::Ssh) +} + +/// Probe which IDEs are installed and whether the VS Code extension is listening. +pub fn probe(config: &Config) -> ConnectProbes { + let port = config.sessions.vscode.webhook_port; + ConnectProbes { + vscode_extension: std::net::TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], port)), + std::time::Duration::from_millis(100), + ) + .is_ok(), + code: which::which("code").is_ok(), + cursor: which::which("cursor").is_ok(), + zed: which::which("zed").is_ok(), + } +} + +/// tmux socket Operator's sessions live on, when its generated config is in use. +pub fn tmux_socket(config: &Config) -> Option<&'static str> { + (config.tmux.config_generated && config.tmux_config_path().exists()) + .then_some(crate::agents::tmux::OPERATOR_SOCKET) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::launcher::step_command::StepLaunchContext; + + fn agent() -> AgentState { + let now = Utc::now(); + AgentState { + git_context: None, + id: "agent-1".to_string(), + ticket_id: "FEAT-12".to_string(), + ticket_type: "FEAT".to_string(), + project: "proj".to_string(), + status: "running".to_string(), + started_at: now, + last_activity: now, + last_message: None, + paired: false, + session_name: Some("op-FEAT-12".to_string()), + session_wrapper: Some("tmux".to_string()), + 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: None, + pr_url: None, + pr_number: None, + repo: None, + pr_status: None, + completed_steps: vec![], + llm_tool: Some("claude".to_string()), + llm_model: None, + launch_mode: None, + review_state: None, + dev_server_pid: None, + worktree_path: Some("/wt/feat-12".to_string()), + remote_host: None, + step_launch_context: None, + target_name: None, + shutdown_recovery: None, + } + } + + fn with_session_id(mut a: AgentState) -> AgentState { + a.step_launch_context = Some(StepLaunchContext { + delegator: None, + tool: "claude".to_string(), + model: "opus".to_string(), + yolo: false, + session_id: Some("sess-1".to_string()), + opr8r: "opr8r".to_string(), + operator_relay: None, + extra_flags: vec![], + }); + a + } + + fn ssh_remote() -> RemoteSession { + RemoteSession::Ssh(RemoteHost { + name: "devbox".to_string(), + ssh_alias: "devbox".to_string(), + workdir: "/home/me/proj".to_string(), + display_name: None, + ssh_config_path: None, + }) + } + + fn ctx<'a>( + agent: &'a AgentState, + remote: Option<&'a RemoteSession>, + probes: &'a ConnectProbes, + ) -> ConnectContext<'a> { + ConnectContext { + agent, + remote, + tmux_socket: None, + preferred: None, + can_open_terminal: false, + probes, + } + } + + fn vscode_probes() -> ConnectProbes { + ConnectProbes { + code: true, + ..ConnectProbes::default() + } + } + + #[test] + fn test_resume_command_per_tool() { + assert_eq!( + resume_command("claude", "abc").as_deref(), + Some("claude --resume abc") + ); + assert_eq!( + resume_command("codex", "abc").as_deref(), + Some("codex resume abc") + ); + assert_eq!(resume_command("grok", "abc"), None); + } + + #[test] + fn test_session_phase_live_resumable_gone() { + assert_eq!(session_phase(&agent()), SessionPhase::Live); + let mut orphan = with_session_id(agent()); + orphan.status = "orphaned".to_string(); + assert_eq!(session_phase(&orphan), SessionPhase::Resumable); + let mut gone = agent(); + gone.status = "orphaned".to_string(); + assert_eq!(session_phase(&gone), SessionPhase::Gone); + } + + #[test] + fn test_connect_plan_ssh_agent_builds_remote_vscode_uri_and_ssh_attach() { + let a = agent(); + let remote = ssh_remote(); + let probes = vscode_probes(); + let plan = connect_plan(&ctx(&a, Some(&remote), &probes)); + + let vscode = plan + .options + .iter() + .find(|o| o.interface == ConnectInterface::Vscode) + .unwrap(); + let uri = vscode.uri.as_deref().unwrap(); + assert!(uri.starts_with("vscode://untra.operator-terminals/connect?")); + assert!(uri.contains("authority=ssh-remote%2Bdevbox")); + assert!(uri.contains("path=%2Fhome%2Fme%2Fproj")); + + let ssh = plan + .options + .iter() + .find(|o| o.interface == ConnectInterface::Ssh) + .unwrap(); + assert_eq!( + ssh.command.as_deref(), + Some("ssh -t devbox 'tmux attach -t op-FEAT-12'") + ); + } + + #[test] + fn test_connect_plan_uris_never_carry_commands() { + let a = with_session_id(agent()); + let remote = ssh_remote(); + let probes = ConnectProbes { + code: true, + cursor: true, + zed: true, + vscode_extension: true, + }; + let plan = connect_plan(&ctx(&a, Some(&remote), &probes)); + for o in plan.options.iter().filter_map(|o| o.uri.as_deref()) { + assert!(!o.contains("attach"), "{o}"); + assert!(!o.contains("resume"), "{o}"); + } + } + + #[test] + fn test_connect_plan_live_session_never_resumes() { + let a = with_session_id(agent()); + let probes = vscode_probes(); + let plan = connect_plan(&ctx(&a, None, &probes)); + assert_eq!(plan.phase, SessionPhase::Live); + assert!(plan + .options + .iter() + .filter_map(|o| o.command.as_deref()) + .all(|c| !c.contains("--resume"))); + } + + #[test] + fn test_connect_plan_orphaned_agent_offers_resume_in_worktree() { + let mut a = with_session_id(agent()); + a.status = "orphaned".to_string(); + let probes = ConnectProbes::default(); + let plan = connect_plan(&ctx(&a, None, &probes)); + let cmd = plan + .options + .iter() + .find(|o| o.kind == ConnectKind::Command) + .and_then(|o| o.command.as_deref()) + .unwrap(); + assert_eq!(cmd, "cd /wt/feat-12 && claude --resume sess-1"); + } + + #[test] + fn test_connect_plan_gone_has_no_options() { + let mut a = agent(); + a.status = "orphaned".to_string(); + let probes = vscode_probes(); + let plan = connect_plan(&ctx(&a, None, &probes)); + assert!(plan.options.is_empty()); + assert!(plan.preferred.is_none()); + } + + #[test] + fn test_connect_plan_preferred_honours_config_then_wrapper() { + let a = agent(); + let probes = vscode_probes(); + let mut c = ctx(&a, None, &probes); + assert_eq!( + connect_plan(&c).preferred.unwrap().interface, + ConnectInterface::Tmux + ); + c.preferred = Some(ConnectInterface::Vscode); + assert_eq!( + connect_plan(&c).preferred.unwrap().interface, + ConnectInterface::Vscode + ); + c.preferred = Some(ConnectInterface::Zed); + assert_eq!( + connect_plan(&c).preferred.unwrap().interface, + ConnectInterface::Tmux + ); + } + + #[test] + fn test_connect_plan_tmux_socket_in_attach_command() { + let a = agent(); + let probes = ConnectProbes::default(); + let mut c = ctx(&a, None, &probes); + c.tmux_socket = Some("operator"); + let plan = connect_plan(&c); + assert!(plan + .options + .iter() + .any(|o| o.command.as_deref() == Some("tmux -L operator attach -t op-FEAT-12"))); + } + + #[test] + fn test_connect_plan_coder_without_url_is_unavailable() { + let a = agent(); + let remote = RemoteSession::Coder { + host: RemoteHost { + name: "ws".to_string(), + ssh_alias: "op-coder-ws".to_string(), + workdir: "/home/coder/proj".to_string(), + display_name: Some("coder/ws".to_string()), + ssh_config_path: Some("/t/ws.config".to_string()), + }, + workspace: "ws".to_string(), + url: None, + }; + let probes = vscode_probes(); + let plan = connect_plan(&ctx(&a, Some(&remote), &probes)); + let coder = plan + .options + .iter() + .find(|o| o.interface == ConnectInterface::Coder) + .unwrap(); + assert!(!coder.available); + let ssh = plan + .options + .iter() + .find(|o| o.interface == ConnectInterface::Ssh) + .unwrap(); + assert!(ssh.command.as_deref().unwrap().contains("-F /t/ws.config")); + assert_eq!(plan.preferred.unwrap().interface, ConnectInterface::Ssh); + } + + #[test] + fn test_connect_terminal_vscode_wrapper_focuses_existing() { + let mut a = agent(); + a.session_wrapper = Some("vscode".to_string()); + let probes = ConnectProbes::default(); + let t = connect_terminal(&ctx(&a, None, &probes)); + assert_eq!(t.command, None); + assert_eq!(t.terminal_name, "op-FEAT-12"); + } + + #[test] + fn test_connect_terminal_remote_runs_inner_attach() { + let a = agent(); + let remote = ssh_remote(); + let probes = ConnectProbes::default(); + let t = connect_terminal(&ctx(&a, Some(&remote), &probes)); + assert_eq!(t.command.as_deref(), Some("tmux attach -t op-FEAT-12")); + assert_eq!(t.cwd.as_deref(), Some("/home/me/proj")); + } + + #[test] + fn test_terminal_invocation_wraps_command_in_sh() { + assert_eq!( + terminal_invocation("open -na Ghostty --args -e {command}", "tmux attach -t s"), + "open -na Ghostty --args -e sh -c 'tmux attach -t s'" + ); + } + + #[test] + fn test_attention_reasons() { + let now = Utc::now(); + let mut a = agent(); + assert_eq!(attention(&a, now), None); + + a.status = "awaiting_input".to_string(); + assert_eq!( + attention(&a, now).unwrap().reason, + AttentionReason::AwaitingInput + ); + a.review_state = Some("pending_plan".to_string()); + assert_eq!(attention(&a, now).unwrap().reason, AttentionReason::Review); + + a.status = "orphaned".to_string(); + assert_eq!( + attention(&a, now).unwrap().reason, + AttentionReason::Orphaned + ); + + a.status = "running".to_string(); + a.last_content_change = Some(now - Duration::minutes(IDLE_ATTENTION_MINUTES + 1)); + assert_eq!(attention(&a, now).unwrap().reason, AttentionReason::Idle); + a.last_content_change = Some(now); + assert_eq!(attention(&a, now), None); + } +} diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 10bbee8b..6a8b1492 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -436,6 +436,62 @@ pub fn resolve_launch_options( Ok(options) } +/// Running tickets under a policy: `(ticket_id, delegator_name)` pairs. +pub type RunningLaunches<'a> = [(&'a str, &'a str)]; + +/// Enforce governance on a launch: `outer` layers (e.g. a campaign's) stacked +/// over the launching delegator's `[[governance]]` policy. +/// +/// With no layers the launch is unconstrained. `max_parallel` counts other +/// tickets running under the delegator's policy. +pub fn enforce_governance( + config: &Config, + options: &LaunchOptions, + outer: &[&crate::config::GovernancePolicy], + ticket_id: &str, + running: &RunningLaunches<'_>, +) -> Result<(), crate::config::governance::GovernanceViolation> { + use crate::config::governance::{ + evaluate, find_policy, layer, GovernanceCounters, LaunchFacts, + }; + let delegator = options + .delegator_name + .as_deref() + .and_then(|name| config.delegators.iter().find(|d| d.name == name)); + let policy_name = delegator.and_then(|d| d.governance.as_deref()); + let own = policy_name + .map(|name| find_policy(&config.governance, name)) + .transpose()?; + let layers: Vec<&crate::config::GovernancePolicy> = outer.iter().copied().chain(own).collect(); + if layers.is_empty() { + return Ok(()); + } + let governed = |name: &str| { + config.delegators.iter().any(|d| { + d.name == name && policy_name.is_some() && d.governance.as_deref() == policy_name + }) + }; + let active = running + .iter() + .filter(|(ticket, name)| *ticket != ticket_id && governed(name)) + .map(|(ticket, _)| *ticket) + .collect::>() + .len(); + let facts = LaunchFacts { + delegator: options.delegator_name.clone(), + llm_tool: options.provider.as_ref().map(|p| p.tool.clone()), + model_server: delegator + .and_then(|d| resolve_model_server_for_delegator(config, d).ok()) + .map(|s| s.name), + target: options.target.name.clone(), + }; + let counters = GovernanceCounters { + active: u32::try_from(active).unwrap_or(u32::MAX), + ..GovernanceCounters::default() + }; + evaluate(&layer(&layers), &facts, &counters) +} + #[cfg(test)] mod tests { use super::*; @@ -443,6 +499,7 @@ mod tests { fn make_delegator(name: &str, tool: &str, model: &str) -> Delegator { Delegator { + governance: None, git: None, name: name.to_string(), llm_tool: tool.to_string(), @@ -751,6 +808,7 @@ mod tests { fn test_resolve_delegator_applies_launch_config() { let mut config = Config::default(); config.delegators.push(Delegator { + governance: None, git: None, name: "full".to_string(), llm_tool: "claude".to_string(), @@ -1052,4 +1110,102 @@ mod tests { assert_eq!(via_host.as_remote_host(), via_target.as_remote_host()); } + + fn governed_config(policy: crate::config::GovernancePolicy) -> Config { + let mut config = Config::default(); + let mut d = make_delegator("opus", "claude", "opus"); + d.governance = Some(policy.name.clone()); + config.delegators.push(d); + config + .delegators + .push(make_delegator("free", "claude", "sonnet")); + config.governance.push(policy); + config + } + + fn options_for(config: &Config, delegator: &str) -> LaunchOptions { + resolve_launch_options(config, Some(delegator), None, None, None, false, None).unwrap() + } + + #[test] + fn test_enforce_governance_ungoverned_delegator_passes() { + let config = governed_config(crate::config::GovernancePolicy { + name: "strict".into(), + allowed_targets: Some(vec![]), + ..Default::default() + }); + let options = options_for(&config, "free"); + assert!(enforce_governance(&config, &options, &[], "FEAT-1", &[]).is_ok()); + } + + #[test] + fn test_enforce_governance_denies_disallowed_target() { + let config = governed_config(crate::config::GovernancePolicy { + name: "remote-only".into(), + allowed_targets: Some(vec!["coder".into()]), + ..Default::default() + }); + let options = options_for(&config, "opus"); + let err = enforce_governance(&config, &options, &[], "FEAT-1", &[]).unwrap_err(); + assert!(err.reason.contains("target 'local'"), "{err}"); + } + + #[test] + fn test_enforce_governance_checks_implicit_model_server() { + let config = governed_config(crate::config::GovernancePolicy { + name: "anthropic".into(), + allowed_model_servers: Some(vec!["anthropic-api".into()]), + ..Default::default() + }); + let options = options_for(&config, "opus"); + assert!(enforce_governance(&config, &options, &[], "FEAT-1", &[]).is_ok()); + } + + #[test] + fn test_enforce_governance_max_parallel_counts_other_tickets_under_policy() { + let config = governed_config(crate::config::GovernancePolicy { + name: "one".into(), + max_parallel: Some(1), + ..Default::default() + }); + let options = options_for(&config, "opus"); + assert!( + enforce_governance(&config, &options, &[], "FEAT-2", &[("FEAT-9", "free")]).is_ok() + ); + assert!( + enforce_governance(&config, &options, &[], "FEAT-2", &[("FEAT-2", "opus")]).is_ok() + ); + assert!( + enforce_governance(&config, &options, &[], "FEAT-2", &[("FEAT-1", "opus")]).is_err() + ); + } + + #[test] + fn test_enforce_governance_unknown_policy_is_hard_error() { + let mut config = governed_config(crate::config::GovernancePolicy { + name: "real".into(), + ..Default::default() + }); + config.delegators[0].governance = Some("ghost".into()); + let options = options_for(&config, "opus"); + let err = enforce_governance(&config, &options, &[], "FEAT-1", &[]).unwrap_err(); + assert_eq!(err.policy, "ghost"); + } + + #[test] + fn test_enforce_governance_outer_layer_restricts_ungoverned_delegator() { + let config = governed_config(crate::config::GovernancePolicy { + name: "unused".into(), + ..Default::default() + }); + let campaign = crate::config::GovernancePolicy { + name: "campaign".into(), + allowed_delegators: Some(vec!["opus".into()]), + ..Default::default() + }; + let options = options_for(&config, "free"); + assert!(enforce_governance(&config, &options, &[&campaign], "FEAT-1", &[]).is_err()); + let options = options_for(&config, "opus"); + assert!(enforce_governance(&config, &options, &[&campaign], "FEAT-1", &[]).is_ok()); + } } diff --git a/src/agents/launcher/coder.rs b/src/agents/launcher/coder.rs index a40a0d06..6a7e5089 100644 --- a/src/agents/launcher/coder.rs +++ b/src/agents/launcher/coder.rs @@ -13,10 +13,12 @@ //! to and nothing has to be baked into the Operator image. use std::ffi::OsStr; +use std::hash::Hasher; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; +use fnv::FnvHasher; use crate::config::{CoderConfig, Config, RemoteHost}; @@ -70,7 +72,7 @@ pub fn workspace_name(prefix: &str, project: &str, ticket_id: &str) -> String { } let hash = fnv1a(&full); let truncated = full[..TRUNCATED_KEY_LEN].trim_end_matches('-'); - format!("{truncated}-{:06x}", hash & 0xFF_FFFF) + format!("{truncated}-{:06x}", hash % 0x0100_0000) } fn sanitize(s: &str) -> String { @@ -89,12 +91,9 @@ fn sanitize(s: &str) -> String { } fn fnv1a(s: &str) -> u64 { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for b in s.bytes() { - hash ^= u64::from(b); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - hash + let mut hasher = FnvHasher::default(); + hasher.write(s.as_bytes()); + hasher.finish() } /// The provisioned ssh alias for a workspace. @@ -681,6 +680,18 @@ mod tests { assert_ne!(name, other); } + #[test] + fn test_workspace_name_truncated_suffix_is_stable() { + assert_eq!( + workspace_name("op", "a-very-long-project-name-here", "FEATURE-12345"), + "op-a-very-long-project-na-81f238" + ); + assert_eq!( + workspace_name("op", "a-very-long-project-name-here", "FEATURE-12346"), + "op-a-very-long-project-na-81f751" + ); + } + /// Create an executable stub at `path`, parent dirs included. fn touch_executable(path: &std::path::Path) { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); diff --git a/src/agents/launcher/interpolation.rs b/src/agents/launcher/interpolation.rs index b3eb2216..055c09ae 100644 --- a/src/agents/launcher/interpolation.rs +++ b/src/agents/launcher/interpolation.rs @@ -281,6 +281,9 @@ mod tests { fn make_test_ticket() -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241225-1200-FEAT-test-test.md".to_string(), filepath: "/tmp/tickets/queue/20241225-1200-FEAT-test-test.md".to_string(), timestamp: "20241225-1200".to_string(), diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index b52ac7dc..05206084 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -263,7 +263,7 @@ impl Launcher { ticket: &Ticket, options: LaunchOptions, ) -> Result { - crate::licensing::require_target(&self.config, &options.target)?; + self.require_launch_allowed(&ticket.id, &options)?; self.require_step_entitlement(ticket, &options)?; let git = crate::git::identity::resolve_config( &self.config, @@ -408,7 +408,7 @@ impl Launcher { working_dir_str: &str, options: &mut LaunchOptions, ) -> Result<()> { - crate::licensing::require_target(&self.config, &options.target)?; + self.require_launch_allowed(&ticket.id, options)?; if let crate::config::TargetKind::Coder(coder_cfg) = &options.target.kind { let remote_url = crate::git::GitCli::get_remote_url(std::path::Path::new(working_dir_str)) @@ -443,7 +443,7 @@ impl Launcher { initial_prompt: &str, options: &LaunchOptions, ) -> Result<(String, String)> { - crate::licensing::require_target(&self.config, &options.target)?; + self.require_launch_allowed(&ticket.id, options)?; // Pre-allocate agent ID so we can inject it into the environment let agent_id = Uuid::new_v4().to_string(); @@ -692,6 +692,36 @@ impl Launcher { Ok(opts) } + /// Gate a launch on premium target entitlement and delegator governance. + fn require_launch_allowed(&self, ticket_id: &str, options: &LaunchOptions) -> Result<()> { + crate::licensing::require_target(&self.config, &options.target)?; + let governed = options + .delegator_name + .as_deref() + .and_then(|name| self.config.delegators.iter().find(|d| d.name == name)) + .is_some_and(|d| d.governance.is_some()); + if !governed { + return Ok(()); + } + let state = State::load(&self.config)?; + let running: Vec<(&str, &str)> = state + .running_agents() + .into_iter() + .filter_map(|a| { + let delegator = a.step_launch_context.as_ref()?.delegator.as_deref()?; + Some((a.ticket_id.as_str(), delegator)) + }) + .collect(); + crate::agents::delegator_resolution::enforce_governance( + &self.config, + options, + &[], + ticket_id, + &running, + )?; + Ok(()) + } + fn require_step_entitlement(&self, ticket: &Ticket, options: &LaunchOptions) -> Result<()> { let Some(step) = ticket.current_step_schema() else { return Ok(()); @@ -718,7 +748,7 @@ impl Launcher { }; for delegator in delegators { let resolved = self.sub_agent_options(options, delegator, delegator)?; - crate::licensing::require_target(&self.config, &resolved.target)?; + self.require_launch_allowed(&ticket.id, &resolved)?; } Ok(()) } @@ -1009,7 +1039,7 @@ impl Launcher { ticket: &Ticket, options: LaunchOptions, ) -> Result { - crate::licensing::require_target(&self.config, &options.target)?; + self.require_launch_allowed(&ticket.id, &options)?; // Clone ticket so we can update worktree info let mut ticket = ticket.clone(); @@ -1297,7 +1327,7 @@ impl Launcher { ticket: &Ticket, options: RelaunchOptions, ) -> Result { - crate::licensing::require_target(&self.config, &options.launch_options.target)?; + self.require_launch_allowed(&ticket.id, &options.launch_options)?; // Clone ticket so we can update worktree info if needed let mut ticket = ticket.clone(); @@ -1612,7 +1642,7 @@ impl Launcher { /// Used when a tmux session died but the ticket is still in progress. /// Can optionally resume from an existing Claude session ID. pub async fn relaunch(&self, ticket: &Ticket, options: RelaunchOptions) -> Result { - crate::licensing::require_target(&self.config, &options.launch_options.target)?; + self.require_launch_allowed(&ticket.id, &options.launch_options)?; let resolved = crate::git::identity::resolve_config( &self.config, ticket, diff --git a/src/agents/launcher/step_command.rs b/src/agents/launcher/step_command.rs index 95c0cb30..8914b094 100644 --- a/src/agents/launcher/step_command.rs +++ b/src/agents/launcher/step_command.rs @@ -330,6 +330,7 @@ mod tests { // The FEAT "code" step names agent "claude-opus"; register a // delegator by that name pointing at a different tool + model. config.delegators = vec![Delegator { + governance: None, git: None, name: "claude-opus".to_string(), llm_tool: "gemini".to_string(), diff --git a/src/agents/launcher/step_config.rs b/src/agents/launcher/step_config.rs index 6a47d96b..cf8e586f 100644 --- a/src/agents/launcher/step_config.rs +++ b/src/agents/launcher/step_config.rs @@ -157,6 +157,9 @@ mod tests { /// Helper to create a minimal test ticket fn make_test_ticket(ticket_type: &str, step: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, id: "TEST-001".to_string(), ticket_type: ticket_type.to_string(), step: step.to_string(), diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 5b22a673..804b07dd 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -307,6 +307,9 @@ fn test_generate_session_uuid_is_unique() { fn make_test_ticket(project: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: format!("20241225-1200-TASK-{project}-test.md"), filepath: format!("/tmp/tickets/queue/20241225-1200-TASK-{project}-test.md"), timestamp: "20241225-1200".to_string(), @@ -1860,6 +1863,7 @@ use crate::state::{PendingSubAgent, State}; fn add_delegators(config: &mut Config, names: &[&str]) { for name in names { config.delegators.push(Delegator { + governance: None, git: None, name: (*name).to_string(), llm_tool: "claude".to_string(), diff --git a/src/agents/launcher/worktree_setup.rs b/src/agents/launcher/worktree_setup.rs index dcbbed69..4d8cd7a5 100644 --- a/src/agents/launcher/worktree_setup.rs +++ b/src/agents/launcher/worktree_setup.rs @@ -329,6 +329,9 @@ mod tests { fn make_ticket(id: &str, ticket_type: &str) -> crate::queue::Ticket { crate::queue::Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, id: id.to_string(), ticket_type: ticket_type.to_string(), filename: "test.md".to_string(), diff --git a/src/agents/mod.rs b/src/agents/mod.rs index b7219697..147b98f7 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -5,6 +5,7 @@ pub mod activity; pub mod agent_switcher; pub mod artifact_detector; pub mod cmux; +pub mod connect; pub mod delegator_resolution; mod generator; pub mod hooks; @@ -75,18 +76,6 @@ pub use cmux::{ SystemCmuxClient, MIN_SUPPORTED_CMUX_VERSION, }; -/// Lowercase-hex encode bytes (e.g. a SHA-256 digest) without pulling in a -/// dedicated hex crate. Used for content-change detection hashes. -pub(crate) fn hex_encode(bytes: &[u8]) -> String { - use std::fmt::Write; - bytes - .iter() - .fold(String::with_capacity(bytes.len() * 2), |mut acc, byte| { - let _ = write!(acc, "{byte:02x}"); - acc - }) -} - pub use tmux_config::{generate_status_script, generate_tmux_conf}; // Zellij implementation diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index 29ce9319..8d6df466 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -532,7 +532,7 @@ impl SessionMonitor { fn hash_content(content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - crate::agents::hex_encode(&hasher.finalize()) + hex::encode(hasher.finalize()) } #[cfg(test)] diff --git a/src/agents/sync.rs b/src/agents/sync.rs index fe510bdc..c4952223 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -1011,7 +1011,7 @@ impl TicketSessionSync { if let Ok(content) = self.tmux.capture_pane(session_name, false) { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - let new_hash = crate::agents::hex_encode(&hasher.finalize()); + let new_hash = hex::encode(hasher.finalize()); // Compare with stored hash let content_changed = agent.content_hash.as_ref() != Some(&new_hash); @@ -1180,6 +1180,9 @@ mod tests { // Create a minimal ticket for testing let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "test.md".to_string(), filepath: "/tmp/test.md".to_string(), timestamp: "20241221-1430".to_string(), @@ -1218,6 +1221,9 @@ mod tests { health.awaiting_input.push("op-FEAT-123".to_string()); let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "test.md".to_string(), filepath: "/tmp/test.md".to_string(), timestamp: "20241221-1430".to_string(), @@ -1256,6 +1262,9 @@ mod tests { health.timed_out.push("op-FEAT-456".to_string()); let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "test.md".to_string(), filepath: "/tmp/test.md".to_string(), timestamp: "20241221-1430".to_string(), @@ -1296,6 +1305,9 @@ mod tests { health.awaiting_input.push("op-FEAT-789".to_string()); let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "test.md".to_string(), filepath: "/tmp/test.md".to_string(), timestamp: "20241221-1430".to_string(), @@ -1336,6 +1348,9 @@ mod tests { health.artifact_ready.push("op-FEAT-123".to_string()); let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "test.md".to_string(), filepath: "/tmp/test.md".to_string(), timestamp: "20241221-1430".to_string(), @@ -1375,6 +1390,9 @@ mod tests { // artifact_ready is empty - agent is idle but no artifacts found let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "test.md".to_string(), filepath: "/tmp/test.md".to_string(), timestamp: "20241221-1430".to_string(), @@ -1471,7 +1489,7 @@ mod tests { // Calculate hash of OLD content (different from what's in the session) let mut old_hasher = Sha256::new(); old_hasher.update(b"Old content"); - let old_hash = crate::agents::hex_encode(&old_hasher.finalize()); + let old_hash = hex::encode(old_hasher.finalize()); // Add an awaiting agent with old content hash let agent_id = state @@ -1527,7 +1545,7 @@ mod tests { let content = mock.capture_pane("op-FEAT-unchanged", false).unwrap(); let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - let same_hash = crate::agents::hex_encode(&hasher.finalize()); + let same_hash = hex::encode(hasher.finalize()); let sync = TicketSessionSync::new(&config, Arc::clone(&mock) as Arc); let mut state = State::load(&config).unwrap(); @@ -1619,6 +1637,9 @@ mod tests { let ticket_path = dir.path().join("FEAT-1.md"); std::fs::write(&ticket_path, "# FEAT-1").unwrap(); let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "FEAT-1.md".to_string(), filepath: ticket_path.to_string_lossy().to_string(), timestamp: "20241221-1430".to_string(), diff --git a/src/agents/tmux.rs b/src/agents/tmux.rs index 3d531e33..b89629a0 100644 --- a/src/agents/tmux.rs +++ b/src/agents/tmux.rs @@ -146,6 +146,9 @@ pub trait TmuxClient: Send + Sync { /// This suspends the current terminal and attaches to the tmux session fn attach_session(&self, session: &str) -> Result<(), TmuxError>; + /// Point the attached client at `target` (`session` or `session:window`) + fn switch_client(&self, target: &str) -> Result<(), TmuxError>; + /// Set a client-detached hook that runs a command when the client detaches fn set_client_detached_hook(&self, session: &str, command: &str) -> Result<(), TmuxError>; @@ -209,6 +212,16 @@ impl SystemTmuxClient { } } + /// The client the rest of Operator uses: the generated config and socket when set up. + pub fn for_config(config: &crate::config::Config) -> Self { + let config_path = config.tmux_config_path(); + if config.tmux.config_generated && config_path.exists() { + Self::with_config(config_path) + } else { + Self::new() + } + } + fn run_tmux(&self, args: &[&str]) -> Result { let mut cmd = Command::new("tmux"); @@ -459,6 +472,17 @@ impl TmuxClient for SystemTmuxClient { Ok(()) } + fn switch_client(&self, target: &str) -> Result<(), TmuxError> { + let output = self.run_tmux(&["switch-client", "-t", target])?; + if output.status.success() { + Ok(()) + } else { + Err(TmuxError::CommandFailed( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )) + } + } + fn attach_session(&self, session: &str) -> Result<(), TmuxError> { // Use status() instead of output() for interactive execution. // This allows the user to interact with the tmux session directly. @@ -1015,6 +1039,21 @@ impl TmuxClient for MockTmuxClient { } } + fn switch_client(&self, target: &str) -> Result<(), TmuxError> { + self.log_command("switch_client", &[target]); + + if !*self.installed.lock().unwrap() { + return Err(TmuxError::NotInstalled); + } + + let session = target.split(':').next().unwrap_or(target); + if self.sessions.lock().unwrap().contains_key(session) { + Ok(()) + } else { + Err(TmuxError::SessionNotFound(session.to_string())) + } + } + fn attach_session(&self, session: &str) -> Result<(), TmuxError> { self.log_command("attach_session", &[session]); diff --git a/src/api/kanban_sync.rs b/src/api/kanban_sync.rs index e0b43ffe..f6a722a2 100644 --- a/src/api/kanban_sync.rs +++ b/src/api/kanban_sync.rs @@ -351,6 +351,9 @@ mod tests { // None and the call must be a silent no-op. let sync = KanbanBidirectionalSync::new(Arc::new(crate::config::Config::default())); let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: String::new(), filepath: String::new(), timestamp: String::new(), diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index 13704573..d27b7218 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -86,9 +86,8 @@ struct StatusFieldCache { /// Resolved (project, item) pair for a given external `issue_key`. /// /// Populated by `list_issues` so `update_issue_status` can resolve the -/// human-readable key (e.g. `octocat/hello#42`) back to the `GraphQL` IDs -/// it needs for the mutation. Cache miss → `update_issue_status` returns -/// a clear error asking the caller to run `list_issues` first. +/// human-readable key (e.g. `octocat/hello#42`) back to the `GraphQL` IDs it needs for the mutation. +// Cache miss `update_issue_status` returns a clear error asking the caller to run `list_issues` first. #[derive(Debug, Clone)] struct ItemLookup { project_id: String, @@ -830,6 +829,14 @@ enum RawContent { labels: Option, #[serde(default, rename = "issueType")] issue_type: Option, + #[serde(default)] + parent: Option, + #[serde(default, rename = "subIssues")] + sub_issues: Option, + #[serde(default, rename = "blockedBy")] + blocked_by: Option, + #[serde(default)] + state: Option, }, PullRequest { #[serde(default)] @@ -865,6 +872,49 @@ struct RawRepoRef { name_with_owner: String, } +#[derive(Debug, Deserialize)] +struct RawIssueRef { + number: i32, + repository: RawRepoRef, +} + +#[derive(Debug, Deserialize)] +struct RawIssueRefs { + nodes: Vec, +} + +fn issue_ref_key(r: &RawIssueRef) -> String { + format!("{}#{}", r.repository.name_with_owner, r.number) +} + +/// Relationships of an item's content: `(blocked_by, parent, subtasks)`; only issues carry them. +fn github_relationships( + content: &RawContent, +) -> (Vec, Option, Vec) { + let RawContent::Issue { + parent, + sub_issues, + blocked_by, + .. + } = content + else { + return (Vec::new(), None, Vec::new()); + }; + let keys = |refs: &Option| -> Vec { + refs.iter() + .flat_map(|r| r.nodes.iter().map(issue_ref_key)) + .collect() + }; + ( + keys(blocked_by), + parent.as_ref().map(|p| super::ExternalParent { + key: issue_ref_key(p), + kind: super::ParentKind::Issue, + }), + keys(sub_issues), + ) +} + #[derive(Debug, Deserialize)] struct RawAssignees { nodes: Vec, @@ -1095,130 +1145,17 @@ impl KanbanProvider for GithubProjectsProvider { continue; } - let assignee = assignees.first().map(|a| ExternalUser { - id: a - .database_id - .map(|n| n.to_string()) - .unwrap_or_else(|| a.login.clone()), - name: a.name.clone().unwrap_or_else(|| a.login.clone()), - email: a.email.clone(), - avatar_url: a.avatar_url.clone(), - }); - - let (issue_id, key, summary, description, url, kanban_issue_types) = match content { - RawContent::Issue { - id, - number, - title, - body, - url, - repository, - labels, - issue_type, - .. - } => { - let repo = repository - .map(|r| r.name_with_owner) - .unwrap_or_else(|| "unknown/unknown".to_string()); - let num = number.unwrap_or(0); - let key = format!("{repo}#{num}"); - let kits = if let Some(it) = issue_type { - vec![KanbanIssueTypeRef { - id: it.id, - name: it.name, - }] - } else { - labels - .map(|l| { - l.nodes - .into_iter() - .map(|n| KanbanIssueTypeRef { - id: n.id, - name: n.name, - }) - .collect() - }) - .unwrap_or_default() - }; - ( - id.unwrap_or_else(|| key.clone()), - key, - title, - body, - url.unwrap_or_default(), - kits, - ) - } - RawContent::PullRequest { - id, - number, - title, - body, - url, - repository, - labels, - .. - } => { - let repo = repository - .map(|r| r.name_with_owner) - .unwrap_or_else(|| "unknown/unknown".to_string()); - let num = number.unwrap_or(0); - let key = format!("{repo}!{num}"); - let kits = labels - .map(|l| { - l.nodes - .into_iter() - .map(|n| KanbanIssueTypeRef { - id: n.id, - name: n.name, - }) - .collect() - }) - .unwrap_or_default(); - ( - id.unwrap_or_else(|| key.clone()), - key, - title, - body, - url.unwrap_or_default(), - kits, - ) - } - RawContent::DraftIssue { - id, title, body, .. - } => { - let key = format!("draft:{item_id}"); - ( - id.unwrap_or_else(|| key.clone()), - key, - title, - body, - String::new(), - Vec::new(), - ) - } - }; + let issue = content_to_issue(content, &item_id, status_name.unwrap_or_default()); // Cache the lookup so update_issue_status can resolve this key later. lookup_writes.push(( - key.clone(), + issue.key.clone(), ItemLookup { project_id: project_key.to_string(), item_id: item_id.clone(), }, )); - - out.push(ExternalIssue { - id: issue_id, - key, - summary, - description, - kanban_issue_types, - status: status_name.unwrap_or_default(), - assignee, - url, - priority: None, // TODO: extract from a Priority single-select field if present - }); + out.push(issue); } // Persist lookups for update_issue_status. @@ -1232,6 +1169,30 @@ impl KanbanProvider for GithubProjectsProvider { Ok(out) } + async fn list_scope( + &self, + scope: &crate::config::SyncScope, + _statuses: &[String], + ) -> Result { + let crate::config::SyncScope::GithubParent(key) = scope else { + return Err(ApiError::http( + "github", + 400, + format!("github cannot sync scope {scope:?}"), + )); + }; + let (owner, name, number) = parse_issue_key(key).ok_or_else(|| { + ApiError::http("github", 400, format!("expected owner/repo#N, got {key}")) + })?; + let variables = serde_json::json!({ "owner": owner, "name": name, "number": number }); + let resp: ScopeResponse = self.graphql(PARENT_SCOPE_QUERY, Some(variables)).await?; + let parent = resp + .repository + .and_then(|r| r.issue) + .ok_or_else(|| ApiError::http("github", 404, format!("issue {key} not found")))?; + Ok(scope_contents(key, parent)) + } + async fn create_issue( &self, project_key: &str, @@ -1325,6 +1286,9 @@ impl KanbanProvider for GithubProjectsProvider { Ok(CreateIssueResponse { issue: ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: issue_id, key, summary, @@ -1406,6 +1370,9 @@ impl KanbanProvider for GithubProjectsProvider { // would be a second round-trip; the caller already has the rest from // its previous list_issues call. Ok(ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: lookup.item_id, key: issue_key.to_string(), summary: String::new(), @@ -1845,6 +1812,9 @@ impl GithubProjectsProvider { } labels(first: 20) { nodes { id name } } issueType { id name } + parent { number repository { nameWithOwner } } + subIssues(first: 50) { nodes { number repository { nameWithOwner } } } + blockedBy(first: 50) { nodes { number repository { nameWithOwner } } } } ... on PullRequest { id @@ -1938,6 +1908,216 @@ fn extract_status_and_priority( } /// Extract assignees from a content variant. Returns an empty slice for None. +/// Normalize a project item's content (issue, pull request or draft) into an `ExternalIssue`. +/// A parent issue and its open sub-issues, fetched for a scoped sync. +const PARENT_SCOPE_QUERY: &str = r" + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + title + body + url + subIssues(first: 100) { + nodes { + __typename + id + number + title + body + url + state + repository { nameWithOwner } + assignees(first: 10) { nodes { login databaseId name avatarUrl } } + labels(first: 20) { nodes { id name } } + issueType { id name } + parent { number repository { nameWithOwner } } + subIssues(first: 50) { nodes { number repository { nameWithOwner } } } + blockedBy(first: 50) { nodes { number repository { nameWithOwner } } } + } + } + } + } + } +"; + +#[derive(Debug, Deserialize)] +struct ScopeResponse { + repository: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopeRepository { + issue: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopeIssue { + title: String, + #[serde(default)] + body: Option, + url: String, + #[serde(rename = "subIssues")] + sub_issues: ScopeIssueNodes, +} + +#[derive(Debug, Deserialize)] +struct ScopeIssueNodes { + nodes: Vec, +} + +/// Split `owner/repo#N`. +fn parse_issue_key(key: &str) -> Option<(&str, &str, i64)> { + let (repo, number) = key.split_once('#')?; + let (owner, name) = repo.split_once('/')?; + Some((owner, name, number.parse().ok()?)) +} + +/// A parent issue as a scope; closed sub-issues are done work and are left out. +fn scope_contents(key: &str, parent: ScopeIssue) -> super::ScopeContents { + let issues = parent + .sub_issues + .nodes + .into_iter() + .filter_map(|content| { + let state = match &content { + RawContent::Issue { state, .. } => state.clone().unwrap_or_default(), + _ => return None, + }; + (state != "CLOSED").then(|| content_to_issue(content, key, state)) + }) + .collect(); + super::ScopeContents { + key: key.to_string(), + title: parent.title, + description: parent.body, + url: parent.url, + issues, + } +} + +fn content_to_issue(content: RawContent, item_id: &str, status: String) -> ExternalIssue { + let assignees = content_assignees(&content); + let (blocked_by, parent, subtasks) = github_relationships(&content); + let assignee = assignees.first().map(|a| ExternalUser { + id: a + .database_id + .map(|n| n.to_string()) + .unwrap_or_else(|| a.login.clone()), + name: a.name.clone().unwrap_or_else(|| a.login.clone()), + email: a.email.clone(), + avatar_url: a.avatar_url.clone(), + }); + + let (issue_id, key, summary, description, url, kanban_issue_types) = match content { + RawContent::Issue { + id, + number, + title, + body, + url, + repository, + labels, + issue_type, + .. + } => { + let repo = repository + .map(|r| r.name_with_owner) + .unwrap_or_else(|| "unknown/unknown".to_string()); + let num = number.unwrap_or(0); + let key = format!("{repo}#{num}"); + let kits = if let Some(it) = issue_type { + vec![KanbanIssueTypeRef { + id: it.id, + name: it.name, + }] + } else { + labels + .map(|l| { + l.nodes + .into_iter() + .map(|n| KanbanIssueTypeRef { + id: n.id, + name: n.name, + }) + .collect() + }) + .unwrap_or_default() + }; + ( + id.unwrap_or_else(|| key.clone()), + key, + title, + body, + url.unwrap_or_default(), + kits, + ) + } + RawContent::PullRequest { + id, + number, + title, + body, + url, + repository, + labels, + .. + } => { + let repo = repository + .map(|r| r.name_with_owner) + .unwrap_or_else(|| "unknown/unknown".to_string()); + let num = number.unwrap_or(0); + let key = format!("{repo}!{num}"); + let kits = labels + .map(|l| { + l.nodes + .into_iter() + .map(|n| KanbanIssueTypeRef { + id: n.id, + name: n.name, + }) + .collect() + }) + .unwrap_or_default(); + ( + id.unwrap_or_else(|| key.clone()), + key, + title, + body, + url.unwrap_or_default(), + kits, + ) + } + RawContent::DraftIssue { + id, title, body, .. + } => { + let key = format!("draft:{item_id}"); + ( + id.unwrap_or_else(|| key.clone()), + key, + title, + body, + String::new(), + Vec::new(), + ) + } + }; + + ExternalIssue { + blocked_by, + parent, + subtasks, + id: issue_id, + key, + summary, + description, + kanban_issue_types, + status, + assignee, + url, + priority: None, // TODO: extract from a Priority single-select field if present + } +} + fn content_assignees(content: &RawContent) -> &[RawAssignee] { let assignees = match content { RawContent::Issue { assignees, .. } => assignees.as_ref(), @@ -2010,6 +2190,60 @@ mod tests { env::remove_var("GITHUB_TOKEN"); } + fn fixture_contents() -> Vec { + let body = + include_str!("../../../../tests/fixtures/kanban/github/items_with_relationships.json"); + let resp: ListItemsResponse = serde_json::from_str(body).unwrap(); + resp.node + .items + .unwrap() + .nodes + .into_iter() + .filter_map(|n| n.content) + .collect() + } + + #[test] + fn test_github_relationships_map_blocked_by_parent_and_sub_issues() { + let (blocked_by, parent, subtasks) = github_relationships(&fixture_contents()[0]); + assert_eq!(blocked_by, vec!["octo/hello#9"]); + assert_eq!( + parent, + Some(super::super::ExternalParent { + key: "octo/hello#3".into(), + kind: super::super::ParentKind::Issue, + }) + ); + assert_eq!(subtasks, vec!["octo/hello#13", "octo/infra#2"]); + } + + #[test] + fn test_parse_issue_key() { + assert_eq!(parse_issue_key("octo/hello#3"), Some(("octo", "hello", 3))); + assert_eq!(parse_issue_key("octo/hello"), None); + assert_eq!(parse_issue_key("hello#3"), None); + assert_eq!(parse_issue_key("octo/hello#x"), None); + } + + #[test] + fn test_scope_contents_maps_open_sub_issues_with_relationships() { + let body = + include_str!("../../../../tests/fixtures/kanban/github/parent_with_sub_issues.json"); + let resp: ScopeResponse = serde_json::from_str(body).unwrap(); + let parent = resp.repository.unwrap().issue.unwrap(); + let scope = scope_contents("octo/hello#3", parent); + assert_eq!(scope.title, "Sync worker hardening"); + let keys: Vec<&str> = scope.issues.iter().map(|i| i.key.as_str()).collect(); + assert_eq!(keys, vec!["octo/hello#12", "octo/hello#13"]); + assert_eq!(scope.issues[1].blocked_by, vec!["octo/hello#12"]); + } + + #[test] + fn test_github_relationships_pull_request_has_none() { + let (blocked_by, parent, subtasks) = github_relationships(&fixture_contents()[1]); + assert!(blocked_by.is_empty() && parent.is_none() && subtasks.is_empty()); + } + #[test] fn test_deserialize_items_page_with_issue() { let json = r#"{ diff --git a/src/api/providers/kanban/jira.rs b/src/api/providers/kanban/jira.rs index ddd667a7..2d898e73 100644 --- a/src/api/providers/kanban/jira.rs +++ b/src/api/providers/kanban/jira.rs @@ -82,13 +82,6 @@ impl JiraProvider { format!("https://{}/rest/api/3", self.domain) } - /// Get Basic Auth header value (simple Base64 encoding) - fn auth_header(&self) -> String { - let credentials = format!("{}:{}", self.email, self.api_token); - let encoded = simple_base64_encode(credentials.as_bytes()); - format!("Basic {encoded}") - } - /// Make an authenticated GET request async fn get Deserialize<'de>>(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url(), path); @@ -97,7 +90,7 @@ impl JiraProvider { let response = self .client .get(&url) - .header("Authorization", self.auth_header()) + .basic_auth(&self.email, Some(&self.api_token)) .header("Accept", "application/json") .send() .await @@ -137,7 +130,7 @@ impl JiraProvider { let response = self .client .post(&url) - .header("Authorization", self.auth_header()) + .basic_auth(&self.email, Some(&self.api_token)) .header("Accept", "application/json") .header("Content-Type", "application/json") .json(body) @@ -175,7 +168,7 @@ impl JiraProvider { let response = self .client .put(&url) - .header("Authorization", self.auth_header()) + .basic_auth(&self.email, Some(&self.api_token)) .header("Accept", "application/json") .header("Content-Type", "application/json") .json(body) @@ -209,7 +202,7 @@ impl JiraProvider { let response = self .client .post(&url) - .header("Authorization", self.auth_header()) + .basic_auth(&self.email, Some(&self.api_token)) .header("Accept", "application/json") .header("Content-Type", "application/json") .json(body) @@ -243,6 +236,9 @@ impl JiraProvider { let url = format!("https://{}/browse/{}", self.domain, issue.key); Ok(ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: issue.id, key: issue.key, summary: issue.fields.summary, @@ -285,58 +281,39 @@ impl JiraProvider { } } -/// Simple Base64 encoding implementation (for Basic Auth only) -fn simple_base64_encode(data: &[u8]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - - let mut result = String::new(); - let mut chunks = data.chunks_exact(3); - - for chunk in chunks.by_ref() { - let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32); - result.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char); - result.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char); - result.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char); - result.push(ALPHABET[(n & 0x3F) as usize] as char); - } - - let remainder = chunks.remainder(); - if remainder.len() == 1 { - let n = (remainder[0] as u32) << 16; - result.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char); - result.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char); - result.push_str("=="); - } else if remainder.len() == 2 { - let n = ((remainder[0] as u32) << 16) | ((remainder[1] as u32) << 8); - result.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char); - result.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char); - result.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char); - result.push('='); - } - - result -} - -/// Simple URL encoding for JQL queries -fn simple_url_encode(s: &str) -> String { - let mut result = String::with_capacity(s.len() * 3); - for c in s.chars() { - match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), - ' ' => result.push_str("%20"), - '"' => result.push_str("%22"), - '=' => result.push_str("%3D"), - '(' => result.push_str("%28"), - ')' => result.push_str("%29"), - ',' => result.push_str("%2C"), - _ => { - for b in c.to_string().as_bytes() { - result.push_str(&format!("%{b:02X}")); - } - } - } +/// Issue fields requested from `/search/jql`, which otherwise returns only ids. +const SEARCH_FIELDS: &str = + "summary,description,issuetype,status,assignee,priority,issuelinks,parent,subtasks"; + +/// Pages fetched per scoped search before giving up (100 issues each). +const MAX_SEARCH_PAGES: usize = 50; + +/// ` AND status IN (...)`, or nothing when no statuses are given. +fn status_clause(statuses: &[String]) -> String { + if statuses.is_empty() { + return String::new(); + } + let quoted: Vec = statuses.iter().map(|s| format!("\"{s}\"")).collect(); + format!(" AND status IN ({})", quoted.join(",")) +} + +/// Every child of an epic or parent issue, any assignee. +fn scope_jql(parent_key: &str, statuses: &[String]) -> String { + format!("parent = \"{parent_key}\"{}", status_clause(statuses)) +} + +/// Search path for a JQL query, with the query string form-encoded. +fn search_path(jql: &str, page_token: Option<&str>) -> String { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query + .append_pair("jql", jql) + .append_pair("maxResults", "100") + .append_pair("fields", SEARCH_FIELDS); + if let Some(token) = page_token { + query.append_pair("nextPageToken", token); } - result + let query = query.finish(); + format!("/search/jql?{query}") } // Jira API response types @@ -422,6 +399,23 @@ pub struct JiraStatus { pub struct JiraSearchResponse { /// List of issues matching the JQL query pub issues: Vec, + /// Token for the next page; absent on the last page + #[serde(default, rename = "nextPageToken")] + pub next_page_token: Option, +} + +/// An issue's title fields, fetched for a scope container +#[derive(Debug, Deserialize)] +struct JiraIssueHeader { + key: String, + fields: JiraHeaderFields, +} + +#[derive(Debug, Deserialize)] +struct JiraHeaderFields { + summary: String, + #[serde(default)] + description: Option, } /// A Jira issue from search results @@ -452,6 +446,123 @@ pub struct JiraIssueFields { pub assignee: Option, /// Issue priority (if set) pub priority: Option, + /// Links to other issues (blocks, relates, ...) + #[serde(default)] + pub issuelinks: Vec, + /// Parent issue or epic + #[serde(default)] + pub parent: Option, + /// Sub-task issues + #[serde(default)] + pub subtasks: Vec, +} + +/// A link between two Jira issues +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct JiraIssueLink { + /// Link type, e.g. `Blocks` with inward text `is blocked by` + #[serde(rename = "type")] + pub link_type: JiraIssueLinkType, + /// Present when the other issue is on the inward side (e.g. the blocker) + #[serde(default)] + pub inward_issue: Option, + /// Present when the other issue is on the outward side (e.g. the blocked) + #[serde(default)] + pub outward_issue: Option, +} + +/// Jira issue link type +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct JiraIssueLinkType { + /// Link type name (e.g. "Blocks") + pub name: String, + /// Inward description (e.g. "is blocked by") + #[serde(default)] + pub inward: String, +} + +/// A related issue as embedded in links, parent and subtasks +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct JiraLinkedIssue { + /// Issue key (e.g. "PROJ-12") + pub key: String, + /// Partial fields; only the issue type is read + #[serde(default)] + pub fields: Option, +} + +/// Partial fields of a related issue +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct JiraLinkedFields { + /// Issue type of the related issue + #[serde(default)] + pub issuetype: Option, +} + +/// Keys of issues that block this one: `Blocks` links whose blocker is on the inward side. +fn jira_blocked_by(links: &[JiraIssueLink]) -> Vec { + links + .iter() + .filter(|l| { + l.link_type.name.eq_ignore_ascii_case("blocks") + || l.link_type + .inward + .to_ascii_lowercase() + .contains("blocked by") + }) + .filter_map(|l| l.inward_issue.as_ref().map(|i| i.key.clone())) + .collect() +} + +/// The parent as an epic when its issue type is `Epic`, otherwise a plain parent issue. +fn jira_parent(parent: Option<&JiraLinkedIssue>) -> Option { + parent.map(|p| { + let is_epic = p + .fields + .as_ref() + .and_then(|f| f.issuetype.as_ref()) + .is_some_and(|t| t.name.eq_ignore_ascii_case("epic")); + super::ExternalParent { + key: p.key.clone(), + kind: if is_epic { + super::ParentKind::Epic + } else { + super::ParentKind::Issue + }, + } + }) +} + +/// Normalize a Jira search result into an `ExternalIssue`. +fn issue_from_jira(domain: &str, issue: JiraIssue) -> ExternalIssue { + let fields = issue.fields; + ExternalIssue { + url: format!("https://{domain}/browse/{}", issue.key), + id: issue.id, + key: issue.key, + summary: fields.summary, + description: extract_description_text(&fields.description), + kanban_issue_types: vec![KanbanIssueTypeRef { + id: fields.issuetype.id.unwrap_or_default(), + name: fields.issuetype.name, + }], + status: fields.status.name, + assignee: fields.assignee.map(|u| ExternalUser { + id: u.account_id, + name: u.display_name, + email: u.email_address, + avatar_url: u.avatar_urls.and_then(|a| a.large), + }), + priority: fields.priority.map(|p| p.name), + blocked_by: jira_blocked_by(&fields.issuelinks), + parent: jira_parent(fields.parent.as_ref()), + subtasks: fields.subtasks.into_iter().map(|s| s.key).collect(), + } } /// Jira description in Atlassian Document Format (ADF) @@ -637,49 +748,57 @@ impl KanbanProvider for JiraProvider { user_id: &str, statuses: &[String], ) -> Result, ApiError> { - // Build JQL query - let status_clause = if statuses.is_empty() { - String::new() - } else { - let quoted: Vec = statuses.iter().map(|s| format!("\"{s}\"")).collect(); - format!(" AND status IN ({})", quoted.join(",")) - }; - - let jql = - format!("project = \"{project_key}\" AND assignee = \"{user_id}\"{status_clause}"); - let encoded_jql = simple_url_encode(&jql); - let path = format!("/search/jql?jql={encoded_jql}&maxResults=100"); - - let response: JiraSearchResponse = self.get(&path).await?; + let jql = format!( + "project = \"{project_key}\" AND assignee = \"{user_id}\"{}", + status_clause(statuses) + ); + let response: JiraSearchResponse = self.get(&search_path(&jql, None)).await?; Ok(response .issues .into_iter() - .map(|issue| { - let url = format!("https://{}/browse/{}", self.domain, issue.key); - ExternalIssue { - id: issue.id, - key: issue.key, - summary: issue.fields.summary, - description: extract_description_text(&issue.fields.description), - kanban_issue_types: vec![KanbanIssueTypeRef { - id: issue.fields.issuetype.id.unwrap_or_default(), - name: issue.fields.issuetype.name, - }], - status: issue.fields.status.name, - assignee: issue.fields.assignee.map(|u| ExternalUser { - id: u.account_id, - name: u.display_name, - email: u.email_address, - avatar_url: u.avatar_urls.and_then(|a| a.large), - }), - url, - priority: issue.fields.priority.map(|p| p.name), - } - }) + .map(|issue| issue_from_jira(&self.domain, issue)) .collect()) } + async fn list_scope( + &self, + scope: &crate::config::SyncScope, + statuses: &[String], + ) -> Result { + let crate::config::SyncScope::JiraEpic(key) = scope else { + return Err(ApiError::http( + "jira", + 400, + format!("jira cannot sync scope {scope:?}"), + )); + }; + let header: JiraIssueHeader = self + .get(&format!("/issue/{key}?fields=summary,description")) + .await?; + let jql = scope_jql(key, statuses); + let mut issues = Vec::new(); + let mut token: Option = None; + for _ in 0..MAX_SEARCH_PAGES { + let page: JiraSearchResponse = self.get(&search_path(&jql, token.as_deref())).await?; + issues.extend(page.issues); + token = page.next_page_token; + if token.is_none() { + break; + } + } + Ok(super::ScopeContents { + url: format!("https://{}/browse/{}", self.domain, header.key), + key: header.key, + title: header.fields.summary, + description: extract_description_text(&header.fields.description), + issues: issues + .into_iter() + .map(|issue| issue_from_jira(&self.domain, issue)) + .collect(), + }) + } + async fn create_issue( &self, project_key: &str, @@ -884,20 +1003,114 @@ impl super::onboarding::KanbanOnboarding for JiraProvider { } } +/// Issues normalized from the checked-in relationship fixture. +#[cfg(test)] +pub(crate) fn fixture_issues() -> Vec { + let body = + include_str!("../../../../tests/fixtures/kanban/jira/search_with_relationships.json"); + let response: JiraSearchResponse = serde_json::from_str(body).unwrap(); + response + .issues + .into_iter() + .map(|i| issue_from_jira("acme.atlassian.net", i)) + .collect() +} + +/// The `PROJ-1` epic and its six children from the checked-in fixture. +#[cfg(test)] +pub(crate) fn epic_fixture() -> super::ScopeContents { + let body = include_str!("../../../../tests/fixtures/kanban/jira/epic_children.json"); + let response: JiraSearchResponse = serde_json::from_str(body).unwrap(); + super::ScopeContents { + key: "PROJ-1".into(), + title: "Pricing overhaul".into(), + description: Some("Ship v2 pricing.".into()), + url: "https://acme.atlassian.net/browse/PROJ-1".into(), + issues: response + .issues + .into_iter() + .map(|i| issue_from_jira("acme.atlassian.net", i)) + .collect(), + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_base64_encode() { - assert_eq!(simple_base64_encode(b"Hello"), "SGVsbG8="); + fn test_search_path_round_trips_jql() { + let jql = r#"project = "OPS" AND status IN ("To Do","In Progress")"#; + let path = search_path(jql, Some("tok")); + let query = path.strip_prefix("/search/jql?").unwrap(); + let pairs: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); + assert_eq!( + pairs, + vec![ + ("jql".to_string(), jql.to_string()), + ("maxResults".to_string(), "100".to_string()), + ("fields".to_string(), SEARCH_FIELDS.to_string()), + ("nextPageToken".to_string(), "tok".to_string()), + ] + ); + } + + #[test] + fn test_scope_jql_drops_assignee_and_keeps_statuses() { + assert_eq!(scope_jql("PROJ-1", &[]), r#"parent = "PROJ-1""#); + assert_eq!( + scope_jql("PROJ-1", &["To Do".to_string()]), + r#"parent = "PROJ-1" AND status IN ("To Do")"# + ); + } + + #[test] + fn test_search_response_reads_next_page_token() { + let page: JiraSearchResponse = + serde_json::from_str(r#"{"issues": [], "nextPageToken": "abc"}"#).unwrap(); + assert_eq!(page.next_page_token.as_deref(), Some("abc")); + let last: JiraSearchResponse = serde_json::from_str(r#"{"issues": []}"#).unwrap(); + assert!(last.next_page_token.is_none()); + } + + #[test] + fn test_search_fields_request_relationships() { + for field in ["issuelinks", "parent", "subtasks", "summary", "status"] { + assert!(SEARCH_FIELDS.split(',').any(|f| f == field), "{field}"); + } + } + + #[test] + fn test_issue_from_jira_maps_inward_blocks_links_only() { + let issue = &fixture_issues()[0]; + assert_eq!(issue.blocked_by, vec!["PROJ-12"]); + assert_eq!(issue.url, "https://acme.atlassian.net/browse/PROJ-42"); + } + + #[test] + fn test_issue_from_jira_maps_epic_parent_and_subtasks() { + let issue = &fixture_issues()[0]; + assert_eq!( + issue.parent, + Some(super::super::ExternalParent { + key: "PROJ-1".into(), + kind: super::super::ParentKind::Epic, + }) + ); + assert_eq!(issue.subtasks, vec!["PROJ-43", "PROJ-44"]); + } + + #[test] + fn test_issue_from_jira_without_links_defaults_empty() { + let issue = &fixture_issues()[1]; + assert!(issue.blocked_by.is_empty()); + assert!(issue.subtasks.is_empty()); assert_eq!( - simple_base64_encode(b"Hello, World!"), - "SGVsbG8sIFdvcmxkIQ==" + issue.parent.as_ref().map(|p| p.kind), + Some(super::super::ParentKind::Issue) ); - assert_eq!(simple_base64_encode(b"abc"), "YWJj"); - assert_eq!(simple_base64_encode(b"ab"), "YWI="); - assert_eq!(simple_base64_encode(b"a"), "YQ=="); } #[test] diff --git a/src/api/providers/kanban/linear.rs b/src/api/providers/kanban/linear.rs index 848a5324..ef378ca8 100644 --- a/src/api/providers/kanban/linear.rs +++ b/src/api/providers/kanban/linear.rs @@ -379,6 +379,60 @@ struct IssuesResponse { #[derive(Debug, Deserialize)] struct IssuesConnection { nodes: Vec, + #[serde(default, rename = "pageInfo")] + page_info: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + has_next_page: bool, + #[serde(default)] + end_cursor: Option, +} + +/// A project and one page of its issues. +#[derive(Debug, Deserialize)] +struct ProjectScopeResponse { + project: LinearProjectHeader, + issues: IssuesConnection, +} + +#[derive(Debug, Deserialize)] +struct LinearProjectHeader { + name: String, + #[serde(default)] + description: Option, + url: String, +} + +/// Pages fetched per scoped query before giving up (100 issues each). +const MAX_SCOPE_PAGES: usize = 50; + +/// A project and one page of all its issues (any assignee), optionally filtered by state. +fn project_scope_query(with_states: bool) -> String { + let (state_var, state_filter) = if with_states { + ( + ", $stateNames: [String!]!", + "state: { name: { in: $stateNames } }", + ) + } else { + ("", "") + }; + format!( + "query($projectId: String!, $projectFilterId: ID!, $after: String{state_var}) {{ + project(id: $projectId) {{ name description url }} + issues( + filter: {{ project: {{ id: {{ eq: $projectFilterId }} }} {state_filter} }} + first: 100 + after: $after + ) {{ + pageInfo {{ hasNextPage endCursor }} + nodes {{ ...SyncedIssue }} + }} + }} + {SYNCED_ISSUE_FRAGMENT}" + ) } #[derive(Debug, Deserialize)] @@ -394,6 +448,122 @@ struct LinearIssue { url: String, #[serde(default)] labels: Option, + #[serde(default)] + inverse_relations: Option, + #[serde(default)] + parent: Option, + #[serde(default)] + children: Option, + #[serde(default)] + project: Option, +} + +#[derive(Debug, Deserialize)] +struct RelationNodes { + nodes: Vec, +} + +/// An inverse relation: `issue` relates to this one with `type`. +#[derive(Debug, Deserialize)] +struct LinearRelation { + #[serde(rename = "type")] + relation_type: String, + issue: IdentifierRef, +} + +#[derive(Debug, Deserialize)] +struct IdentifierRef { + identifier: String, +} + +#[derive(Debug, Deserialize)] +struct IdentifierNodes { + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +struct LinearProjectRef { + id: String, +} + +/// Issue fields read by sync, shared by every `issues` query. +const SYNCED_ISSUE_FRAGMENT: &str = r" + fragment SyncedIssue on Issue { + id + identifier + title + description + state { name } + assignee { id name email avatarUrl } + priority + url + labels { nodes { id name } } + inverseRelations { nodes { type issue { identifier } } } + parent { identifier } + children { nodes { identifier } } + project { id name } + } +"; + +/// Normalize a Linear issue: a project outranks a parent issue as the container. +fn issue_from_linear(issue: LinearIssue) -> ExternalIssue { + let kanban_issue_types = issue + .labels + .map(|labels| { + labels + .nodes + .into_iter() + .map(|l| KanbanIssueTypeRef { + id: l.id, + name: l.name, + }) + .collect() + }) + .unwrap_or_default(); + let blocked_by = issue + .inverse_relations + .map(|r| { + r.nodes + .into_iter() + .filter(|rel| rel.relation_type == "blocks") + .map(|rel| rel.issue.identifier) + .collect() + }) + .unwrap_or_default(); + let parent = issue + .project + .map(|p| super::ExternalParent { + key: p.id, + kind: super::ParentKind::Project, + }) + .or_else(|| { + issue.parent.map(|p| super::ExternalParent { + key: p.identifier, + kind: super::ParentKind::Issue, + }) + }); + ExternalIssue { + id: issue.id, + key: issue.identifier, + summary: issue.title, + description: issue.description, + kanban_issue_types, + status: issue.state.name, + assignee: issue.assignee.map(|u| ExternalUser { + id: u.id, + name: u.name, + email: u.email, + avatar_url: u.avatar_url, + }), + url: issue.url, + priority: priority_to_string(issue.priority), + blocked_by, + parent, + subtasks: issue + .children + .map(|c| c.nodes.into_iter().map(|n| n.identifier).collect()) + .unwrap_or_default(), + } } #[derive(Debug, Deserialize)] @@ -672,27 +842,7 @@ impl KanbanProvider for LinearProvider { first: 100 ) { nodes { - id - identifier - title - description - state { - name - } - assignee { - id - name - email - avatarUrl - } - priority - url - labels { - nodes { - id - name - } - } + ...SyncedIssue } } } @@ -709,27 +859,7 @@ impl KanbanProvider for LinearProvider { first: 100 ) { nodes { - id - identifier - title - description - state { - name - } - assignee { - id - name - email - avatarUrl - } - priority - url - labels { - nodes { - id - name - } - } + ...SyncedIssue } } } @@ -749,46 +879,62 @@ impl KanbanProvider for LinearProvider { }) }; - let response: IssuesResponse = self.graphql(query, Some(variables)).await?; + let query = format!("{query}{SYNCED_ISSUE_FRAGMENT}"); + let response: IssuesResponse = self.graphql(&query, Some(variables)).await?; Ok(response .issues .nodes .into_iter() - .map(|issue| { - let kanban_issue_types = issue - .labels - .map(|labels| { - labels - .nodes - .into_iter() - .map(|l| KanbanIssueTypeRef { - id: l.id, - name: l.name, - }) - .collect() - }) - .unwrap_or_default(); - ExternalIssue { - id: issue.id, - key: issue.identifier, - summary: issue.title, - description: issue.description, - kanban_issue_types, - status: issue.state.name, - assignee: issue.assignee.map(|u| ExternalUser { - id: u.id, - name: u.name, - email: u.email, - avatar_url: u.avatar_url, - }), - url: issue.url, - priority: priority_to_string(issue.priority), - } - }) + .map(issue_from_linear) .collect()) } + async fn list_scope( + &self, + scope: &crate::config::SyncScope, + statuses: &[String], + ) -> Result { + let crate::config::SyncScope::LinearProject(project_id) = scope else { + return Err(ApiError::http( + "linear", + 400, + format!("linear cannot sync scope {scope:?}"), + )); + }; + let query = project_scope_query(!statuses.is_empty()); + let mut header = None; + let mut nodes = Vec::new(); + let mut after: Option = None; + for _ in 0..MAX_SCOPE_PAGES { + let variables = serde_json::json!({ + "projectId": project_id, + "projectFilterId": project_id, + "after": after, + "stateNames": statuses, + }); + let page: ProjectScopeResponse = self.graphql(&query, Some(variables)).await?; + header.get_or_insert(page.project); + nodes.extend(page.issues.nodes); + after = page + .issues + .page_info + .filter(|p| p.has_next_page) + .and_then(|p| p.end_cursor); + if after.is_none() { + break; + } + } + let header = header.ok_or_else(|| ApiError::http("linear", 404, "project not found"))?; + Ok(super::ScopeContents { + key: project_id.clone(), + title: header.name, + description: header.description, + url: header.url, + issues: nodes.into_iter().map(issue_from_linear).collect(), + }) + } + async fn create_issue( &self, project_key: &str, @@ -859,6 +1005,9 @@ impl KanbanProvider for LinearProvider { Ok(super::CreateIssueResponse { issue: ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: issue.id, key: issue.identifier, summary: issue.title, @@ -955,6 +1104,9 @@ impl KanbanProvider for LinearProvider { })?; Ok(ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: issue.id, key: issue.identifier, summary: issue.title, @@ -1217,6 +1369,20 @@ impl super::onboarding::KanbanOnboarding for LinearProvider { } } +/// Issues normalized from the checked-in relationship fixture. +#[cfg(test)] +pub(crate) fn fixture_issues() -> Vec { + let body = + include_str!("../../../../tests/fixtures/kanban/linear/issues_with_relationships.json"); + let response: IssuesResponse = serde_json::from_str(body).unwrap(); + response + .issues + .nodes + .into_iter() + .map(issue_from_linear) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -1339,6 +1505,65 @@ mod tests { assert_eq!(states[3].name, "Done"); } + #[test] + fn test_issue_from_linear_maps_only_blocks_inverse_relations() { + assert_eq!(fixture_issues()[0].blocked_by, vec!["ENG-3"]); + } + + #[test] + fn test_issue_from_linear_project_outranks_parent_issue() { + let issues = fixture_issues(); + assert_eq!( + issues[0].parent, + Some(super::super::ExternalParent { + key: "proj-billing".into(), + kind: super::super::ParentKind::Project, + }) + ); + assert_eq!(issues[0].subtasks, vec!["ENG-8", "ENG-9"]); + assert_eq!( + issues[1].parent, + Some(super::super::ExternalParent { + key: "ENG-7".into(), + kind: super::super::ParentKind::Issue, + }) + ); + assert!(issues[1].blocked_by.is_empty()); + } + + #[test] + fn test_project_scope_query_filters_by_project_not_assignee() { + let query = project_scope_query(false); + assert!(query.contains("project: { id: { eq: $projectFilterId } }")); + assert!( + !query.contains("assignee: {"), + "scoped sync must not filter by assignee" + ); + assert!(!query.contains("$stateNames")); + assert!(query.contains("fragment SyncedIssue")); + assert!(project_scope_query(true).contains("state: { name: { in: $stateNames } }")); + } + + #[test] + fn test_project_scope_response_reads_header_and_page_info() { + let page: ProjectScopeResponse = serde_json::from_str( + r#"{"project": {"name": "Billing v2", "description": null, "url": "https://linear.app/acme/project/billing"}, + "issues": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}, "nodes": []}}"#, + ) + .unwrap(); + assert_eq!(page.project.name, "Billing v2"); + let info = page.issues.page_info.unwrap(); + assert!(info.has_next_page); + assert_eq!(info.end_cursor.as_deref(), Some("c1")); + } + + #[test] + fn test_synced_issue_fragment_requests_relationships() { + for field in ["inverseRelations", "parent", "children", "project"] { + assert!(SYNCED_ISSUE_FRAGMENT.contains(field), "{field}"); + } + } + #[test] fn test_deserialize_issues_response() { let json = r#"{ diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index 772c7617..5aaec4a5 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -20,10 +20,17 @@ pub use openspec::{ }; // Re-export Jira API response types for schema/binding generation +#[cfg(test)] +pub(crate) use jira::epic_fixture as jira_epic_fixture; +#[cfg(test)] +pub(crate) use jira::fixture_issues as jira_fixture_issues; pub use jira::{ - JiraAvatarUrls, JiraDescription, JiraIssue, JiraIssueFields, JiraIssueTypeRef, JiraPriority, - JiraProjectStatus, JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, + JiraAvatarUrls, JiraDescription, JiraIssue, JiraIssueFields, JiraIssueLink, JiraIssueLinkType, + JiraIssueTypeRef, JiraLinkedFields, JiraLinkedIssue, JiraPriority, JiraProjectStatus, + JiraSearchResponse, JiraStatus, JiraStatusRef, JiraUser, }; +#[cfg(test)] +pub(crate) use linear::fixture_issues as linear_fixture_issues; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -76,6 +83,43 @@ pub struct ExternalIssue { pub url: String, /// Priority (if available) pub priority: Option, + /// Keys of issues that block this one + #[serde(default)] + pub blocked_by: Vec, + /// Epic, project or parent issue this issue belongs to + #[serde(default)] + pub parent: Option, + /// Keys of this issue's sub-tasks / sub-issues + #[serde(default)] + pub subtasks: Vec, +} + +/// An upstream container (epic, project, parent issue, change) and its member issues. +#[derive(Debug, Clone)] +pub struct ScopeContents { + /// Container key, e.g. `PROJ-1` + pub key: String, + pub title: String, + pub description: Option, + pub url: String, + pub issues: Vec, +} + +/// What kind of container an issue's parent is. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ParentKind { + Epic, + Project, + Issue, +} + +/// An issue's upstream container (Jira epic, Linear project, GitHub parent issue). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExternalParent { + /// Provider key of the parent (e.g. `PROJ-1`, a Linear project id, `owner/repo#3`) + pub key: String, + pub kind: ParentKind, } /// External issue type from a kanban provider @@ -191,6 +235,19 @@ pub trait KanbanProvider: Send + Sync { statuses: &[String], ) -> Result, ApiError>; + /// Fetch an upstream container and all its member issues, regardless of assignee. + async fn list_scope( + &self, + scope: &crate::config::SyncScope, + _statuses: &[String], + ) -> Result { + Err(ApiError::http( + self.name(), + 400, + format!("{} cannot sync scope {scope:?}", self.name()), + )) + } + // ─── CRUD Operations ───────────────────────────────────────────────────── /// Create a new issue in the specified project @@ -867,6 +924,9 @@ mod tests { #[test] fn test_external_issue() { let issue = ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: "issue-789".to_string(), key: "PROJ-123".to_string(), summary: "Fix login bug".to_string(), @@ -894,6 +954,9 @@ mod tests { #[test] fn test_external_issue_unassigned() { let issue = ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: "issue-999".to_string(), key: "ENG-456".to_string(), summary: "Add dark mode".to_string(), diff --git a/src/api/providers/kanban/openspec.rs b/src/api/providers/kanban/openspec.rs index 2372d831..c9945d54 100644 --- a/src/api/providers/kanban/openspec.rs +++ b/src/api/providers/kanban/openspec.rs @@ -72,13 +72,11 @@ fn parse_checklist_item(line: &str) -> Option { .or_else(|| trimmed.strip_prefix("* "))?; let (checked, text) = if let Some(t) = rest.strip_prefix("[ ]") { (false, t) - } else if let Some(t) = rest - .strip_prefix("[x]") - .or_else(|| rest.strip_prefix("[X]")) - { - (true, t) } else { - return None; + let t = rest + .strip_prefix("[x]") + .or_else(|| rest.strip_prefix("[X]"))?; + (true, t) }; Some(TaskItem { checked, @@ -266,6 +264,7 @@ impl OpenspecProvider { change_dir: &Path, proposal: &ProposalMeta, group: &TaskGroup, + blocker: Option<&TaskGroup>, ) -> ExternalIssue { let key = format!("{change_id}#{}", group.number); let status = if group.is_done() { @@ -294,6 +293,14 @@ impl OpenspecProvider { )); ExternalIssue { + blocked_by: blocker + .map(|b| vec![format!("{change_id}#{}", b.number)]) + .unwrap_or_default(), + parent: Some(super::ExternalParent { + key: change_id.to_string(), + kind: super::ParentKind::Project, + }), + subtasks: Vec::new(), id: key.clone(), key, summary: group.title.clone(), @@ -307,6 +314,11 @@ impl OpenspecProvider { } } +/// Groups run in order: each waits on the nearest earlier group that is still open. +fn previous_open_group(earlier: &[TaskGroup]) -> Option<&TaskGroup> { + earlier.iter().rev().find(|g| !g.is_done()) +} + #[async_trait] impl KanbanProvider for OpenspecProvider { fn name(&self) -> &str { @@ -369,14 +381,42 @@ impl KanbanProvider for OpenspecProvider { openspec_error(404, format!("cannot read {}: {e}", tasks_path.display())) })?; let proposal = self.proposal_meta(&change_dir); - let issues = parse_tasks_md(&content) + let groups = parse_tasks_md(&content); + let issues = groups .iter() - .map(|group| self.issue_for_group(project_key, &change_dir, &proposal, group)) + .enumerate() + .map(|(i, group)| { + let blocker = previous_open_group(&groups[..i]); + self.issue_for_group(project_key, &change_dir, &proposal, group, blocker) + }) .filter(|issue| statuses.is_empty() || statuses.contains(&issue.status)) .collect(); Ok(issues) } + async fn list_scope( + &self, + scope: &crate::config::SyncScope, + statuses: &[String], + ) -> Result { + let crate::config::SyncScope::OpenspecChange(change_id) = scope else { + return Err(openspec_error( + 400, + format!("openspec cannot sync scope {scope:?}"), + )); + }; + let issues = self.list_issues(change_id, "", statuses).await?; + let change_dir = self.change_dir(change_id)?; + let proposal = self.proposal_meta(&change_dir); + Ok(super::ScopeContents { + key: change_id.clone(), + title: proposal.title.unwrap_or_else(|| change_id.clone()), + description: proposal.why_excerpt, + url: format!("file://{}", change_dir.display()), + issues, + }) + } + async fn create_issue( &self, _project_key: &str, @@ -542,6 +582,48 @@ Some prose without checkboxes. assert!(issues[0].url.starts_with("file://")); } + #[tokio::test] + async fn test_openspec_list_scope_titles_from_proposal() { + let root = fixture_root(); + let provider = OpenspecProvider::new("demo", root.path()); + let scope = provider + .list_scope( + &crate::config::SyncScope::OpenspecChange("add-dark-mode".into()), + &[], + ) + .await + .unwrap(); + assert_eq!(scope.key, "add-dark-mode"); + assert_eq!(scope.title, "Proposal: Add dark mode"); + assert_eq!(scope.issues.len(), 2); + } + + #[tokio::test] + async fn test_openspec_groups_chain_on_nearest_open_group() { + let root = fixture_root(); + let change = root.path().join("changes/add-dark-mode"); + std::fs::write( + change.join("tasks.md"), + "## 1. A\n- [ ] open\n## 2. B\n- [x] done\n## 3. C\n- [ ] open\n", + ) + .unwrap(); + let provider = OpenspecProvider::new("demo", root.path()); + let issues = provider + .list_issues("add-dark-mode", "", &[]) + .await + .unwrap(); + assert!(issues[0].blocked_by.is_empty()); + assert_eq!(issues[1].blocked_by, vec!["add-dark-mode#1"]); + assert_eq!(issues[2].blocked_by, vec!["add-dark-mode#1"]); + assert_eq!( + issues[2].parent, + Some(super::super::ExternalParent { + key: "add-dark-mode".into(), + kind: super::super::ParentKind::Project, + }) + ); + } + #[tokio::test] async fn test_openspec_list_issues_status_filter() { let root = fixture_root(); diff --git a/src/app/pr_workflow.rs b/src/app/pr_workflow.rs index def7c124..21e4e80a 100644 --- a/src/app/pr_workflow.rs +++ b/src/app/pr_workflow.rs @@ -60,8 +60,7 @@ impl App { project: ticket.project.clone(), ticket_id: ticket_id.clone(), pr_number, - }) - .await; + }); } } @@ -97,8 +96,7 @@ impl App { project: String::new(), // Project unknown in this context ticket_id: ticket_id.clone(), pr_number, - }) - .await; + }); } // Untrack the PR @@ -121,8 +119,7 @@ impl App { project: String::new(), // Project unknown in this context ticket_id: ticket_id.clone(), pr_number, - }) - .await; + }); } PrStatusEvent::Approved { ticket_id, @@ -155,8 +152,7 @@ impl App { project: String::new(), // Project unknown in this context ticket_id: ticket_id.clone(), pr_number, - }) - .await; + }); } PrStatusEvent::ReadyForReview { ticket_id, @@ -300,8 +296,7 @@ impl App { ticket_id: ticket.id.clone(), pr_url: pr.url.clone(), pr_number: pr.number, - }) - .await; + }); } Err(e) => { tracing::error!( @@ -329,8 +324,7 @@ impl App { project: ticket.project.clone(), ticket_id: ticket.id.clone(), error: format!("Failed to create PR: {e}"), - }) - .await; + }); } } } diff --git a/src/app/tests.rs b/src/app/tests.rs index 4d60ab02..ff37c9c4 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -759,6 +759,9 @@ Test content // Create ticket struct for return_to_queue let ticket = Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: ticket_filename.to_string(), filepath: in_progress_path.to_string_lossy().to_string(), timestamp: "20241225-1200".to_string(), @@ -1779,6 +1782,9 @@ mod key_dispatch { fn sample_ticket(project: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: format!("20240101-0000-TASK-{project}-sample.md"), filepath: format!("/tmp/missing-{project}.md"), timestamp: "20240101-0000".to_string(), diff --git a/src/auth/egress.rs b/src/auth/egress.rs index c50b525e..8baad907 100644 --- a/src/auth/egress.rs +++ b/src/auth/egress.rs @@ -90,8 +90,7 @@ fn is_always_forbidden(ip: IpAddr) -> bool { IpAddr::V6(v6) => { v6.is_multicast() || v6.is_unspecified() - // fe80::/10 link-local - || (v6.segments()[0] & 0xffc0) == 0xfe80 + || v6.is_unicast_link_local() // IPv4-mapped addresses re-enter the v4 rules; without this a forbidden v4 address could be smuggled in as ::ffff:169.254.169.254 || v6.to_ipv4_mapped().is_some_and(|v4| is_always_forbidden(IpAddr::V4(v4))) } @@ -102,8 +101,7 @@ fn is_always_forbidden(ip: IpAddr) -> bool { fn is_private(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => v4.is_private(), - // fc00::/7 unique local - IpAddr::V6(v6) => (v6.segments()[0] & 0xfe00) == 0xfc00, + IpAddr::V6(v6) => v6.is_unique_local(), } } @@ -211,6 +209,7 @@ mod tests { "http://224.0.0.1/", "http://[ff02::1]/", "http://[fe80::1]/", + "http://[febf::1]/", "http://0.0.0.0/", ] { assert!( @@ -251,6 +250,8 @@ mod tests { "http://10.1.2.3/", "http://192.168.1.5/", "http://172.16.0.9/", + "http://[fc00::1]/", + "http://[fd12:3456::1]/", ] { assert!(validate(url, &permissive()).is_ok(), "{url} under default"); assert!( diff --git a/src/auth/scope.rs b/src/auth/scope.rs index 8730c551..39dc3b3a 100644 --- a/src/auth/scope.rs +++ b/src/auth/scope.rs @@ -78,6 +78,14 @@ pub static ROUTE_RULES: &[RouteRule] = &[ admin("PUT", "/api/v1/targets/{name}"), admin("DELETE", "/api/v1/targets/{name}"), admin("POST", "/api/v1/targets/{name}/probe"), + // --- Campaigns ---------------------------------------------------------- + read("GET", "/api/v1/campaigns"), + write("POST", "/api/v1/campaigns"), + read("GET", "/api/v1/campaigns/{id}"), + write("PUT", "/api/v1/tickets/{id}/campaign"), + execute("POST", "/api/v1/campaigns/{id}/launch"), + execute("POST", "/api/v1/campaigns/{id}/pause"), + read("GET", "/api/v1/campaigns/{id}/status"), read("GET", "/api/v1/profiles"), admin("POST", "/api/v1/profiles"), read("GET", "/api/v1/profiles/{profile_id}"), @@ -142,6 +150,7 @@ pub static ROUTE_RULES: &[RouteRule] = &[ // --- Agents ------------------------------------------------------------- read("GET", "/api/v1/agents/active"), read("GET", "/api/v1/agents/{agent_id}"), + read("GET", "/api/v1/agents/{agent_id}/connect/terminal"), write("POST", "/api/v1/agents/{agent_id}/approve"), write("POST", "/api/v1/agents/{agent_id}/reject"), // Focus shells out to the session multiplexer binary. @@ -152,6 +161,8 @@ pub static ROUTE_RULES: &[RouteRule] = &[ // --- Tickets ------------------------------------------------------------ read("GET", "/api/v1/tickets/{id}"), write("POST", "/api/v1/tickets"), + read("POST", "/api/v1/tickets/batch/preview"), + write("POST", "/api/v1/tickets/batch"), write("PUT", "/api/v1/tickets/{id}/status"), write("POST", "/api/v1/alerts"), // --- Launch ------------------------------------------------------------- diff --git a/src/bin/generate_types.rs b/src/bin/generate_types.rs index a7f19e2d..1d0c26e2 100644 --- a/src/bin/generate_types.rs +++ b/src/bin/generate_types.rs @@ -51,7 +51,8 @@ use operator::rest::dto::{ WorkflowExportResponse, WorkflowFormatDto, WorkflowHintsDto, WorkflowPreviewResponse, }; use operator::state::{ - AgentState, CompletedTicket, MultiAgentGroup, MultiAgentPhase, PendingSubAgent, State, + AgentState, CampaignRun, CompletedTicket, MultiAgentGroup, MultiAgentPhase, PendingSubAgent, + State, }; use operator::types::{ AttemptStatus, ExecutionProcess, ProcessStatus, Project, ProjectRepo, RunReason, Session, @@ -166,6 +167,7 @@ fn generate_typescript() -> String { // State types (src/state.rs) State::decl(&cfg), AgentState::decl(&cfg), + CampaignRun::decl(&cfg), CompletedTicket::decl(&cfg), MultiAgentGroup::decl(&cfg), MultiAgentPhase::decl(&cfg), @@ -198,6 +200,30 @@ fn generate_typescript() -> String { WorkflowFormatDto::decl(&cfg), // Ticket creation + alert DTOs CreateTicketRequest::decl(&cfg), + // Campaigns and ticket batches (src/campaigns/, src/rest/dto/campaigns.rs) + operator::config::GovernancePolicy::decl(&cfg), + operator::campaigns::model::Campaign::decl(&cfg), + operator::campaigns::model::CampaignStatus::decl(&cfg), + operator::campaigns::model::CampaignWorkspace::decl(&cfg), + operator::campaigns::runner::BlockReason::decl(&cfg), + operator::services::campaign_runner::TickReport::decl(&cfg), + operator::campaigns::batch::BatchItem::decl(&cfg), + operator::campaigns::batch::BatchRequest::decl(&cfg), + operator::campaigns::batch::PlannedTicket::decl(&cfg), + operator::campaigns::batch::BatchPreview::decl(&cfg), + operator::campaigns::batch::CreatedTicket::decl(&cfg), + operator::campaigns::batch::BatchCreated::decl(&cfg), + operator::campaigns::service::NewCampaignInput::decl(&cfg), + operator::campaigns::service::CampaignMember::decl(&cfg), + operator::campaigns::service::CampaignDetail::decl(&cfg), + operator::campaigns::service::CampaignCounters::decl(&cfg), + operator::campaigns::service::CampaignStatusView::decl(&cfg), + operator::campaigns::service::CampaignLaunchOutcome::decl(&cfg), + operator::rest::dto::AssignCampaignRequest::decl(&cfg), + operator::rest::dto::AssignCampaignResponse::decl(&cfg), + operator::rest::dto::LaunchCampaignRequest::decl(&cfg), + operator::rest::dto::CampaignSummary::decl(&cfg), + operator::rest::dto::CampaignListResponse::decl(&cfg), CreateTicketResponse::decl(&cfg), CreateAlertRequest::decl(&cfg), CreateAlertResponse::decl(&cfg), diff --git a/src/campaigns/batch.rs b/src/campaigns/batch.rs new file mode 100644 index 00000000..93b7de82 --- /dev/null +++ b/src/campaigns/batch.rs @@ -0,0 +1,514 @@ +//! Plan and create a batch of related tickets: ids allocated up front, local refs wired as dependencies. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use super::plan::{waves, PlanError}; +use super::CampaignStore; +use crate::config::Config; +use crate::queue::deps::{provider_ref, resolve, Resolved}; +use crate::queue::{ids, Queue, Ticket, TicketCreator, TicketPriority}; +use crate::templates::TemplateType; + +/// One ticket to create; `ref` names it within the batch. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct BatchItem { + /// Batch-local name other items use in `depends_on`. + #[serde(rename = "ref")] + pub reference: String, + /// Template key (FEAT, FIX, TASK, SPIKE, INV, ...). + pub template: String, + #[serde(default)] + pub project: Option, + #[serde(default)] + pub summary: Option, + /// Extra Handlebars values; `project`/`summary` above take precedence. + #[serde(default)] + pub values: HashMap, + /// Batch refs, existing ticket ids, or `provider:key` refs. + #[serde(default)] + pub depends_on: Vec, +} + +/// A batch of tickets, optionally created inside a campaign. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct BatchRequest { + #[serde(default)] + pub campaign: Option, + pub tickets: Vec, +} + +/// One item as it would be written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct PlannedTicket { + #[serde(rename = "ref")] + pub reference: String, + /// Allocated id; empty when the template is unknown. + pub id: String, + pub template: String, + /// Filename without the date prefix: `TYPE-project-slug`. + pub filename_stem: String, + /// Dependencies with batch refs resolved to allocated ids. + pub depends_on: Vec, + pub errors: Vec, +} + +/// What a batch would create; nothing is written. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS, +)] +#[ts(export)] +pub struct BatchPreview { + pub tickets: Vec, + /// Allocated ids in dependency waves. + pub waves: Vec>, + /// Batch-wide errors (cycles, unknown campaign). + pub errors: Vec, + pub valid: bool, +} + +/// A ticket written by a batch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CreatedTicket { + #[serde(rename = "ref")] + pub reference: String, + pub id: String, + pub filename: String, +} + +/// Result of creating a batch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct BatchCreated { + pub tickets: Vec, + pub waves: Vec>, +} + +#[derive(Debug, thiserror::Error)] +pub enum BatchError { + #[error("batch is invalid")] + Invalid(BatchPreview), + #[error("batch write failed and was rolled back: {0}")] + Write(String), +} + +/// Plan a batch against the tickets that exist; pure. +pub fn preview( + config: &Config, + existing: &[Ticket], + request: &BatchRequest, + campaign_exists: bool, +) -> BatchPreview { + let items = &request.tickets; + let mut taken: Vec = existing.iter().map(|t| t.id.clone()).collect(); + let mut seen: BTreeSet<&str> = BTreeSet::new(); + let mut tickets: Vec = items + .iter() + .map(|item| { + let mut errors = Vec::new(); + if item.reference.trim().is_empty() { + errors.push("ref is required".to_string()); + } else if !seen.insert(item.reference.as_str()) { + errors.push(format!("duplicate ref '{}'", item.reference)); + } + let (id, stem) = match TemplateType::from_key(&item.template) { + Some(kind) => { + let id = ids::next_ticket_id(kind.as_str(), taken.iter().map(String::as_str)); + taken.push(id.clone()); + let project = item + .project + .as_deref() + .filter(|p| !p.is_empty()) + .unwrap_or("global"); + let slug = item + .summary + .as_deref() + .map(|s| ids::slugify(s, 50)) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| ids::slugify(&id, 50)); + let stem = format!("{}-{project}-{slug}", kind.as_str()); + (id, stem) + } + None => { + errors.push(format!("unknown template '{}'", item.template)); + (String::new(), String::new()) + } + }; + PlannedTicket { + reference: item.reference.clone(), + id, + template: item.template.clone(), + filename_stem: stem, + depends_on: Vec::new(), + errors, + } + }) + .collect(); + + let by_ref: BTreeMap = tickets + .iter() + .filter(|t| !t.id.is_empty()) + .map(|t| (t.reference.clone(), t.id.clone())) + .collect(); + let batch_ids: BTreeSet<&str> = by_ref.values().map(String::as_str).collect(); + let resolved: Vec<(Vec, Vec)> = items + .iter() + .map(|item| { + let mut deps = Vec::new(); + let mut errors = Vec::new(); + for dep in &item.depends_on { + if let Some(id) = by_ref.get(dep) { + deps.push(id.clone()); + } else if batch_ids.contains(dep.as_str()) + || provider_ref(dep).is_some() + || matches!(resolve(dep, existing), Resolved::Local(_)) + { + deps.push(dep.clone()); + } else { + errors.push(format!("unknown dependency '{dep}'")); + } + } + (deps, errors) + }) + .collect(); + for (ticket, (deps, errors)) in tickets.iter_mut().zip(resolved) { + ticket.depends_on = deps; + ticket.errors.extend(errors); + } + + let mut errors = Vec::new(); + if let Some(campaign) = request.campaign.as_deref().filter(|_| !campaign_exists) { + errors.push(format!("campaign {campaign} not found")); + } + let blockers: BTreeMap> = tickets + .iter() + .filter(|t| !t.id.is_empty()) + .map(|t| { + let local = t + .depends_on + .iter() + .filter(|d| batch_ids.contains(d.as_str())) + .cloned() + .collect(); + (t.id.clone(), local) + }) + .collect(); + let rank: BTreeMap<&str, (usize, TicketPriority, usize)> = tickets + .iter() + .zip(items) + .enumerate() + .map(|(n, (t, item))| { + let priority = item + .values + .get("priority") + .map_or(TicketPriority::P2Medium, |p| { + TicketPriority::from_frontmatter(p) + }); + let kind = TemplateType::from_key(&item.template).map_or("", |k| k.as_str()); + (t.id.as_str(), (config.priority_index(kind), priority, n)) + }) + .collect(); + let waves = match waves(blockers, |id| rank[id]) { + Ok(waves) => waves, + Err(PlanError::Cycle(ids)) => { + errors.push(format!("dependency cycle among {}", ids.join(", "))); + Vec::new() + } + }; + let valid = errors.is_empty() && tickets.iter().all(|t| t.errors.is_empty()); + BatchPreview { + tickets, + waves, + errors, + valid, + } +} + +/// Run `write` for each index; on the first failure remove every file already written. +fn write_all( + count: usize, + mut write: impl FnMut(usize) -> anyhow::Result, +) -> Result, BatchError> { + let mut written = Vec::new(); + for i in 0..count { + match write(i) { + Ok(path) => written.push(path), + Err(e) => { + for path in &written { + let _ = std::fs::remove_file(path); + } + return Err(BatchError::Write(e.to_string())); + } + } + } + Ok(written) +} + +fn all_tickets(config: &Config) -> anyhow::Result> { + let queue = Queue::new(config)?; + let mut all = queue.list_queue()?; + all.extend(queue.list_in_progress()?); + all.extend(queue.list_completed()?); + Ok(all) +} + +/// Write every planned ticket or none. +pub fn create(config: &Config, request: &BatchRequest) -> Result { + let _guard = TicketCreator::creation_lock(); + let existing = all_tickets(config).map_err(|e| BatchError::Write(e.to_string()))?; + let campaign_exists = request + .campaign + .as_deref() + .is_some_and(|id| CampaignStore::new(config).get(id).is_some()); + let planned = preview(config, &existing, request, campaign_exists); + if !planned.valid { + return Err(BatchError::Invalid(planned)); + } + let creator = TicketCreator::new(config); + let paths = write_all(planned.tickets.len(), |i| { + let item = &request.tickets[i]; + let plan = &planned.tickets[i]; + let kind = TemplateType::from_key(&item.template) + .ok_or_else(|| anyhow::anyhow!("unknown template '{}'", item.template))?; + let project = item.project.clone().unwrap_or_default(); + let mut values = creator.generate_default_values(kind, &project); + values.extend(item.values.clone()); + if let Some(summary) = &item.summary { + values.insert("summary".into(), summary.clone()); + } + values.insert("id".into(), plan.id.clone()); + values.insert( + "branch".into(), + format!("{}/{}", kind.as_str().to_lowercase(), plan.id), + ); + let path = creator.create_ticket_headless(kind, &values)?; + let mut ticket = Ticket::from_file(&path)?; + ticket.set_campaign(request.campaign.as_deref())?; + ticket.set_depends_on(&plan.depends_on)?; + Ok(path) + })?; + let tickets = planned + .tickets + .iter() + .zip(paths) + .map(|(plan, path)| CreatedTicket { + reference: plan.reference.clone(), + id: plan.id.clone(), + filename: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + }) + .collect(); + Ok(BatchCreated { + tickets, + waves: planned.waves, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn item(r: &str, template: &str, deps: &[&str]) -> BatchItem { + BatchItem { + reference: r.into(), + template: template.into(), + project: Some("api".into()), + summary: Some(format!("Do {r}")), + depends_on: deps.iter().map(ToString::to_string).collect(), + ..BatchItem::default() + } + } + + fn request(items: Vec) -> BatchRequest { + BatchRequest { + campaign: None, + tickets: items, + } + } + + struct Workspace { + dir: tempfile::TempDir, + config: Config, + } + + impl Workspace { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.tickets = dir.path().join("tickets").to_string_lossy().into_owned(); + config.paths.state = dir.path().join("state").to_string_lossy().into_owned(); + for sub in ["queue", "in-progress", "completed"] { + fs::create_dir_all(dir.path().join("tickets").join(sub)).unwrap(); + } + Self { dir, config } + } + + fn seed(&self, folder: &str, name: &str, id: &str) { + fs::write( + self.dir.path().join("tickets").join(folder).join(name), + format!("---\nid: {id}\nstatus: queued\n---\n\n# {id}\n"), + ) + .unwrap(); + } + + fn tickets(&self) -> Vec { + let queue = Queue::new(&self.config).unwrap(); + let mut all = queue.list_queue().unwrap(); + all.extend(queue.list_in_progress().unwrap()); + all.extend(queue.list_completed().unwrap()); + all + } + + fn queued(&self) -> usize { + fs::read_dir(self.dir.path().join("tickets/queue")) + .unwrap() + .count() + } + } + + fn diamond() -> Vec { + vec![ + item("root", "FEAT", &[]), + item("left", "FEAT", &["root"]), + item("right", "FIX", &["root"]), + item("join", "TASK", &["left", "right"]), + ] + } + + #[test] + fn test_preview_allocates_ids_past_existing_per_type() { + let ws = Workspace::new(); + ws.seed("completed", "20260101-1200-FEAT-api-old.md", "FEAT-0007"); + let p = preview(&ws.config, &ws.tickets(), &request(diamond()), false); + let ids: Vec<&str> = p.tickets.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["FEAT-0008", "FEAT-0009", "FIX-0001", "TASK-0001"]); + assert!(p.valid, "{p:?}"); + assert_eq!(ws.queued(), 0, "preview writes nothing"); + } + + #[test] + fn test_preview_resolves_refs_and_orders_waves() { + let ws = Workspace::new(); + let p = preview(&ws.config, &ws.tickets(), &request(diamond()), false); + assert_eq!(p.tickets[3].depends_on, vec!["FEAT-0002", "FIX-0001"]); + assert_eq!( + p.waves, + vec![ + vec!["FEAT-0001".to_string()], + vec!["FIX-0001".to_string(), "FEAT-0002".to_string()], + vec!["TASK-0001".to_string()], + ] + ); + assert_eq!(p.tickets[0].filename_stem, "FEAT-api-do-root"); + } + + #[test] + fn test_preview_passes_existing_ids_and_provider_refs() { + let ws = Workspace::new(); + ws.seed("queue", "20260101-1200-TASK-api-gate.md", "TASK-0004"); + let items = vec![item("a", "FEAT", &["TASK-0004", "jira:PROJ-9"])]; + let p = preview(&ws.config, &ws.tickets(), &request(items), false); + assert!(p.valid, "{p:?}"); + assert_eq!(p.tickets[0].depends_on, vec!["TASK-0004", "jira:PROJ-9"]); + assert_eq!(p.waves, vec![vec!["FEAT-0001".to_string()]]); + } + + #[test] + fn test_preview_reports_item_errors() { + let ws = Workspace::new(); + let items = vec![ + item("a", "NOPE", &[]), + item("b", "FEAT", &["FEAT-0999"]), + item("b", "FIX", &[]), + item("", "TASK", &[]), + ]; + let p = preview(&ws.config, &ws.tickets(), &request(items), false); + assert!(!p.valid); + assert!(p.tickets[0].errors[0].contains("unknown template")); + assert!(p.tickets[0].id.is_empty()); + assert!(p.tickets[1].errors[0].contains("unknown dependency 'FEAT-0999'")); + assert!(p.tickets[2].errors[0].contains("duplicate ref 'b'")); + assert!(p.tickets[3].errors[0].contains("ref is required")); + } + + #[test] + fn test_preview_reports_cycle_and_unknown_campaign() { + let ws = Workspace::new(); + let items = vec![item("a", "FEAT", &["b"]), item("b", "FEAT", &["a"])]; + let mut req = request(items); + req.campaign = Some("CAMP-0042".into()); + let p = preview(&ws.config, &ws.tickets(), &req, false); + assert!(!p.valid); + assert!( + p.errors.iter().any(|e| e.contains("dependency cycle")), + "{p:?}" + ); + assert!(p.errors.iter().any(|e| e.contains("CAMP-0042")), "{p:?}"); + } + + #[test] + fn test_create_writes_tickets_with_campaign_and_dependencies() { + let ws = Workspace::new(); + CampaignStore::new(&ws.config) + .create(super::super::NewCampaign { + title: "Epic".into(), + ..Default::default() + }) + .unwrap(); + let mut req = request(diamond()); + req.campaign = Some("CAMP-0001".into()); + let created = create(&ws.config, &req).unwrap(); + assert_eq!(created.tickets.len(), 4); + let all = ws.tickets(); + let join = all.iter().find(|t| t.id == "TASK-0001").unwrap(); + assert_eq!(join.depends_on, vec!["FEAT-0002", "FIX-0001"]); + assert_eq!(join.campaign.as_deref(), Some("CAMP-0001")); + assert_eq!(join.project, "api"); + let members = super::super::members("CAMP-0001", &all); + let plan = super::super::plan::plan(&ws.config, &members, &all).unwrap(); + assert_eq!(plan.waves, created.waves); + } + + #[test] + fn test_create_refuses_invalid_batch_without_writing() { + let ws = Workspace::new(); + let mut items = diamond(); + items.push(item("bad", "NOPE", &[])); + match create(&ws.config, &request(items)) { + Err(BatchError::Invalid(p)) => assert!(!p.valid), + other => panic!("expected invalid, got {other:?}"), + } + assert_eq!(ws.queued(), 0); + } + + #[test] + fn test_write_all_rolls_back_on_failure() { + let ws = Workspace::new(); + let queue = ws.dir.path().join("tickets/queue"); + let mut n = 0; + let result = write_all(3, |i| { + n += 1; + if i == 2 { + anyhow::bail!("disk full"); + } + let path = queue.join(format!("t{i}.md")); + fs::write(&path, "x")?; + Ok(path) + }); + assert!(matches!(result, Err(BatchError::Write(m)) if m.contains("disk full"))); + assert_eq!(n, 3); + assert_eq!(ws.queued(), 0, "written files are removed"); + } +} diff --git a/src/campaigns/mod.rs b/src/campaigns/mod.rs new file mode 100644 index 00000000..f5860819 --- /dev/null +++ b/src/campaigns/mod.rs @@ -0,0 +1,11 @@ +//! Campaigns: epics of tickets planned into dependency waves and launched together. + +pub mod batch; +pub mod model; +pub mod plan; +pub mod runner; +pub mod service; +pub mod store; +pub mod workspace; + +pub use store::{members, CampaignStore, NewCampaign}; diff --git a/src/campaigns/model.rs b/src/campaigns/model.rs new file mode 100644 index 00000000..ddb92f3b --- /dev/null +++ b/src/campaigns/model.rs @@ -0,0 +1,105 @@ +//! Campaign types: an epic of tickets planned and launched together. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::config::GovernancePolicy; + +/// Lifecycle of a campaign. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + TS, + utoipa::ToSchema, +)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum CampaignStatus { + #[default] + Draft, + Ready, + Running, + Paused, + Completed, + Cancelled, +} + +/// One execution environment shared by every ticket in a campaign. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CampaignWorkspace { + /// One Coder workspace created from this template. + Coder { template: String }, + /// One container from this image. + Docker { image: String }, +} + +/// A campaign, stored as frontmatter plus a markdown body under `/campaigns/`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct Campaign { + /// Campaign id (e.g. `CAMP-0003`); member tickets reference it as `campaign:`. + pub id: String, + /// Human-readable title. + pub title: String, + #[serde(default)] + pub status: CampaignStatus, + /// Creation date (`YYYY-MM-DD`). + #[serde(default)] + pub created: String, + /// Name of a `[[governance]]` policy layered over each delegator's own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance: Option, + /// Inline policy layered over `governance`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance_overrides: Option, + /// Shared execution environment; absent runs each ticket in its own workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + /// Upstream container this campaign was synced from (e.g. `jira:PROJ-1`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_ref: Option, + /// Markdown body after the frontmatter: the campaign's goal. + #[serde(skip)] + pub body: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_workspace_is_tagged_by_kind() { + let ws: CampaignWorkspace = + serde_yaml::from_str("kind: coder\ntemplate: operator-agent").unwrap(); + assert_eq!( + ws, + CampaignWorkspace::Coder { + template: "operator-agent".into() + } + ); + let ws: CampaignWorkspace = serde_yaml::from_str("kind: docker\nimage: img:1").unwrap(); + assert_eq!( + ws, + CampaignWorkspace::Docker { + image: "img:1".into() + } + ); + } + + #[test] + fn test_campaign_minimal_frontmatter_defaults_to_draft() { + let c: Campaign = serde_yaml::from_str("id: CAMP-0001\ntitle: T").unwrap(); + assert_eq!(c.status, CampaignStatus::Draft); + assert!(c.workspace.is_none() && c.governance.is_none()); + } +} diff --git a/src/campaigns/plan.rs b/src/campaigns/plan.rs new file mode 100644 index 00000000..d614d868 --- /dev/null +++ b/src/campaigns/plan.rs @@ -0,0 +1,303 @@ +//! Plan a campaign's tickets into dependency waves. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::config::Config; +use crate::queue::deps::{resolve, Resolved}; +use crate::queue::{queue_order_key, Ticket}; + +/// Tickets grouped into waves; every ticket in a wave can run in parallel. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CampaignPlan { + /// Pending members in launch order; wave N depends only on earlier waves. + pub waves: Vec>, + /// Member id → local tickets outside the campaign it waits on. + pub gates: BTreeMap>, + /// Member id → references to issues that are not synced; these block. + pub unsynced: BTreeMap>, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PlanError { + #[error("dependency cycle among {}", .0.join(", "))] + Cycle(Vec), +} + +fn folder(ticket: &Ticket) -> Option<&str> { + Path::new(&ticket.filepath).parent()?.file_name()?.to_str() +} + +/// Done: in `completed/`, or marked completed. +pub fn is_completed(ticket: &Ticket) -> bool { + folder(ticket) == Some("completed") || ticket.status == "completed" +} + +fn is_queued(ticket: &Ticket) -> bool { + folder(ticket) == Some("queue") && !is_completed(ticket) +} + +/// Build waves over the pending members; completed members count as satisfied. +pub fn plan( + config: &Config, + members: &[&Ticket], + all: &[Ticket], +) -> Result { + let member_ids: BTreeSet<&str> = members.iter().map(|t| t.id.as_str()).collect(); + let pending: BTreeMap<&str, &Ticket> = members + .iter() + .filter(|t| !is_completed(t)) + .map(|t| (t.id.as_str(), *t)) + .collect(); + + let mut result = CampaignPlan::default(); + let mut blockers: BTreeMap> = BTreeMap::new(); + for (id, ticket) in &pending { + let entry = blockers.entry((*id).to_string()).or_default(); + for dep in &ticket.depends_on { + match resolve(dep, all) { + Resolved::Local(dep_id) if pending.contains_key(dep_id.as_str()) => { + entry.insert(dep_id); + } + Resolved::Local(dep_id) if member_ids.contains(dep_id.as_str()) => {} + Resolved::Local(dep_id) => { + result + .gates + .entry((*id).to_string()) + .or_default() + .push(dep_id); + } + Resolved::Unsynced(r) => { + result + .unsynced + .entry((*id).to_string()) + .or_default() + .push(r); + } + } + } + } + result.waves = waves(blockers, |id| queue_order_key(config, pending[id]))?; + Ok(result) +} + +/// Kahn layering: each wave holds nodes whose blockers are all in earlier waves. +pub fn waves( + mut blockers: BTreeMap>, + order: impl Fn(&str) -> K, +) -> Result>, PlanError> { + let mut result = Vec::new(); + while !blockers.is_empty() { + let mut wave: Vec = blockers + .iter() + .filter(|(_, deps)| deps.is_empty()) + .map(|(id, _)| id.clone()) + .collect(); + if wave.is_empty() { + return Err(PlanError::Cycle(blockers.into_keys().collect())); + } + wave.sort_by_cached_key(|id| order(id)); + for id in &wave { + blockers.remove(id); + } + for deps in blockers.values_mut() { + for id in &wave { + deps.remove(id); + } + } + result.push(wave); + } + Ok(result) +} + +/// Queued members whose dependencies are all completed, in plan order. +pub fn ready(plan: &CampaignPlan, all: &[Ticket]) -> Vec { + let done = |id: &str| all.iter().any(|t| t.id == id && is_completed(t)); + plan.waves + .iter() + .flatten() + .filter(|id| !plan.unsynced.contains_key(*id)) + .filter_map(|id| all.iter().find(|t| &t.id == id)) + .filter(|t| is_queued(t)) + .filter(|t| { + t.depends_on.iter().all(|dep| match resolve(dep, all) { + Resolved::Local(dep_id) => done(&dep_id), + Resolved::Unsynced(_) => false, + }) + }) + .map(|t| t.id.clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + struct Board { + dir: tempfile::TempDir, + n: u32, + } + + impl Board { + fn new() -> Self { + Self { + dir: tempfile::tempdir().unwrap(), + n: 0, + } + } + + fn add(&mut self, folder: &str, id: &str, priority: &str, extra: &str) { + self.n += 1; + let dir = self.dir.path().join(folder); + fs::create_dir_all(&dir).unwrap(); + let ticket_type = id.split('-').next().unwrap(); + let name = format!("20260101-12{:02}-{ticket_type}-demo-t{}.md", self.n, self.n); + fs::write( + dir.join(name), + format!( + "---\nid: {id}\nstatus: queued\npriority: {priority}\n{extra}\n---\n\n# T\n" + ), + ) + .unwrap(); + } + + fn member(&mut self, id: &str, deps: &[&str]) { + self.add("queue", id, "P2-medium", &member_extra(deps)); + } + + fn tickets(&self) -> Vec { + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|d| fs::read_dir(self.dir.path().join(d)).ok()) + .flatten() + .map(|e| Ticket::from_file(&e.unwrap().path()).unwrap()) + .collect() + } + } + + fn member_extra(deps: &[&str]) -> String { + let mut extra = "campaign: CAMP-0001".to_string(); + if !deps.is_empty() { + extra.push_str("\ndepends_on:"); + for d in deps { + extra.push_str(&format!("\n - \"{d}\"")); + } + } + extra + } + + fn plan_board(board: &Board) -> (Result, Vec) { + let all = board.tickets(); + let members: Vec<&Ticket> = all + .iter() + .filter(|t| t.campaign.as_deref() == Some("CAMP-0001")) + .collect(); + (plan(&Config::default(), &members, &all), all) + } + + #[test] + fn test_diamond_plans_three_waves() { + let mut b = Board::new(); + b.member("FEAT-0001", &[]); + b.member("FEAT-0002", &["FEAT-0001"]); + b.member("FEAT-0003", &["FEAT-0001"]); + b.member("FEAT-0004", &["FEAT-0002", "FEAT-0003"]); + let (plan, all) = plan_board(&b); + let plan = plan.unwrap(); + assert_eq!( + plan.waves, + vec![ + vec!["FEAT-0001"], + vec!["FEAT-0002", "FEAT-0003"], + vec!["FEAT-0004"] + ] + ); + assert_eq!(ready(&plan, &all), vec!["FEAT-0001"]); + } + + #[test] + fn test_cycle_is_reported() { + let mut b = Board::new(); + b.member("FEAT-0001", &["FEAT-0002"]); + b.member("FEAT-0002", &["FEAT-0001"]); + b.member("FEAT-0003", &[]); + let (plan, _) = plan_board(&b); + assert_eq!( + plan.unwrap_err(), + PlanError::Cycle(vec!["FEAT-0001".into(), "FEAT-0002".into()]) + ); + } + + #[test] + fn test_wave_order_follows_queue_priority() { + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "P3-low", &member_extra(&[])); + b.add("queue", "FEAT-0002", "P0-critical", &member_extra(&[])); + b.add("queue", "FIX-0001", "P3-low", &member_extra(&[])); + let (plan, _) = plan_board(&b); + assert_eq!( + plan.unwrap().waves, + vec![vec!["FIX-0001", "FEAT-0002", "FEAT-0001"]] + ); + } + + #[test] + fn test_completed_members_drop_out_and_unblock() { + let mut b = Board::new(); + b.add("completed", "FEAT-0001", "P2-medium", &member_extra(&[])); + b.member("FEAT-0002", &["FEAT-0001"]); + let (plan, all) = plan_board(&b); + let plan = plan.unwrap(); + assert_eq!(plan.waves, vec![vec!["FEAT-0002"]]); + assert_eq!(ready(&plan, &all), vec!["FEAT-0002"]); + } + + #[test] + fn test_external_gate_blocks_until_completed() { + let mut b = Board::new(); + b.add("in-progress", "TASK-0009", "P2-medium", ""); + b.member("FEAT-0001", &["TASK-0009"]); + let (plan, all) = plan_board(&b); + let plan = plan.unwrap(); + assert_eq!(plan.gates["FEAT-0001"], vec!["TASK-0009"]); + assert!(ready(&plan, &all).is_empty()); + + let mut b = Board::new(); + b.add("completed", "TASK-0009", "P2-medium", ""); + b.member("FEAT-0001", &["TASK-0009"]); + let (plan, all) = plan_board(&b); + assert_eq!(ready(&plan.unwrap(), &all), vec!["FEAT-0001"]); + } + + #[test] + fn test_unsynced_ref_blocks() { + let mut b = Board::new(); + b.member("FEAT-0001", &["jira:PROJ-99"]); + let (plan, all) = plan_board(&b); + let plan = plan.unwrap(); + assert_eq!(plan.unsynced["FEAT-0001"], vec!["jira:PROJ-99"]); + assert_eq!(plan.waves, vec![vec!["FEAT-0001"]]); + assert!(ready(&plan, &all).is_empty()); + } + + #[test] + fn test_provider_refs_resolve_to_members_despite_type_mapping() { + let mut b = Board::new(); + b.add( + "queue", + "FIX-PROJ12", + "P2-medium", + &format!( + "{}\nexternal_id: PROJ-12\nexternal_provider: jira", + member_extra(&[]) + ), + ); + b.member("TASK-PROJ42", &["jira:PROJ-12"]); + let (plan, _) = plan_board(&b); + assert_eq!( + plan.unwrap().waves, + vec![vec!["FIX-PROJ12"], vec!["TASK-PROJ42"]] + ); + } +} diff --git a/src/campaigns/runner.rs b/src/campaigns/runner.rs new file mode 100644 index 00000000..55d72800 --- /dev/null +++ b/src/campaigns/runner.rs @@ -0,0 +1,476 @@ +//! Decide what a campaign launches next. Pure: the driver supplies every fact. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::plan::{is_completed, ready, CampaignPlan}; +use crate::config::GovernancePolicy; +use crate::queue::Ticket; +use crate::state::CampaignRun; + +/// Free launch capacity outside the campaign's own policy. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Slots { + /// Agents that may still start across all projects. + pub global: usize, + /// Agents allowed per project. + pub per_repo_limit: usize, + /// Running agents per project. + pub per_repo_busy: BTreeMap, +} + +/// Everything one tick decides from. +pub struct TickInput<'a> { + pub plan: &'a CampaignPlan, + pub members: &'a [&'a Ticket], + pub all: &'a [Ticket], + pub run: &'a CampaignRun, + pub policy: &'a GovernancePolicy, + pub slots: Slots, + pub entitled: bool, + /// Ticket ids with a live agent session. + pub live: &'a BTreeSet, + pub is_paired: &'a dyn Fn(&Ticket) -> bool, +} + +/// Why a pending member is not launching. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +#[serde(tag = "kind", content = "refs", rename_all = "snake_case")] +pub enum BlockReason { + /// Waiting on members in earlier waves. + Dependencies, + /// Waiting on local tickets outside the campaign. + Gate(Vec), + /// Waiting on upstream issues that are not synced. + Unsynced(Vec), + /// Ready, but no Premium entitlement. + License, + /// Ready, but no free slot this tick. + Capacity, +} + +/// What the run does after this tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunTransition { + Complete, + Pause(String), +} + +/// One tick's decision. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TickDecision { + pub launch: Vec, + pub awaiting_operator: Vec, + /// In progress with no live agent; left to the relaunch flows. + pub stalled: Vec, + pub blocked: BTreeMap, + pub transition: Option, +} + +fn in_progress(ticket: &Ticket) -> bool { + !is_completed(ticket) + && std::path::Path::new(&ticket.filepath) + .parent() + .and_then(|p| p.file_name()) + .is_some_and(|n| n == "in-progress") +} + +fn below(cap: Option, used: u32) -> bool { + cap.is_none_or(|c| used < c) +} + +fn waiting_reason(plan: &CampaignPlan, all: &[Ticket], id: &str) -> BlockReason { + if let Some(refs) = plan.unsynced.get(id) { + return BlockReason::Unsynced(refs.clone()); + } + let open_gates: Vec = plan + .gates + .get(id) + .into_iter() + .flatten() + .filter(|g| !all.iter().any(|t| &t.id == *g && is_completed(t))) + .cloned() + .collect(); + if open_gates.is_empty() { + BlockReason::Dependencies + } else { + BlockReason::Gate(open_gates) + } +} + +/// Decide launches for one tick. +pub fn next_launches(input: &TickInput) -> TickDecision { + let mut decision = TickDecision::default(); + if input.members.iter().all(|t| is_completed(t)) { + decision.transition = Some(RunTransition::Complete); + return decision; + } + + let active: Vec<&&Ticket> = input.members.iter().filter(|t| in_progress(t)).collect(); + decision.stalled = active + .iter() + .filter(|t| !input.live.contains(&t.id)) + .map(|t| t.id.clone()) + .collect(); + + let ready_ids: BTreeSet = ready(input.plan, input.all).into_iter().collect(); + let mut candidates: Vec<&Ticket> = Vec::new(); + for id in input.plan.waves.iter().flatten() { + let Some(ticket) = input.members.iter().find(|t| &t.id == id) else { + continue; + }; + if in_progress(ticket) { + continue; + } + if !ready_ids.contains(id) { + decision + .blocked + .insert(id.clone(), waiting_reason(input.plan, input.all, id)); + } else if (input.is_paired)(ticket) { + decision.awaiting_operator.push(id.clone()); + } else { + candidates.push(ticket); + } + } + + let policy = input.policy; + let mut global = input.slots.global; + let mut parallel = policy + .max_parallel + .map(|cap| (cap as usize).saturating_sub(active.len())); + let mut busy = input.slots.per_repo_busy.clone(); + let mut launched = input.run.launched(); + for ticket in candidates { + let id = &ticket.id; + if !input.entitled { + decision.blocked.insert(id.clone(), BlockReason::License); + continue; + } + if decision.transition.is_some() { + decision.blocked.insert(id.clone(), BlockReason::Capacity); + continue; + } + if !below( + policy.max_retries.map(|c| c + 1), + input.run.launches.get(id).copied().unwrap_or(0), + ) { + decision.transition = + Some(RunTransition::Pause(format!("max_retries reached by {id}"))); + decision.blocked.insert(id.clone(), BlockReason::Capacity); + continue; + } + if !below(policy.max_launches, launched) { + decision.transition = Some(RunTransition::Pause("max_launches reached".into())); + decision.blocked.insert(id.clone(), BlockReason::Capacity); + continue; + } + let repo_busy = busy.entry(ticket.project.clone()).or_default(); + if global == 0 || parallel == Some(0) || *repo_busy >= input.slots.per_repo_limit { + decision.blocked.insert(id.clone(), BlockReason::Capacity); + continue; + } + *repo_busy += 1; + global -= 1; + parallel = parallel.map(|p| p - 1); + launched += 1; + decision.launch.push(id.clone()); + } + decision +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + struct Board { + dir: tempfile::TempDir, + n: u32, + } + + impl Board { + fn new() -> Self { + Self { + dir: tempfile::tempdir().unwrap(), + n: 0, + } + } + + fn add(&mut self, folder: &str, id: &str, repo: &str, deps: &[&str]) { + self.n += 1; + let dir = self.dir.path().join(folder); + fs::create_dir_all(&dir).unwrap(); + let kind = id.split('-').next().unwrap(); + let mut fm = + format!("id: {id}\nstatus: queued\npriority: P2-medium\ncampaign: CAMP-0001"); + if !deps.is_empty() { + fm.push_str("\ndepends_on:"); + for d in deps { + fm.push_str(&format!("\n - \"{d}\"")); + } + } + let name = format!("20260101-12{:02}-{kind}-{repo}-t{}.md", self.n, self.n); + fs::write(dir.join(name), format!("---\n{fm}\n---\n\n# T\n")).unwrap(); + } + + fn tickets(&self) -> Vec { + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|d| fs::read_dir(self.dir.path().join(d)).ok()) + .flatten() + .map(|e| Ticket::from_file(&e.unwrap().path()).unwrap()) + .collect() + } + } + + fn spike_paired(t: &Ticket) -> bool { + t.ticket_type == "SPIKE" + } + + fn slots(global: usize, per_repo: usize) -> Slots { + Slots { + global, + per_repo_limit: per_repo, + per_repo_busy: BTreeMap::new(), + } + } + + struct Case { + all: Vec, + run: CampaignRun, + policy: GovernancePolicy, + slots: Slots, + entitled: bool, + live: BTreeSet, + } + + impl Case { + fn new(board: &Board) -> Self { + Self { + all: board.tickets(), + run: CampaignRun::default(), + policy: GovernancePolicy::default(), + slots: slots(8, 8), + entitled: true, + live: BTreeSet::new(), + } + } + + fn decide(&self) -> TickDecision { + let members: Vec<&Ticket> = self.all.iter().collect(); + let plan = + super::super::plan::plan(&crate::config::Config::default(), &members, &self.all) + .unwrap(); + next_launches(&TickInput { + plan: &plan, + members: &members, + all: &self.all, + run: &self.run, + policy: &self.policy, + slots: self.slots.clone(), + entitled: self.entitled, + live: &self.live, + is_paired: &spike_paired, + }) + } + } + + fn diamond() -> Board { + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "api", &[]); + b.add("queue", "FEAT-0002", "api", &["FEAT-0001"]); + b.add("queue", "FEAT-0003", "web", &["FEAT-0001"]); + b.add("queue", "FEAT-0004", "web", &["FEAT-0002", "FEAT-0003"]); + b + } + + #[test] + fn test_first_wave_launches_and_rest_wait_on_dependencies() { + let d = Case::new(&diamond()).decide(); + assert_eq!(d.launch, vec!["FEAT-0001"]); + assert_eq!(d.blocked["FEAT-0004"], BlockReason::Dependencies); + assert_eq!(d.transition, None); + } + + #[test] + fn test_pair_launches_together_once_root_completes() { + let mut b = Board::new(); + b.add("completed", "FEAT-0001", "api", &[]); + b.add("queue", "FEAT-0002", "api", &["FEAT-0001"]); + b.add("queue", "FEAT-0003", "web", &["FEAT-0001"]); + let d = Case::new(&b).decide(); + assert_eq!(d.launch, vec!["FEAT-0002", "FEAT-0003"]); + } + + #[test] + fn test_per_repo_limit_holds_back_same_repo() { + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "api", &[]); + b.add("queue", "FEAT-0002", "api", &[]); + b.add("queue", "FEAT-0003", "web", &[]); + let mut c = Case::new(&b); + c.slots = slots(8, 1); + let d = c.decide(); + assert_eq!(d.launch, vec!["FEAT-0001", "FEAT-0003"]); + assert_eq!(d.blocked["FEAT-0002"], BlockReason::Capacity); + } + + #[test] + fn test_busy_repo_counts_against_limit() { + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "api", &[]); + let mut c = Case::new(&b); + c.slots = slots(8, 1); + c.slots.per_repo_busy.insert("api".into(), 1); + assert!(c.decide().launch.is_empty()); + } + + #[test] + fn test_global_and_max_parallel_cap_launches() { + let mut b = Board::new(); + for i in 1..=4 { + b.add("queue", &format!("FEAT-000{i}"), &format!("r{i}"), &[]); + } + let mut c = Case::new(&b); + c.slots = slots(3, 8); + assert_eq!(c.decide().launch.len(), 3); + c.policy.max_parallel = Some(2); + assert_eq!(c.decide().launch.len(), 2); + } + + #[test] + fn test_max_parallel_counts_in_progress_members() { + let mut b = Board::new(); + b.add("in-progress", "FEAT-0001", "a", &[]); + b.add("queue", "FEAT-0002", "b", &[]); + b.add("queue", "FEAT-0003", "c", &[]); + let mut c = Case::new(&b); + c.live.insert("FEAT-0001".into()); + c.policy.max_parallel = Some(2); + assert_eq!(c.decide().launch, vec!["FEAT-0002"]); + } + + #[test] + fn test_paired_ticket_awaits_operator_and_never_launches() { + let mut b = Board::new(); + b.add("queue", "SPIKE-0001", "api", &[]); + b.add("queue", "FIX-0002", "api", &["SPIKE-0001"]); + let d = Case::new(&b).decide(); + assert!(d.launch.is_empty()); + assert_eq!(d.awaiting_operator, vec!["SPIKE-0001"]); + } + + #[test] + fn test_unentitled_launches_nothing() { + let mut c = Case::new(&diamond()); + c.entitled = false; + let d = c.decide(); + assert!(d.launch.is_empty()); + assert_eq!(d.blocked["FEAT-0001"], BlockReason::License); + assert_eq!(d.transition, None); + } + + #[test] + fn test_all_completed_completes_run() { + let mut b = Board::new(); + b.add("completed", "FEAT-0001", "api", &[]); + assert_eq!( + Case::new(&b).decide().transition, + Some(RunTransition::Complete) + ); + } + + #[test] + fn test_max_retries_pauses_when_ticket_needs_another_launch() { + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "api", &[]); + let mut c = Case::new(&b); + c.policy.max_retries = Some(1); + c.run.record_launch("FEAT-0001"); + assert_eq!(c.decide().launch, vec!["FEAT-0001"]); + c.run.record_launch("FEAT-0001"); + let d = c.decide(); + assert!(d.launch.is_empty()); + assert_eq!( + d.transition, + Some(RunTransition::Pause( + "max_retries reached by FEAT-0001".into() + )) + ); + } + + #[test] + fn test_max_launches_caps_the_run() { + let mut b = Board::new(); + b.add("completed", "FEAT-0001", "a", &[]); + b.add("queue", "FEAT-0002", "b", &[]); + b.add("queue", "FEAT-0003", "c", &[]); + let mut c = Case::new(&b); + c.run.record_launch("FEAT-0001"); + c.policy.max_launches = Some(2); + let d = c.decide(); + assert_eq!(d.launch, vec!["FEAT-0002"]); + assert_eq!( + d.transition, + Some(RunTransition::Pause("max_launches reached".into())) + ); + } + + #[test] + fn test_in_progress_without_live_agent_is_stalled() { + let mut b = Board::new(); + b.add("in-progress", "FEAT-0001", "a", &[]); + b.add("in-progress", "FEAT-0002", "b", &[]); + let mut c = Case::new(&b); + c.live.insert("FEAT-0002".into()); + let d = c.decide(); + assert_eq!(d.stalled, vec!["FEAT-0001"]); + assert!(d.launch.is_empty()); + } + + #[test] + fn test_unsynced_and_gate_reasons() { + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "a", &["jira:PROJ-9"]); + let mut c = Case::new(&b); + let d = c.decide(); + assert_eq!( + d.blocked["FEAT-0001"], + BlockReason::Unsynced(vec!["jira:PROJ-9".into()]) + ); + + let mut b = Board::new(); + b.add("queue", "FEAT-0001", "a", &["TASK-0009"]); + let dir = b.dir.path().join("queue"); + fs::write( + dir.join("20260101-1100-TASK-a-gate.md"), + "---\nid: TASK-0009\nstatus: queued\n---\n\n# G\n", + ) + .unwrap(); + c = Case::new(&b); + c.all + .retain(|t| t.campaign.is_some() || t.id == "TASK-0009"); + let members: Vec<&Ticket> = c.all.iter().filter(|t| t.campaign.is_some()).collect(); + let plan = + super::super::plan::plan(&crate::config::Config::default(), &members, &c.all).unwrap(); + let d = next_launches(&TickInput { + plan: &plan, + members: &members, + all: &c.all, + run: &c.run, + policy: &c.policy, + slots: c.slots.clone(), + entitled: true, + live: &c.live, + is_paired: &spike_paired, + }); + assert_eq!( + d.blocked["FEAT-0001"], + BlockReason::Gate(vec!["TASK-0009".into()]) + ); + } +} diff --git a/src/campaigns/service.rs b/src/campaigns/service.rs new file mode 100644 index 00000000..8a25d529 --- /dev/null +++ b/src/campaigns/service.rs @@ -0,0 +1,286 @@ +//! Campaign operations shared by REST, MCP and the CLI. Callers gate on the Premium licence first. + +use anyhow::{bail, Context, Result}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use super::model::{Campaign, CampaignStatus, CampaignWorkspace}; +use super::{members, plan, CampaignStore, NewCampaign}; +use crate::config::{governance, Config, GovernancePolicy}; +use crate::queue::{Queue, Ticket}; +use crate::services::campaign_runner::{self, CampaignPort, TickReport}; +use crate::state::State; + +/// Fields for a new campaign. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct NewCampaignInput { + pub title: String, + /// Markdown goal written as the campaign body. + #[serde(default)] + pub goal: Option, + /// Name of a `[[governance]]` policy. + #[serde(default)] + pub governance: Option, + /// Inline policy layered over `governance`. + #[serde(default)] + pub governance_overrides: Option, + #[serde(default)] + pub workspace: Option, +} + +/// A member ticket as seen from its campaign. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignMember { + pub id: String, + pub summary: String, + pub project: String, + /// `queue`, `in-progress` or `completed`. + pub column: String, + pub depends_on: Vec, + /// Launches made by the runner. + pub launches: u32, +} + +/// A campaign with its members and plan. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignDetail { + pub campaign: Campaign, + pub members: Vec, + pub waves: Vec>, + /// Member id → local tickets outside the campaign it waits on. + pub gates: std::collections::BTreeMap>, + /// Member id → unsynced upstream refs it waits on. + pub unsynced: std::collections::BTreeMap>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan_error: Option, +} + +/// Usage counted against the campaign's caps. +#[derive( + Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS, +)] +#[ts(export)] +pub struct CampaignCounters { + pub launched: u32, + pub max_launches: Option, + /// Members in progress. + pub active: u32, + pub max_parallel: Option, + /// Most relaunches of any one member. + pub retries: u32, + pub max_retries: Option, +} + +/// Live state of a campaign run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignStatusView { + pub id: String, + pub status: CampaignStatus, + pub tickets: Vec, + pub waves: Vec>, + /// What the next tick would do. + pub report: TickReport, + pub counters: CampaignCounters, + /// The policy the campaign layers over each delegator's own. + pub policy: GovernancePolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pause_reason: Option, +} + +/// Result of a launch or dry run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignLaunchOutcome { + pub id: String, + pub dry_run: bool, + pub status: CampaignStatus, + pub waves: Vec>, + pub policy: GovernancePolicy, + /// Reasons the campaign cannot start; empty when it can. + pub violations: Vec, + /// The first tick, run or (for a dry run) assessed. + pub report: TickReport, +} + +fn all_tickets(config: &Config) -> Result> { + let queue = Queue::new(config)?; + let mut all = queue.list_queue()?; + all.extend(queue.list_in_progress()?); + all.extend(queue.list_completed()?); + Ok(all) +} + +fn get(config: &Config, id: &str) -> Result { + CampaignStore::new(config) + .get(id) + .with_context(|| format!("campaign {id} not found")) +} + +/// The campaign's own layered policy. +pub fn policy(config: &Config, campaign: &Campaign) -> Result { + let mut layers: Vec<&GovernancePolicy> = Vec::new(); + if let Some(name) = &campaign.governance { + layers.push(governance::find_policy(&config.governance, name)?); + } + if let Some(overrides) = &campaign.governance_overrides { + layers.push(overrides); + } + Ok(governance::layer(&layers)) +} + +pub fn create_campaign(config: &Config, input: NewCampaignInput) -> Result { + if let Some(name) = &input.governance { + governance::find_policy(&config.governance, name)?; + } + CampaignStore::new(config).create(NewCampaign { + title: input.title, + goal: input.goal, + governance: input.governance, + governance_overrides: input.governance_overrides, + workspace: input.workspace, + external_ref: None, + }) +} + +/// Move a ticket into (or out of) a campaign; an edit that creates a cycle is reverted. +pub fn assign_ticket( + config: &Config, + ticket_id: &str, + campaign: Option<&str>, + depends_on: Option>, +) -> Result { + if let Some(id) = campaign { + get(config, id)?; + } + let queue = Queue::new(config)?; + let mut ticket = queue + .find_ticket(ticket_id)? + .with_context(|| format!("ticket {ticket_id} not found"))?; + let before = (ticket.campaign.clone(), ticket.depends_on.clone()); + ticket.set_campaign(campaign)?; + if let Some(deps) = &depends_on { + ticket.set_depends_on(deps)?; + } + if let Some(id) = campaign { + let all = all_tickets(config)?; + if let Err(e) = plan::plan(config, &members(id, &all), &all) { + ticket.set_campaign(before.0.as_deref())?; + ticket.set_depends_on(&before.1)?; + bail!(e); + } + } + Ok(ticket) +} + +fn member_views(config: &Config, id: &str, all: &[Ticket]) -> Result> { + let run = State::load(config)? + .campaign_runs + .remove(id) + .unwrap_or_default(); + Ok(members(id, all) + .into_iter() + .map(|t| CampaignMember { + id: t.id.clone(), + summary: t.summary.clone(), + project: t.project.clone(), + column: t.column(), + depends_on: t.depends_on.clone(), + launches: run.launches.get(&t.id).copied().unwrap_or(0), + }) + .collect()) +} + +pub fn detail(config: &Config, id: &str) -> Result { + let campaign = get(config, id)?; + let all = all_tickets(config)?; + let members_view = member_views(config, id, &all)?; + let (planned, plan_error) = match plan::plan(config, &members(id, &all), &all) { + Ok(planned) => (planned, None), + Err(e) => (plan::CampaignPlan::default(), Some(e.to_string())), + }; + Ok(CampaignDetail { + campaign, + members: members_view, + waves: planned.waves, + gates: planned.gates, + unsynced: planned.unsynced, + plan_error, + }) +} + +/// Start the campaign and run its first tick inline; a dry run only reports. +pub async fn launch( + config: &Config, + port: &impl CampaignPort, + id: &str, + dry_run: bool, +) -> Result { + let campaign = get(config, id)?; + let violations: Vec = campaign_runner::preflight(config, port, &campaign) + .iter() + .map(ToString::to_string) + .collect(); + let policy = policy(config, &campaign).unwrap_or_default(); + let waves = detail(config, id)?.waves; + let (status, report) = if dry_run { + (campaign.status, campaign_runner::assess(config, port, id)?) + } else { + campaign_runner::start_campaign(config, port, id)?; + let report = campaign_runner::tick(config, port, id).await?; + campaign_runner::wake().notify_one(); + (report.status, report) + }; + Ok(CampaignLaunchOutcome { + id: id.to_string(), + dry_run, + status, + waves, + policy, + violations, + report, + }) +} + +pub fn status(config: &Config, port: &impl CampaignPort, id: &str) -> Result { + let campaign = get(config, id)?; + let all = all_tickets(config)?; + let tickets = member_views(config, id, &all)?; + let run = State::load(config)? + .campaign_runs + .remove(id) + .unwrap_or_default(); + let policy = policy(config, &campaign).unwrap_or_default(); + let report = campaign_runner::assess(config, port, id)?; + let waves = plan::plan(config, &members(id, &all), &all) + .map(|p| p.waves) + .unwrap_or_default(); + let counters = CampaignCounters { + launched: run.launched(), + max_launches: policy.max_launches, + active: u32::try_from(tickets.iter().filter(|t| t.column == "in-progress").count()) + .unwrap_or(u32::MAX), + max_parallel: policy.max_parallel, + retries: tickets + .iter() + .map(|t| run.retries(&t.id)) + .max() + .unwrap_or(0), + max_retries: policy.max_retries, + }; + Ok(CampaignStatusView { + id: id.to_string(), + status: campaign.status, + tickets, + waves, + report, + counters, + policy, + pause_reason: run.pause_reason, + }) +} diff --git a/src/campaigns/store.rs b/src/campaigns/store.rs new file mode 100644 index 00000000..bcdc9aba --- /dev/null +++ b/src/campaigns/store.rs @@ -0,0 +1,236 @@ +//! Campaign files under `/campaigns/`. Membership lives on tickets. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::model::{Campaign, CampaignStatus, CampaignWorkspace}; +use crate::config::Config; +use crate::queue::{ids, Ticket}; + +/// Fields a new campaign starts with. +#[derive(Debug, Clone, Default)] +pub struct NewCampaign { + pub title: String, + pub goal: Option, + pub governance: Option, + pub governance_overrides: Option, + pub workspace: Option, + pub external_ref: Option, +} + +/// Reads and writes campaign files. +pub struct CampaignStore { + dir: PathBuf, +} + +impl CampaignStore { + pub fn new(config: &Config) -> Self { + Self::at(config.tickets_path().join("campaigns")) + } + + pub fn at(dir: PathBuf) -> Self { + Self { dir } + } + + /// Every campaign with its file path, ordered by id. + fn entries(&self) -> Vec<(PathBuf, Campaign)> { + let mut entries: Vec<(PathBuf, Campaign)> = fs::read_dir(&self.dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == "md")) + .filter_map(|p| Some((p.clone(), read_campaign(&p).ok()?))) + .collect(); + entries.sort_by(|a, b| a.1.id.cmp(&b.1.id)); + entries + } + + pub fn list(&self) -> Vec { + self.entries().into_iter().map(|(_, c)| c).collect() + } + + pub fn get(&self, id: &str) -> Option { + self.list().into_iter().find(|c| c.id == id) + } + + pub fn find_by_external_ref(&self, external_ref: &str) -> Option { + self.list() + .into_iter() + .find(|c| c.external_ref.as_deref() == Some(external_ref)) + } + + pub fn create(&self, new: NewCampaign) -> Result { + let existing: Vec = self.list().into_iter().map(|c| c.id).collect(); + let id = ids::next_ticket_id("CAMP", existing.iter().map(String::as_str)); + let body = format!( + "# {}\n\n{}\n", + new.title, + new.goal.as_deref().unwrap_or_default() + ); + let campaign = Campaign { + id, + title: new.title, + status: CampaignStatus::Draft, + created: chrono::Local::now().format("%Y-%m-%d").to_string(), + governance: new.governance, + governance_overrides: new.governance_overrides, + workspace: new.workspace, + external_ref: new.external_ref, + body, + }; + fs::create_dir_all(&self.dir).context("Failed to create campaigns directory")?; + let slug = ids::slugify(&campaign.title, 50); + let path = ids::unique_path(&self.dir, &format!("{}-{slug}", campaign.id)); + write_campaign(&path, &campaign)?; + Ok(campaign) + } + + pub fn set_status(&self, id: &str, status: CampaignStatus) -> Result { + let (path, mut campaign) = self + .entries() + .into_iter() + .find(|(_, c)| c.id == id) + .with_context(|| format!("campaign {id} not found"))?; + campaign.status = status; + write_campaign(&path, &campaign)?; + Ok(campaign) + } +} + +/// Tickets that belong to `campaign_id`. +pub fn members<'a>(campaign_id: &str, tickets: &'a [Ticket]) -> Vec<&'a Ticket> { + tickets + .iter() + .filter(|t| t.campaign.as_deref() == Some(campaign_id)) + .collect() +} + +fn read_campaign(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let after_open = content + .trim_start() + .strip_prefix("---") + .context("campaign file has no frontmatter")?; + let end = after_open + .find("\n---") + .context("campaign frontmatter is not closed")?; + let mut campaign: Campaign = serde_yaml::from_str(&after_open[..end])?; + campaign.body = after_open[end + 4..].trim_start_matches('\n').to_string(); + Ok(campaign) +} + +fn write_campaign(path: &Path, campaign: &Campaign) -> Result<()> { + let yaml = serde_yaml::to_string(campaign).context("Failed to serialize campaign")?; + fs::write(path, format!("---\n{yaml}---\n\n{}", campaign.body)) + .context("Failed to write campaign file") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(dir: &Path) -> CampaignStore { + CampaignStore::at(dir.join("campaigns")) + } + + #[test] + fn test_create_then_get_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let store = store(dir.path()); + let created = store + .create(NewCampaign { + title: "Pricing overhaul".into(), + goal: Some("Ship v2 pricing.".into()), + workspace: Some(CampaignWorkspace::Docker { + image: "untra/operator:latest".into(), + }), + ..Default::default() + }) + .unwrap(); + assert_eq!(created.id, "CAMP-0001"); + let read = store.get("CAMP-0001").unwrap(); + assert_eq!(read, created); + assert!(read.body.contains("Ship v2 pricing.")); + assert!(dir + .path() + .join("campaigns/CAMP-0001-pricing-overhaul.md") + .exists()); + } + + #[test] + fn test_ids_are_sequential_and_titles_may_repeat() { + let dir = tempfile::tempdir().unwrap(); + let store = store(dir.path()); + for _ in 0..3 { + store + .create(NewCampaign { + title: "Same".into(), + ..Default::default() + }) + .unwrap(); + } + let ids: Vec = store.list().into_iter().map(|c| c.id).collect(); + assert_eq!(ids, vec!["CAMP-0001", "CAMP-0002", "CAMP-0003"]); + } + + #[test] + fn test_set_status_persists_and_keeps_body() { + let dir = tempfile::tempdir().unwrap(); + let store = store(dir.path()); + store + .create(NewCampaign { + title: "Run".into(), + goal: Some("Goal text.".into()), + ..Default::default() + }) + .unwrap(); + store + .set_status("CAMP-0001", CampaignStatus::Running) + .unwrap(); + let read = store.get("CAMP-0001").unwrap(); + assert_eq!(read.status, CampaignStatus::Running); + assert!(read.body.contains("Goal text.")); + assert!(store + .set_status("CAMP-0009", CampaignStatus::Paused) + .is_err()); + } + + #[test] + fn test_find_by_external_ref() { + let dir = tempfile::tempdir().unwrap(); + let store = store(dir.path()); + store + .create(NewCampaign { + title: "Epic".into(), + external_ref: Some("jira:PROJ-1".into()), + ..Default::default() + }) + .unwrap(); + let found = store.find_by_external_ref("jira:PROJ-1").unwrap(); + assert_eq!(found.id, "CAMP-0001"); + assert_eq!(found.status, CampaignStatus::Draft); + assert!(store.find_by_external_ref("jira:PROJ-2").is_none()); + } + + #[test] + fn test_assign_and_members() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("20260101-1200-FEAT-demo-a.md"); + fs::write( + &path, + "---\nid: FEAT-0001\nstatus: queued\n---\n\n# Feature: a\n", + ) + .unwrap(); + let mut ticket = Ticket::from_file(&path).unwrap(); + ticket.set_campaign(Some("CAMP-0001")).unwrap(); + let tickets = vec![Ticket::from_file(&path).unwrap()]; + assert_eq!(members("CAMP-0001", &tickets).len(), 1); + + ticket.set_campaign(None).unwrap(); + let reread = Ticket::from_file(&path).unwrap(); + assert!(reread.campaign.is_none()); + assert!(!reread.content.contains("campaign")); + } +} diff --git a/src/campaigns/workspace.rs b/src/campaigns/workspace.rs new file mode 100644 index 00000000..d59ba62b --- /dev/null +++ b/src/campaigns/workspace.rs @@ -0,0 +1,144 @@ +//! Resolve a campaign's shared workspace to the target its tickets launch on. + +use super::model::{Campaign, CampaignWorkspace}; +use crate::config::{CoderConfig, Config, TargetDef, TargetKind, DEFAULT_CODER_TARGET_NAME}; + +/// Where every ticket of a shared-workspace campaign runs. +/// +/// Tickets still get their own worktree and branch inside it, so a wave can run in parallel. +#[derive(Debug, Clone, PartialEq)] +pub struct CampaignTarget { + pub target: TargetDef, + /// Keys the one shared environment (e.g. the Coder workspace name) instead of a ticket id. + pub workspace_key: String, +} + +/// `None` when the campaign has no shared workspace; tickets resolve targets as usual. +pub fn resolve_workspace(config: &Config, campaign: &Campaign) -> Option { + let target = match campaign.workspace.as_ref()? { + CampaignWorkspace::Coder { template } => { + let (name, base) = config + .targets + .iter() + .find_map(|t| match &t.kind { + TargetKind::Coder(c) => Some((t.name.clone(), c.clone())), + _ => None, + }) + .unwrap_or_else(|| { + ( + DEFAULT_CODER_TARGET_NAME.to_string(), + CoderConfig::default(), + ) + }); + TargetDef { + name, + display_name: None, + kind: TargetKind::Coder(CoderConfig { + template: template.clone(), + ..base + }), + } + } + CampaignWorkspace::Docker { image } => { + let mut docker = config.launch.docker.clone(); + docker.image = image.clone(); + TargetDef::docker(docker) + } + }; + Some(CampaignTarget { + target, + workspace_key: campaign.id.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::campaigns::model::CampaignStatus; + + fn campaign(workspace: Option) -> Campaign { + Campaign { + id: "CAMP-0003".into(), + title: "T".into(), + status: CampaignStatus::Draft, + created: String::new(), + governance: None, + governance_overrides: None, + workspace, + external_ref: None, + body: String::new(), + } + } + + #[test] + fn test_no_workspace_resolves_to_none() { + assert!(resolve_workspace(&Config::default(), &campaign(None)).is_none()); + } + + #[test] + fn test_coder_template_overrides_configured_target() { + let mut config = Config::default(); + config.targets.push(TargetDef { + name: "cloud".into(), + display_name: None, + kind: TargetKind::Coder(CoderConfig { + template: "default-tpl".into(), + name_prefix: "acme".into(), + ..CoderConfig::default() + }), + }); + let resolved = resolve_workspace( + &config, + &campaign(Some(CampaignWorkspace::Coder { + template: "campaign-tpl".into(), + })), + ) + .unwrap(); + assert_eq!(resolved.workspace_key, "CAMP-0003"); + assert_eq!(resolved.target.name, "cloud"); + let TargetKind::Coder(coder) = resolved.target.kind else { + panic!("expected coder"); + }; + assert_eq!(coder.template, "campaign-tpl"); + assert_eq!(coder.name_prefix, "acme"); + } + + #[test] + fn test_coder_without_configured_target_uses_defaults() { + let resolved = resolve_workspace( + &Config::default(), + &campaign(Some(CampaignWorkspace::Coder { + template: "tpl".into(), + })), + ) + .unwrap(); + assert_eq!(resolved.target.name, DEFAULT_CODER_TARGET_NAME); + } + + #[test] + fn test_docker_image_override() { + let resolved = resolve_workspace( + &Config::default(), + &campaign(Some(CampaignWorkspace::Docker { + image: "untra/operator:camp".into(), + })), + ) + .unwrap(); + let TargetKind::Docker(docker) = resolved.target.kind else { + panic!("expected docker"); + }; + assert_eq!(docker.image, "untra/operator:camp"); + } + + #[test] + fn test_shared_coder_still_needs_remote_entitlement() { + let resolved = resolve_workspace( + &Config::default(), + &campaign(Some(CampaignWorkspace::Coder { + template: "tpl".into(), + })), + ) + .unwrap(); + assert!(crate::licensing::require_target(&Config::default(), &resolved.target).is_err()); + } +} diff --git a/src/collections/fetch.rs b/src/collections/fetch.rs index 1efff618..b132c8e7 100644 --- a/src/collections/fetch.rs +++ b/src/collections/fetch.rs @@ -18,13 +18,7 @@ use crate::collections::{get_embedded_collection, EmbeddedCollection, EMBEDDED_C /// Compute the lowercase-hex SHA-256 of `bytes`. pub fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::with_capacity(64); - for b in digest { - use std::fmt::Write as _; - let _ = write!(out, "{b:02x}"); - } - out + hex::encode(Sha256::digest(bytes)) } /// Derive a manifest-level checksum from the per-issuetype file checksums. diff --git a/src/config.rs b/src/config.rs index 9406a19c..3b639126 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,8 @@ pub mod agent_profile; #[path = "config/git_config.rs"] pub mod git_config; +#[path = "config/governance.rs"] +pub mod governance; #[path = "config/kanban.rs"] pub mod kanban; #[path = "config/llm_tools.rs"] @@ -15,6 +17,7 @@ pub mod targets; pub use agent_profile::*; pub use git_config::*; +pub use governance::GovernancePolicy; pub use kanban::*; pub use llm_tools::*; pub use notifications_config::*; @@ -87,6 +90,9 @@ pub struct Config { /// Named execution targets (docker/coder/ssh/local) referenced by `DelegatorLaunchConfig.target`. #[serde(default)] pub targets: Vec, + /// Named launch policies referenced by `Delegator.governance`. + #[serde(default)] + pub governance: Vec, /// Relay MCP injection configuration #[serde(default)] pub relay: RelayConfig, @@ -1000,6 +1006,7 @@ impl Default for Config { model_servers: Vec::new(), hosts: Vec::new(), targets: Vec::new(), + governance: Vec::new(), relay: RelayConfig::default(), mcp: McpConfig::default(), acp: AcpConfig::default(), diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs index 26204c4f..d9744bb1 100644 --- a/src/config/agent_profile.rs +++ b/src/config/agent_profile.rs @@ -78,6 +78,9 @@ pub struct XOperator { /// Name of a declared `ModelServer` (`None` = implicit vendor default). #[serde(default, skip_serializing_if = "Option::is_none")] pub model_server: Option, + /// Name of a `[[governance]]` policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance: Option, /// Launch configuration (permission mode, flags, worktree/docker, prompt /// wrapping, ...). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -91,6 +94,7 @@ impl XOperator { && self.display_name.is_none() && self.model_properties.is_empty() && self.model_server.is_none() + && self.governance.is_none() && self.launch_config.is_none() } } @@ -138,6 +142,7 @@ pub fn delegator_to_profile(d: &Delegator) -> AgentProfile { display_name: d.display_name.clone(), model_properties: d.model_properties.clone(), model_server: d.model_server.clone(), + governance: d.governance.clone(), launch_config: d.launch_config.clone(), }; @@ -190,6 +195,7 @@ pub fn profile_to_delegator(p: &AgentProfile) -> Delegator { model_properties: x.model_properties, launch_config: x.launch_config, model_server: x.model_server, + governance: x.governance, remote_agent: p.remote_agent.clone(), x_agnt: p.x_agnt.clone(), x_openai: p.x_openai.clone(), @@ -213,6 +219,7 @@ mod tests { let mut props = std::collections::HashMap::new(); props.insert("reasoning_effort".to_string(), "high".to_string()); Delegator { + governance: None, git: None, name: "claude-opus-auto".to_string(), llm_tool: "claude".to_string(), @@ -268,6 +275,7 @@ mod tests { #[test] fn delegator_with_no_operator_data_has_no_x_operator() { let d = Delegator { + governance: None, git: None, name: "bare".to_string(), llm_tool: "claude".to_string(), diff --git a/src/config/config_tests.rs b/src/config/config_tests.rs index 2122ebe2..cee48b8f 100644 --- a/src/config/config_tests.rs +++ b/src/config/config_tests.rs @@ -23,6 +23,7 @@ fn test_dev_kanban_has_three_issue_types() { #[test] fn test_delegator_serde_roundtrip() { let delegator = Delegator { + governance: None, git: None, name: "claude-opus-auto".to_string(), llm_tool: "claude".to_string(), diff --git a/src/config/governance.rs b/src/config/governance.rs new file mode 100644 index 00000000..dfc7b393 --- /dev/null +++ b/src/config/governance.rs @@ -0,0 +1,365 @@ +//! Governance: named, layerable launch policies. +//! +//! A `[[governance]]` policy is referenced by name from a delegator (and, later, +//! a campaign). Layered policies intersect, so the most restrictive rule wins. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// A named launch policy. Every constraint is optional; an absent one is unconstrained. +#[derive( + Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, +)] +#[ts(export)] +pub struct GovernancePolicy { + /// Unique name, referenced by `Delegator.governance`. + pub name: String, + /// Delegator names allowed to launch. Absent = any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_delegators: Option>, + /// LLM tool names allowed to launch (e.g. `claude`). Absent = any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_llm_tools: Option>, + /// Model server names allowed (declared or implicit, e.g. `anthropic-api`). Absent = any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_model_servers: Option>, + /// Execution target names allowed (e.g. `local`, `docker`, a `[[targets]]` name). Absent = any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_targets: Option>, + /// Maximum concurrently running agents under this policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_parallel: Option, + /// Maximum total launches under this policy (counted per campaign run). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_launches: Option, + /// Maximum relaunches of a failed ticket (counted per campaign run). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_retries: Option, + /// Maximum wall-clock minutes per agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_runtime_minutes: Option, + /// Spend ceiling in USD. Reserved: accepted and reported, not yet enforced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget_usd: Option, + /// Require operator confirmation before each launch. + #[serde(default)] + pub require_confirmation: bool, +} + +/// What a launch is about to use, checked against a policy. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LaunchFacts { + pub delegator: Option, + pub llm_tool: Option, + pub model_server: Option, + pub target: String, +} + +/// Usage already counted against a policy. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct GovernanceCounters { + pub active: u32, + pub launched: u32, + pub retries: u32, +} + +/// A launch refused by governance. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("Governance policy '{policy}' denied launch: {reason}")] +pub struct GovernanceViolation { + pub policy: String, + pub reason: String, +} + +fn intersect(a: Option<&Vec>, b: Option<&Vec>) -> Option> { + match (a, b) { + (None, None) => None, + (Some(x), None) | (None, Some(x)) => Some(x.clone()), + (Some(x), Some(y)) => Some(x.iter().filter(|v| y.contains(v)).cloned().collect()), + } +} + +fn min_cap(a: Option, b: Option) -> Option { + match (a, b) { + (Some(x), Some(y)) => Some(if y < x { y } else { x }), + (x, y) => x.or(y), + } +} + +/// Stack policies into one effective policy; the most restrictive rule wins. +pub fn layer(policies: &[&GovernancePolicy]) -> GovernancePolicy { + let name = policies + .iter() + .map(|p| p.name.as_str()) + .collect::>() + .join("+"); + policies.iter().fold( + GovernancePolicy { + name, + ..GovernancePolicy::default() + }, + |acc, p| GovernancePolicy { + name: acc.name, + allowed_delegators: intersect( + acc.allowed_delegators.as_ref(), + p.allowed_delegators.as_ref(), + ), + allowed_llm_tools: intersect( + acc.allowed_llm_tools.as_ref(), + p.allowed_llm_tools.as_ref(), + ), + allowed_model_servers: intersect( + acc.allowed_model_servers.as_ref(), + p.allowed_model_servers.as_ref(), + ), + allowed_targets: intersect(acc.allowed_targets.as_ref(), p.allowed_targets.as_ref()), + max_parallel: min_cap(acc.max_parallel, p.max_parallel), + max_launches: min_cap(acc.max_launches, p.max_launches), + max_retries: min_cap(acc.max_retries, p.max_retries), + max_runtime_minutes: min_cap(acc.max_runtime_minutes, p.max_runtime_minutes), + budget_usd: min_cap(acc.budget_usd, p.budget_usd), + require_confirmation: acc.require_confirmation || p.require_confirmation, + }, + ) +} + +fn check_allowed( + policy: &str, + kind: &str, + allowed: Option<&Vec>, + value: Option<&str>, +) -> Result<(), GovernanceViolation> { + let Some(allowed) = allowed else { + return Ok(()); + }; + match value { + Some(v) if allowed.iter().any(|a| a == v) => Ok(()), + Some(v) => Err(GovernanceViolation { + policy: policy.to_string(), + reason: format!( + "{kind} '{v}' is not in allowed {kind}s [{}]", + allowed.join(", ") + ), + }), + None => Err(GovernanceViolation { + policy: policy.to_string(), + reason: format!("launch has no {kind}, but the policy restricts {kind}s"), + }), + } +} + +fn check_cap( + policy: &str, + kind: &str, + cap: Option, + used: u32, +) -> Result<(), GovernanceViolation> { + match cap { + Some(cap) if used >= cap => Err(GovernanceViolation { + policy: policy.to_string(), + reason: format!("{kind} limit of {cap} reached"), + }), + _ => Ok(()), + } +} + +/// Check one launch against a policy and its counted usage. +pub fn evaluate( + policy: &GovernancePolicy, + facts: &LaunchFacts, + counters: &GovernanceCounters, +) -> Result<(), GovernanceViolation> { + let name = policy.name.as_str(); + check_allowed( + name, + "delegator", + policy.allowed_delegators.as_ref(), + facts.delegator.as_deref(), + )?; + check_allowed( + name, + "llm_tool", + policy.allowed_llm_tools.as_ref(), + facts.llm_tool.as_deref(), + )?; + check_allowed( + name, + "model_server", + policy.allowed_model_servers.as_ref(), + facts.model_server.as_deref(), + )?; + check_allowed( + name, + "target", + policy.allowed_targets.as_ref(), + Some(&facts.target), + )?; + check_cap(name, "max_parallel", policy.max_parallel, counters.active)?; + check_cap(name, "max_launches", policy.max_launches, counters.launched)?; + check_cap(name, "max_retries", policy.max_retries, counters.retries)?; + Ok(()) +} + +/// Find a named policy; an unknown name is an error, never a silent pass. +pub fn find_policy<'a>( + policies: &'a [GovernancePolicy], + name: &str, +) -> Result<&'a GovernancePolicy, GovernanceViolation> { + policies + .iter() + .find(|p| p.name == name) + .ok_or_else(|| GovernanceViolation { + policy: name.to_string(), + reason: "no [[governance]] policy with that name".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strs(v: &[&str]) -> Option> { + Some(v.iter().map(|s| (*s).to_string()).collect()) + } + + fn facts(target: &str) -> LaunchFacts { + LaunchFacts { + delegator: Some("opus".into()), + llm_tool: Some("claude".into()), + model_server: Some("anthropic-api".into()), + target: target.into(), + } + } + + #[test] + fn test_evaluate_empty_policy_allows_everything() { + let policy = GovernancePolicy::default(); + assert!(evaluate(&policy, &facts("coder"), &GovernanceCounters::default()).is_ok()); + } + + #[test] + fn test_evaluate_disallowed_target_is_denied() { + let policy = GovernancePolicy { + name: "local-only".into(), + allowed_targets: strs(&["local", "docker"]), + ..Default::default() + }; + let err = evaluate(&policy, &facts("coder"), &GovernanceCounters::default()).unwrap_err(); + assert_eq!(err.policy, "local-only"); + assert!(err.reason.contains("target 'coder'")); + assert!(evaluate(&policy, &facts("docker"), &GovernanceCounters::default()).is_ok()); + } + + #[test] + fn test_evaluate_restricted_kind_missing_from_launch_is_denied() { + let policy = GovernancePolicy { + name: "named".into(), + allowed_delegators: strs(&["opus"]), + ..Default::default() + }; + let adhoc = LaunchFacts { + delegator: None, + ..facts("local") + }; + assert!(evaluate(&policy, &adhoc, &GovernanceCounters::default()).is_err()); + } + + #[test] + fn test_evaluate_caps_deny_at_limit() { + let policy = GovernancePolicy { + name: "capped".into(), + max_parallel: Some(2), + max_launches: Some(5), + ..Default::default() + }; + let under = GovernanceCounters { + active: 1, + launched: 4, + retries: 0, + }; + assert!(evaluate(&policy, &facts("local"), &under).is_ok()); + let full = GovernanceCounters { active: 2, ..under }; + assert!(evaluate(&policy, &facts("local"), &full) + .unwrap_err() + .reason + .contains("max_parallel")); + let spent = GovernanceCounters { + launched: 5, + ..under + }; + assert!(evaluate(&policy, &facts("local"), &spent) + .unwrap_err() + .reason + .contains("max_launches")); + } + + #[test] + fn test_layer_intersects_lists_and_takes_minimum_caps() { + let campaign = GovernancePolicy { + name: "campaign".into(), + allowed_targets: strs(&["local", "coder"]), + max_parallel: Some(4), + budget_usd: Some(50.0), + ..Default::default() + }; + let delegator = GovernancePolicy { + name: "delegator".into(), + allowed_targets: strs(&["coder", "ssh"]), + allowed_llm_tools: strs(&["claude"]), + max_parallel: Some(2), + require_confirmation: true, + ..Default::default() + }; + let effective = layer(&[&campaign, &delegator]); + assert_eq!(effective.name, "campaign+delegator"); + assert_eq!(effective.allowed_targets, strs(&["coder"])); + assert_eq!(effective.allowed_llm_tools, strs(&["claude"])); + assert_eq!(effective.allowed_delegators, None); + assert_eq!(effective.max_parallel, Some(2)); + assert_eq!(effective.budget_usd, Some(50.0)); + assert!(effective.require_confirmation); + } + + #[test] + fn test_layer_disjoint_lists_allow_nothing() { + let a = GovernancePolicy { + name: "a".into(), + allowed_targets: strs(&["local"]), + ..Default::default() + }; + let b = GovernancePolicy { + name: "b".into(), + allowed_targets: strs(&["coder"]), + ..Default::default() + }; + let effective = layer(&[&a, &b]); + assert_eq!(effective.allowed_targets, Some(vec![])); + assert!(evaluate(&effective, &facts("local"), &GovernanceCounters::default()).is_err()); + } + + #[test] + fn test_find_policy_unknown_name_errors() { + let policies = vec![GovernancePolicy { + name: "strict".into(), + ..Default::default() + }]; + assert!(find_policy(&policies, "strict").is_ok()); + assert!(find_policy(&policies, "missing").is_err()); + } + + #[test] + fn test_policy_deserializes_from_toml_with_falsey_defaults() { + let policy: GovernancePolicy = toml::from_str( + r#" + name = "team" + allowed_targets = ["local"] + max_parallel = 3 + "#, + ) + .unwrap(); + assert_eq!(policy.max_parallel, Some(3)); + assert!(!policy.require_confirmation); + assert_eq!(policy.budget_usd, None); + } +} diff --git a/src/config/kanban.rs b/src/config/kanban.rs index e39ea8f4..7f7d76fc 100644 --- a/src/config/kanban.rs +++ b/src/config/kanban.rs @@ -190,6 +190,7 @@ impl KanbanConfig { type_mappings: std::collections::HashMap::new(), bidirectional: false, ticket_project: None, + scope: None, }, ); } @@ -220,6 +221,7 @@ impl KanbanConfig { type_mappings: std::collections::HashMap::new(), bidirectional: false, ticket_project: None, + scope: None, }, ); } @@ -253,6 +255,7 @@ impl KanbanConfig { type_mappings: std::collections::HashMap::new(), bidirectional: false, ticket_project: None, + scope: None, }, ); } @@ -379,6 +382,25 @@ pub struct ProjectSyncConfig { /// Defaults to the external project key when unset. #[serde(default, skip_serializing_if = "Option::is_none")] pub ticket_project: Option, + /// Sync one upstream container into a campaign instead of the whole project (Premium). + /// Scoped syncs pull every member regardless of assignee. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, +} + +/// An upstream container synced as one campaign. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +#[serde(rename_all = "snake_case")] +pub enum SyncScope { + /// A Jira epic (or any parent issue) by key, e.g. `PROJ-1`; children are its members. + JiraEpic(String), + /// A Linear project by id; its issues are the members. + LinearProject(String), + /// A GitHub parent issue as `owner/repo#N`; its sub-issues are the members. + GithubParent(String), + /// An OpenSpec change by id; its task groups are the members. + OpenspecChange(String), } impl ProjectSyncConfig { diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index 3aca104e..b6f3c905 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -173,6 +173,9 @@ pub struct Delegator { /// (claude → anthropic-api, codex → openai-api, gemini → google-api). #[serde(default)] pub model_server: Option, + /// Name of a `[[governance]]` policy enforced on every launch by this delegator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance: Option, /// Declarative reference to a remote, named agent on another platform /// (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). /// @@ -451,6 +454,7 @@ mod tests { #[test] fn delegator_serializes_omits_none_new_fields() { let d = Delegator { + governance: None, git: None, name: "claude-opus".to_string(), llm_tool: "claude".to_string(), diff --git a/src/config/sessions.rs b/src/config/sessions.rs index 1e456e52..ecea9003 100644 --- a/src/config/sessions.rs +++ b/src/config/sessions.rs @@ -112,6 +112,10 @@ pub struct SessionsConfig { /// Zellij-specific configuration #[serde(default)] pub zellij: SessionsZellijConfig, + + /// How the UI connects you to a launched agent's session + #[serde(default)] + pub connect: SessionsConnectConfig, } impl Default for SessionsConfig { @@ -122,10 +126,68 @@ impl Default for SessionsConfig { vscode: SessionsVSCodeConfig::default(), cmux: SessionsCmuxConfig::default(), zellij: SessionsZellijConfig::default(), + connect: SessionsConnectConfig::default(), } } } +/// Interface used to open a launched agent's session +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema, +)] +#[serde(rename_all = "lowercase")] +#[ts(export)] +pub enum ConnectInterface { + /// VS Code, via the Operator extension + Vscode, + /// Cursor, via the Operator extension + Cursor, + /// Zed (opens the folder only) + Zed, + /// Coder's VS Code extension + Coder, + /// cmux workspace focus + Cmux, + /// tmux attach + Tmux, + /// zellij attach + Zellij, + /// Plain SSH into the remote session + Ssh, + /// Native terminal running the attach or resume command + Terminal, +} + +impl ConnectInterface { + /// Display label for links and buttons + pub fn label(&self) -> &'static str { + match self { + ConnectInterface::Vscode => "VS Code", + ConnectInterface::Cursor => "Cursor", + ConnectInterface::Zed => "Zed", + ConnectInterface::Coder => "Coder", + ConnectInterface::Cmux => "cmux", + ConnectInterface::Tmux => "tmux", + ConnectInterface::Zellij => "zellij", + ConnectInterface::Ssh => "ssh", + ConnectInterface::Terminal => "terminal", + } + } +} + +/// Connect preferences; the offered interfaces are derived from what is configured and installed +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct SessionsConnectConfig { + /// Primary interface for connect links; unset follows the agent's session wrapper + #[serde(default)] + pub preferred: Option, + + /// Command that opens a native terminal running `{command}` (e.g. `open -na Ghostty --args -e {command}`); unset disables server-side terminal opening + #[serde(default)] + pub terminal_command: Option, +} + /// Tmux-specific session configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] diff --git a/src/config/targets.rs b/src/config/targets.rs index 096cb303..9b052b40 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -544,6 +544,7 @@ create_timeout_secs = 600 fn test_validate_targets_unknown_delegator_reference_error() { let mut config = config_with_targets(vec![]); config.delegators.push(crate::config::Delegator { + governance: None, git: None, name: "heavy".to_string(), llm_tool: "claude".to_string(), @@ -589,6 +590,7 @@ create_timeout_secs = 600 }); for name in ["local", "docker", "gpu-vm", "legacy-host"] { config.delegators = vec![crate::config::Delegator { + governance: None, git: None, name: "d".to_string(), llm_tool: "claude".to_string(), diff --git a/src/docs_gen/taxonomy.rs b/src/docs_gen/taxonomy.rs index 714a04b5..a1301454 100644 --- a/src/docs_gen/taxonomy.rs +++ b/src/docs_gen/taxonomy.rs @@ -46,7 +46,7 @@ impl DocGenerator for TaxonomyDocGenerator { output.push_str(&format!( "- {}: {}\n\n", bold("Description"), - &taxonomy.meta.description + taxonomy.meta.description )); // Quick reference table diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index d3256837..6bfac838 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -57,11 +57,12 @@ pub enum Vertical { Transport, AgentRelay, RemoteTargets, + Feature, } impl Vertical { /// All verticals, in README display order. - pub const ALL: [Vertical; 13] = [ + pub const ALL: [Vertical; 14] = [ Vertical::Kanban, Vertical::Model, Vertical::Git, @@ -75,6 +76,7 @@ impl Vertical { Vertical::Transport, Vertical::AgentRelay, Vertical::RemoteTargets, + Vertical::Feature, ]; /// Stable lowercase slug (wire id for the REST DTO). @@ -93,6 +95,7 @@ impl Vertical { Vertical::Transport => "transport", Vertical::AgentRelay => "agent-relay", Vertical::RemoteTargets => "remote-targets", + Vertical::Feature => "feature", } } @@ -112,6 +115,7 @@ impl Vertical { Vertical::Transport => "Execution Transport", Vertical::AgentRelay => "Agent Relay", Vertical::RemoteTargets => "Remote Targets", + Vertical::Feature => "Operator Feature", } } @@ -132,6 +136,7 @@ impl Vertical { Vertical::Transport => "getting-started/transports", Vertical::AgentRelay => "getting-started/agent-relays", Vertical::RemoteTargets => "getting-started/remote-targets", + Vertical::Feature => "getting-started/concepts", } } } @@ -188,8 +193,8 @@ impl CatalogEntry { pub fn all_integrations() -> Vec { use SupportStatus::{Alpha, Beta, Ga, Proto}; use Vertical::{ - AgentRelay, Editor, Git, Integration, Kanban, LlmTool, Model, Notification, Platform, - RemoteTargets, Session, Transport, Workflows, + AgentRelay, Editor, Feature, Git, Integration, Kanban, LlmTool, Model, Notification, + Platform, RemoteTargets, Session, Transport, Workflows, }; vec![ // --- Kanban providers (mirror KanbanProviderType::ALL) --- @@ -552,6 +557,26 @@ pub fn all_integrations() -> Vec { true, Beta, ), + // --- Operator features (concepts, not brands) --- + entry( + Feature, + "governance", + "Governance", + Some("getting-started/concepts/governance"), + None, + false, + Alpha, + ), + entry( + Feature, + "campaigns", + "Campaigns", + Some("getting-started/concepts/campaigns"), + None, + false, + Alpha, + ) + .premium(), ] } @@ -645,6 +670,14 @@ mod tests { assert!(!entry_for(Vertical::Editor, "cursor").unwrap().is_public()); } + #[test] + fn campaigns_are_premium_and_governance_is_free() { + assert!(entry_for(Vertical::Feature, "campaigns").unwrap().premium); + let governance = entry_for(Vertical::Feature, "governance").unwrap(); + assert!(!governance.premium); + assert!(governance.is_public()); + } + #[test] fn ide_controllers_are_not_interchangeable() { assert!(validate_ide_session("vscode", SessionWrapperType::Vscode).is_ok()); diff --git a/src/integrations/inventory.rs b/src/integrations/inventory.rs index ca410907..da0863bc 100644 --- a/src/integrations/inventory.rs +++ b/src/integrations/inventory.rs @@ -101,6 +101,48 @@ pub fn all_capabilities() -> Vec { rest_endpoint: Some("POST /api/v1/agents/:agent_id/reject"), tui_action: Some("Reject review"), }, + Capability { + name: "Preview Ticket Batch", + slash_command: None, + mcp_tool: Some("operator_preview_ticket_batch"), + rest_endpoint: Some("POST /api/v1/tickets/batch/preview"), + tui_action: None, + }, + Capability { + name: "Create Tickets in Bulk", + slash_command: None, + mcp_tool: Some("operator_create_tickets_bulk"), + rest_endpoint: Some("POST /api/v1/tickets/batch"), + tui_action: None, + }, + Capability { + name: "Create Campaign", + slash_command: None, + mcp_tool: Some("operator_create_campaign"), + rest_endpoint: Some("POST /api/v1/campaigns"), + tui_action: None, + }, + Capability { + name: "Assign Ticket to Campaign", + slash_command: None, + mcp_tool: Some("operator_assign_ticket_campaign"), + rest_endpoint: Some("PUT /api/v1/tickets/:id/campaign"), + tui_action: None, + }, + Capability { + name: "Launch Campaign", + slash_command: None, + mcp_tool: Some("operator_launch_campaign"), + rest_endpoint: Some("POST /api/v1/campaigns/:id/launch"), + tui_action: None, + }, + Capability { + name: "Campaign Status", + slash_command: None, + mcp_tool: Some("operator_campaign_status"), + rest_endpoint: Some("GET /api/v1/campaigns/:id/status"), + tui_action: None, + }, Capability { name: "Setup Agent", slash_command: Some("op-setup-agent"), @@ -160,6 +202,6 @@ mod tests { #[test] fn test_capability_count() { let caps = all_capabilities(); - assert_eq!(caps.len(), 14, "Expected 14 capabilities in the inventory"); + assert_eq!(caps.len(), 20, "Expected 20 capabilities in the inventory"); } } diff --git a/src/integrations/support_catalog.rs b/src/integrations/support_catalog.rs index 52539af9..531d084e 100644 --- a/src/integrations/support_catalog.rs +++ b/src/integrations/support_catalog.rs @@ -245,6 +245,7 @@ pub enum VerticalSupport { Transport(RemoteTargetSupport), AgentRelay(SparseSupport), RemoteTargets(RemoteTargetSupport), + Feature(SparseSupport), } /// LLM tool × model server protocol relationship. @@ -340,6 +341,7 @@ pub fn support_for(vertical: Vertical, slug: &str) -> VerticalSupport { (Vertical::Integration, _) => VerticalSupport::Integration(sparse_partial(None)), (Vertical::Workflows, _) => VerticalSupport::Workflows(sparse_partial(None)), (Vertical::Notification, _) => VerticalSupport::Notification(sparse_partial(None)), + (Vertical::Feature, _) => VerticalSupport::Feature(sparse_partial(None)), (Vertical::AgentRelay, _) => VerticalSupport::AgentRelay(sparse_partial(Some( "Relay MCP injection is Claude-first; Codex is partial.", ))), diff --git a/src/lib.rs b/src/lib.rs index 816d115e..8f1df761 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod agents; pub mod api; pub mod auth; +pub mod campaigns; pub mod collections; pub mod config; pub mod editors; diff --git a/src/licensing.rs b/src/licensing.rs index 7ed660a2..271c62b5 100644 --- a/src/licensing.rs +++ b/src/licensing.rs @@ -23,12 +23,14 @@ static LICENSE_UPDATE: Mutex<()> = Mutex::new(()); #[serde(rename_all = "snake_case")] pub enum PremiumFeature { RemoteTargets, + Campaigns, } impl std::fmt::Display for PremiumFeature { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::RemoteTargets => f.write_str("remote_targets"), + Self::Campaigns => f.write_str("campaigns"), } } } @@ -228,7 +230,7 @@ pub struct Entitlements { impl Entitlements { pub fn allows(&self, feature: PremiumFeature) -> bool { match feature { - PremiumFeature::RemoteTargets => self.premium, + PremiumFeature::RemoteTargets | PremiumFeature::Campaigns => self.premium, } } } @@ -574,6 +576,23 @@ mod tests { crate::trust_verify::Envelope::new(license, fixture.attestation.clone()).encode() } + #[test] + fn premium_features_follow_the_premium_flag() { + for feature in [PremiumFeature::RemoteTargets, PremiumFeature::Campaigns] { + let free = Entitlements { + status: LicenseStatus::Missing, + premium: false, + }; + let premium = Entitlements { + status: LicenseStatus::Valid, + premium: true, + }; + assert!(!free.allows(feature)); + assert!(premium.allows(feature)); + } + assert_eq!(PremiumFeature::Campaigns.to_string(), "campaigns"); + } + #[test] fn verifies_signature_identity_and_time_boundaries() { let fixture = mint(); diff --git a/src/main.rs b/src/main.rs index 66939e27..f3191c20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; mod api; mod app; mod auth; +mod campaigns; mod collections; mod config; mod editors; @@ -231,6 +232,11 @@ enum Commands { /// Project/change reference (e.g. an `OpenSpec` change id, a Jira project key). /// Omit to sync all of the provider's configured collections. reference: Option, + + /// Import an `OpenSpec` change as one campaign (Premium). Other providers set + /// `scope` on the project's sync config instead. + #[arg(long)] + campaign: bool, }, /// Create a new ticket from template @@ -321,6 +327,28 @@ enum Commands { #[command(subcommand)] action: AuthAction, }, + + /// Inspect campaigns: epics of tickets planned into dependency waves + Campaign { + #[command(subcommand)] + action: CampaignAction, + }, +} + +#[derive(Subcommand)] +enum CampaignAction { + /// List campaigns with their status and member count + List, + /// Show a campaign's plan: waves, blockers, and what is ready to launch + Show { + /// Campaign id (e.g. CAMP-0001) + id: String, + }, + /// Start a campaign (Premium): launch its first wave; `operator api` drives the rest + Launch { + /// Campaign id (e.g. CAMP-0001) + id: String, + }, } impl Commands { @@ -495,8 +523,9 @@ async fn main() -> Result<()> { Some(Commands::Import { provider, reference, + campaign, }) => { - cmd_import(&config, provider, reference).await?; + cmd_import(&config, provider, reference, campaign).await?; } Some(Commands::Create { template, project }) => { cmd_create(&config, template, project).await?; @@ -539,6 +568,9 @@ async fn main() -> Result<()> { Some(Commands::Workflow { action }) => { cmd_workflow(&config, action)?; } + Some(Commands::Campaign { action }) => { + cmd_campaign(&config, action).await?; + } None => { // No subcommand = launch TUI dashboard #[allow(clippy::large_futures)] // TUI state is inherently large @@ -822,14 +854,12 @@ async fn cmd_alert( // Send notification let notification_service = notifications::NotificationService::from_config(config)?; - notification_service - .notify(notifications::NotificationEvent::InvestigationCreated { - source, - severity, - summary: ticket.summary.clone(), - ticket_id: ticket.id.clone(), - }) - .await; + notification_service.notify(notifications::NotificationEvent::InvestigationCreated { + source, + severity, + summary: ticket.summary.clone(), + ticket_id: ticket.id, + }); Ok(()) } @@ -838,11 +868,25 @@ async fn cmd_import( config: &Config, provider: Option, reference: Option, + campaign: bool, ) -> Result<()> { use services::kanban_sync::KanbanSyncService; let service = KanbanSyncService::new(config); + if campaign { + let (Some("openspec"), Some(change)) = (provider.as_deref(), reference.as_deref()) else { + anyhow::bail!( + "--campaign takes `openspec `; for jira, linear and github set `scope` on the project's sync config" + ); + }; + let scope = config::SyncScope::OpenspecChange(change.to_string()); + let result = service.sync_scope("openspec", change, &scope).await?; + println!("openspec/{change}: {}", result.summary()); + print_sync_details(&result); + return Ok(()); + } + let provider = match provider { None => { let result = service.sync_all().await?; @@ -889,9 +933,15 @@ async fn cmd_import( } fn print_sync_details(result: &services::kanban_sync::SyncResult) { + if let Some(campaign) = &result.campaign { + println!(" campaign {campaign}"); + } for key in &result.created { println!(" created {key}"); } + for key in &result.updated { + println!(" updated {key}"); + } for error in &result.errors { eprintln!(" error {error}"); } @@ -1028,6 +1078,79 @@ async fn cmd_auth(config: &Config, action: AuthAction) -> Result<()> { } } +async fn cmd_campaign(config: &Config, action: CampaignAction) -> Result<()> { + let store = campaigns::CampaignStore::new(config); + let queue = queue::Queue::new(config)?; + let mut tickets = queue.list_queue()?; + tickets.extend(queue.list_in_progress()?); + tickets.extend(queue.list_completed()?); + + match action { + CampaignAction::List => { + for campaign in store.list() { + let count = campaigns::members(&campaign.id, &tickets).len(); + println!( + "{} {:?} {} tickets {}", + campaign.id, campaign.status, count, campaign.title + ); + } + } + CampaignAction::Show { id } => { + let campaign = store + .get(&id) + .ok_or_else(|| anyhow::anyhow!("Campaign {id} not found"))?; + let members = campaigns::members(&campaign.id, &tickets); + println!( + "{}: {} ({:?})", + campaign.id, campaign.title, campaign.status + ); + if let Some(external_ref) = &campaign.external_ref { + println!(" synced from {external_ref}"); + } + match campaigns::workspace::resolve_workspace(config, &campaign) { + Some(shared) => println!( + " shared workspace on target '{}' (key {})", + shared.target.name, shared.workspace_key + ), + None => println!(" each ticket in its own workspace"), + } + let plan = campaigns::plan::plan(config, &members, &tickets)?; + for (n, wave) in plan.waves.iter().enumerate() { + println!(" wave {}: {}", n + 1, wave.join(", ")); + } + for (ticket, gates) in &plan.gates { + println!(" {ticket} waits on {}", gates.join(", ")); + } + for (ticket, refs) in &plan.unsynced { + println!(" {ticket} blocked by unsynced {}", refs.join(", ")); + } + println!( + " ready: {}", + campaigns::plan::ready(&plan, &tickets).join(", ") + ); + } + CampaignAction::Launch { id } => { + let port = services::campaign_runner::LauncherPort::new(config.clone()); + services::campaign_runner::start_campaign(config, &port, &id)?; + let report = services::campaign_runner::tick(config, &port, &id).await?; + println!("{id}: {:?}", report.status); + println!(" launched: {}", report.launched.join(", ")); + println!( + " awaiting operator: {}", + report.awaiting_operator.join(", ") + ); + println!(" stalled: {}", report.stalled.join(", ")); + for (ticket, reason) in &report.blocked { + println!(" {ticket} blocked: {reason:?}"); + } + if let Some(reason) = &report.pause_reason { + println!(" paused: {reason}"); + } + } + } + Ok(()) +} + fn cmd_workflow(config: &Config, action: WorkflowAction) -> Result<()> { match action { WorkflowAction::Export { @@ -1160,6 +1283,8 @@ async fn cmd_api(config: &Config, port: Option, open: bool) -> Result<()> { } let state = rest::ApiState::new(config.clone(), config.tickets_path()); + let runner_state = state.clone(); + services::campaign_runner::spawn_campaign_runner(move || runner_state.config()); rest::serve(state, port).await?; Ok(()) diff --git a/src/mcp/campaigns.rs b/src/mcp/campaigns.rs new file mode 100644 index 00000000..cff22300 --- /dev/null +++ b/src/mcp/campaigns.rs @@ -0,0 +1,127 @@ +//! MCP tools for ticket batches and campaigns; each delegates to its REST handler. + +use axum::extract::{Path, State}; +use axum::Json; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::tools::McpToolDefinition; +use crate::campaigns::batch::BatchRequest; +use crate::campaigns::service::NewCampaignInput; +use crate::rest::dto::{AssignCampaignRequest, LaunchCampaignRequest}; +use crate::rest::error::ApiError; +use crate::rest::routes::campaigns as routes; +use crate::rest::state::ApiState; + +const WORKFLOW: &str = "Workflow: operator_list_issue_types -> operator_preview_ticket_batch -> operator_create_campaign -> operator_create_tickets_bulk (with campaign) -> operator_campaign_status -> operator_launch_campaign with dry_run, then for real."; + +fn schema() -> Value { + serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| json!({"type": "object"})) +} + +fn with_id(mut body: Value, id_description: &str) -> Value { + if let Some(props) = body.get_mut("properties").and_then(Value::as_object_mut) { + props.insert( + "id".into(), + json!({"type": "string", "description": id_description}), + ); + } + let required = body + .get_mut("required") + .and_then(Value::as_array_mut) + .map(std::mem::take) + .unwrap_or_default(); + body["required"] = Value::Array(std::iter::once(json!("id")).chain(required).collect()); + body +} + +pub fn definitions() -> Vec { + vec![ + McpToolDefinition { + name: "operator_preview_ticket_batch".into(), + description: format!("Preview a batch of related tickets without writing: allocated ids, dependencies resolved (depends_on takes batch refs, existing ids or provider:key refs), dependency waves, and per-item and cycle errors. Free. {WORKFLOW}"), + input_schema: schema::(), + }, + McpToolDefinition { + name: "operator_create_tickets_bulk".into(), + description: format!("Create every ticket in a batch or none: validates first, then writes, rolling back on failure. Batch refs resolve to allocated ids. Set campaign to create inside one. Free. Disabled unless [mcp].expose_ticket_write_tools = true. {WORKFLOW}"), + input_schema: schema::(), + }, + McpToolDefinition { + name: "operator_create_campaign".into(), + description: format!("Create a draft campaign (Premium). {WORKFLOW}"), + input_schema: schema::(), + }, + McpToolDefinition { + name: "operator_assign_ticket_campaign".into(), + description: "Move a ticket into a campaign (or out, with campaign null) and optionally replace its depends_on; an edit that creates a cycle is rejected (Premium).".into(), + input_schema: with_id(schema::(), "Ticket id (e.g. FEAT-0012)"), + }, + McpToolDefinition { + name: "operator_launch_campaign".into(), + description: "Launch a campaign (Premium). dry_run returns the waves, effective governance and violations without starting. A real launch starts the run and launches the first wave; `operator api` drives later waves.".into(), + input_schema: with_id(schema::(), "Campaign id (e.g. CAMP-0001)"), + }, + McpToolDefinition { + name: "operator_campaign_status".into(), + description: "Campaign run status (Premium): per-ticket column and launches, waves, blocked reasons, tickets awaiting the operator, stalled tickets, and counters against governance caps.".into(), + input_schema: json!({ + "type": "object", + "properties": {"id": {"type": "string", "description": "Campaign id (e.g. CAMP-0001)"}}, + "required": ["id"] + }), + }, + ] +} + +fn mcp_error(error: ApiError) -> String { + let (_, code, message) = error.parts(); + format!("{code}: {message}") +} + +fn parse(args: &Value) -> Result { + serde_json::from_value(args.clone()).map_err(|e| format!("invalid arguments: {e}")) +} + +fn id(args: &Value) -> Result { + args.get("id") + .and_then(Value::as_str) + .map(String::from) + .ok_or_else(|| "Missing required parameter: id".to_string()) +} + +fn reply(result: Result, ApiError>) -> Result { + match result { + Ok(Json(body)) => serde_json::to_value(body).map_err(|e| e.to_string()), + Err(e) => Err(mcp_error(e)), + } +} + +pub async fn preview(args: Value, state: &ApiState) -> Result { + reply(routes::batch_preview(State(state.clone()), Json(parse(&args)?)).await) +} + +pub async fn create_bulk(args: Value, state: &ApiState) -> Result { + reply(routes::batch_create(State(state.clone()), Json(parse(&args)?)).await) +} + +pub async fn create_campaign(args: Value, state: &ApiState) -> Result { + reply(routes::create(State(state.clone()), Json(parse(&args)?)).await) +} + +pub async fn assign(args: Value, state: &ApiState) -> Result { + let ticket = id(&args)?; + let request: AssignCampaignRequest = parse(&args)?; + reply(routes::assign(State(state.clone()), Path(ticket), Json(request)).await) +} + +pub async fn launch(args: Value, state: &ApiState) -> Result { + let campaign = id(&args)?; + let request: LaunchCampaignRequest = parse(&args)?; + reply(routes::launch(State(state.clone()), Path(campaign), Json(request)).await) +} + +pub async fn status(args: Value, state: &ApiState) -> Result { + reply(routes::status(State(state.clone()), Path(id(&args)?)).await) +} diff --git a/src/mcp/handler.rs b/src/mcp/handler.rs index ba7fa7b7..faefe681 100644 --- a/src/mcp/handler.rs +++ b/src/mcp/handler.rs @@ -232,7 +232,7 @@ mod tests { assert!(response.error.is_none()); let result = response.result.unwrap(); let tools_arr = result["tools"].as_array().unwrap(); - assert_eq!(tools_arr.len(), 18); + assert_eq!(tools_arr.len(), 24); let first = &tools_arr[0]; assert!(first.get("name").is_some()); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 0e558caf..ee01fcb4 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -4,6 +4,7 @@ //! read-only MCP tools. Includes a descriptor endpoint for client discovery, //! tool definitions, and an SSE transport for JSON-RPC communication. +pub mod campaigns; pub mod client_configs; pub mod descriptor; pub mod handler; @@ -28,7 +29,10 @@ pub struct Host(pub String); impl FromRequestParts for Host { type Rejection = std::convert::Infallible; - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + fn from_request_parts( + parts: &mut Parts, + _state: &S, + ) -> impl std::future::Future> { let host = parts .headers .get(axum::http::header::HOST) @@ -36,7 +40,7 @@ impl FromRequestParts for Host { .map(str::to_string) .or_else(|| parts.uri.authority().map(ToString::to_string)) .unwrap_or_default(); - Ok(Host(host)) + std::future::ready(Ok(Host(host))) } } diff --git a/src/mcp/tickets.rs b/src/mcp/tickets.rs index 8f5ddbcc..3da7ca44 100644 --- a/src/mcp/tickets.rs +++ b/src/mcp/tickets.rs @@ -103,7 +103,6 @@ pub async fn return_to_queue(args: Value, state: &ApiState) -> Result Result { - use crate::queue::creator::TicketCreator; use crate::templates::TemplateType; use std::collections::HashMap; @@ -123,19 +122,18 @@ pub async fn create_ticket(args: Value, state: &ApiState) -> Result 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())??; + let (ticket, path) = + crate::rest::routes::tickets::create_ticket_from_values(state, template_type, values) + .await + .map_err(|e| { + let (_, code, message) = e.parts(); + format!("{code}: {message}") + })?; Ok(json!({ + "id": ticket.id, "path": path.to_string_lossy(), - "filename": path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + "filename": ticket.filename, })) } @@ -254,6 +252,7 @@ mod tests { ) .await .unwrap(); + assert_eq!(result["id"], "FEAT-0001"); let filename = result["filename"].as_str().unwrap(); assert!( filename.contains("FEAT-demo"), diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 7ec294b9..b23f4f2d 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -25,7 +25,7 @@ pub struct McpToolDefinition { /// Returns all available MCP tool definitions pub fn all_tool_definitions() -> Vec { - vec![ + let mut tools = vec![ McpToolDefinition { name: "operator_health".to_string(), description: "Check Operator API health status".to_string(), @@ -249,7 +249,9 @@ pub fn all_tool_definitions() -> Vec { "required": ["id", "reason"] }), }, - ] + ]; + tools.extend(crate::mcp::campaigns::definitions()); + tools } /// Gate the ticket-mutating tools. @@ -360,6 +362,24 @@ pub async fn execute_tool( require_write_tools(state, scopes)?; crate::mcp::tickets::create_ticket(args, state).await } + "operator_preview_ticket_batch" => crate::mcp::campaigns::preview(args, state).await, + "operator_create_tickets_bulk" => { + require_write_tools(state, scopes)?; + crate::mcp::campaigns::create_bulk(args, state).await + } + "operator_create_campaign" => { + require_write_tools(state, scopes)?; + crate::mcp::campaigns::create_campaign(args, state).await + } + "operator_assign_ticket_campaign" => { + require_write_tools(state, scopes)?; + crate::mcp::campaigns::assign(args, state).await + } + "operator_launch_campaign" => { + require_write_tools(state, scopes)?; + crate::mcp::campaigns::launch(args, state).await + } + "operator_campaign_status" => crate::mcp::campaigns::status(args, state).await, "operator_launch_ticket" => { require_write_tools(state, scopes)?; let id = args @@ -490,7 +510,7 @@ mod tests { #[test] fn test_all_tool_definitions_count() { let tools = all_tool_definitions(); - assert_eq!(tools.len(), 18); + assert_eq!(tools.len(), 24); } #[test] @@ -512,6 +532,16 @@ mod tests { assert!(names.contains(&"operator_sync_kanban")); assert!(names.contains(&"operator_approve_agent")); assert!(names.contains(&"operator_reject_agent")); + for tool in [ + "operator_preview_ticket_batch", + "operator_create_tickets_bulk", + "operator_create_campaign", + "operator_assign_ticket_campaign", + "operator_launch_campaign", + "operator_campaign_status", + ] { + assert!(names.contains(&tool), "{tool}"); + } } #[test] diff --git a/src/notifications/service.rs b/src/notifications/service.rs index c43c7041..eeec85dd 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -86,7 +86,7 @@ impl NotificationService { /// /// This is fire-and-forget - each integration is spawned as a separate task /// and errors are logged but not propagated. - pub async fn notify(&self, event: NotificationEvent) { + pub fn notify(&self, event: NotificationEvent) { if !self.enabled { return; } @@ -330,7 +330,7 @@ mod tests { launch_mode: None, }; - service.notify(event).await; + service.notify(event); // Give spawned tasks time to complete tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; @@ -361,7 +361,7 @@ mod tests { launch_mode: None, }; - service.notify(event).await; + service.notify(event); tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; assert_eq!(count.load(Ordering::SeqCst), 0); @@ -389,7 +389,7 @@ mod tests { launch_mode: None, }; - service.notify(event).await; + service.notify(event); tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; assert_eq!(count.load(Ordering::SeqCst), 0); diff --git a/src/queue/creator.rs b/src/queue/creator.rs index 06952cee..a6d463d1 100644 --- a/src/queue/creator.rs +++ b/src/queue/creator.rs @@ -8,13 +8,14 @@ use std::fs; use std::path::PathBuf; use std::process::Command; +use super::ids; use crate::config::Config; use crate::templates::schema::TemplateSchema; use crate::templates::TemplateType; /// Creates new tickets from templates pub struct TicketCreator { - queue_path: PathBuf, + tickets_root: PathBuf, /// Active issuetype collection, stamped into created tickets' frontmatter collection: Option, } @@ -24,11 +25,35 @@ impl TicketCreator { pub fn new(config: &Config) -> Self { let tickets_path = config.tickets_path(); Self { - queue_path: tickets_path.join("queue"), + tickets_root: tickets_path, collection: config.templates.active_collection.clone(), } } + fn queue_path(&self) -> PathBuf { + self.tickets_root.join("queue") + } + + /// Serializes id allocation and writes across single and batch creation. + pub fn creation_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Ids of every ticket in queue, in-progress and completed. + fn existing_ids(&self) -> Vec { + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|dir| fs::read_dir(self.tickets_root.join(dir)).ok()) + .flatten() + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "md")) + .filter_map(|path| super::Ticket::from_file(&path).ok()) + .map(|ticket| ticket.id) + .collect() + } + /// 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( @@ -46,14 +71,23 @@ impl TicketCreator { .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 slug = values + .get("summary") + .map(|summary| ids::slugify(summary, 50)) + .filter(|slug| !slug.is_empty()) + .or_else(|| values.get("id").map(|id| ids::slugify(id, 50))) + .unwrap_or_else(|| "new-ticket".to_string()); + let queue_path = self.queue_path(); + let filepath = ids::unique_path( + &queue_path, + &format!("{timestamp}-{type_str}-{project}-{slug}"), + ); let template = template_type.template_content(); let content = render_template(template, values)?; let content = stamp_collection(&content, self.collection.as_deref()); - fs::create_dir_all(&self.queue_path).context("Failed to create queue directory")?; + fs::create_dir_all(&queue_path).context("Failed to create queue directory")?; fs::write(&filepath, &content).context("Failed to write ticket file")?; Ok(filepath) @@ -96,12 +130,13 @@ impl TicketCreator { let now = Utc::now(); let date = now.format("%Y-%m-%d").to_string(); let datetime = now.format("%Y-%m-%d %H:%M").to_string(); - let id = format!("{:04}", now.timestamp() % 10000); let type_str = template_type.as_str(); + let existing = self.existing_ids(); + let id = ids::next_ticket_id(type_str, existing.iter().map(String::as_str)); let branch_prefix = type_str.to_lowercase(); let mut values = HashMap::new(); - values.insert("id".to_string(), format!("{type_str}-{id}")); + values.insert("id".to_string(), id.clone()); values.insert("created".to_string(), date.clone()); values.insert("created_date".to_string(), date); values.insert("created_datetime".to_string(), datetime); @@ -109,7 +144,7 @@ impl TicketCreator { values.insert("project".to_string(), project.to_string()); values.insert( "branch".to_string(), - format!("{branch_prefix}/{type_str}-{id}-short-description"), + format!("{branch_prefix}/{id}-short-description"), ); values.insert("step".to_string(), template_type.first_step().to_string()); @@ -237,7 +272,7 @@ mod tests { fn test_create_ticket_headless_stamps_collection() { let temp_dir = tempfile::tempdir().unwrap(); let creator = TicketCreator { - queue_path: temp_dir.path().join("queue"), + tickets_root: temp_dir.path().to_path_buf(), collection: Some("dev_kanban".to_string()), }; let mut values = HashMap::new(); @@ -286,20 +321,64 @@ mod tests { #[test] fn test_generate_default_values() { + let temp_dir = tempfile::tempdir().unwrap(); let creator = TicketCreator { - queue_path: PathBuf::from("/tmp"), + tickets_root: temp_dir.path().to_path_buf(), collection: None, }; let values = creator.generate_default_values(TemplateType::Feature, "myproject"); - assert!(values.get("id").unwrap().starts_with("FEAT-")); + assert_eq!(values.get("id").unwrap(), "FEAT-0001"); assert_eq!(values.get("project").unwrap(), "myproject"); assert_eq!(values.get("status").unwrap(), "queued"); assert!(values.get("branch").unwrap().starts_with("feat/FEAT-")); assert!(values.contains_key("created")); } + #[test] + fn test_bulk_headless_creates_never_collide() { + let temp_dir = tempfile::tempdir().unwrap(); + let creator = TicketCreator { + tickets_root: temp_dir.path().to_path_buf(), + collection: None, + }; + for _ in 0..50 { + let mut values = creator.generate_default_values(TemplateType::Task, "demo"); + values.insert("summary".to_string(), "same summary".to_string()); + creator + .create_ticket_headless(TemplateType::Task, &values) + .unwrap(); + } + let mut ids = creator.existing_ids(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 50); + assert_eq!(ids.last().map(String::as_str), Some("TASK-0050")); + assert_eq!( + fs::read_dir(temp_dir.path().join("queue")).unwrap().count(), + 50 + ); + } + + #[test] + fn test_ids_continue_across_in_progress_and_completed() { + let temp_dir = tempfile::tempdir().unwrap(); + let done = temp_dir.path().join("completed"); + fs::create_dir_all(&done).unwrap(); + fs::write( + done.join("20260101-1200-FEAT-demo-old.md"), + "---\nid: FEAT-0041\nstatus: completed\n---\n\n# Feature: old\n", + ) + .unwrap(); + let creator = TicketCreator { + tickets_root: temp_dir.path().to_path_buf(), + collection: None, + }; + let values = creator.generate_default_values(TemplateType::Feature, "demo"); + assert_eq!(values.get("id").unwrap(), "FEAT-0042"); + } + #[test] fn test_parse_and_sort_schema() { let json = r#"{ diff --git a/src/queue/deps.rs b/src/queue/deps.rs new file mode 100644 index 00000000..c9af02af --- /dev/null +++ b/src/queue/deps.rs @@ -0,0 +1,106 @@ +//! Resolve `depends_on` references to local tickets. + +use super::Ticket; + +/// Providers whose issues can be referenced as `provider:key`. +pub const PROVIDERS: [&str; 4] = ["jira", "linear", "github", "openspec"]; + +/// Where a dependency reference points. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Resolved { + /// A local ticket, by id. + Local(String), + /// Nothing local matches yet; the ticket stays blocked on it. + Unsynced(String), +} + +/// Split `provider:key`; local ids return `None`. +pub fn provider_ref(r: &str) -> Option<(&str, &str)> { + let (provider, key) = r.split_once(':')?; + PROVIDERS.contains(&provider).then_some((provider, key)) +} + +/// Resolve one reference against known tickets (queue, in-progress and completed). +pub fn resolve(r: &str, tickets: &[Ticket]) -> Resolved { + let found = match provider_ref(r) { + Some((provider, key)) => tickets.iter().find(|t| { + t.external_id.as_deref() == Some(key) + && t.external_provider.as_deref().is_none_or(|p| p == provider) + }), + None => tickets.iter().find(|t| t.id == r), + }; + found.map_or_else( + || Resolved::Unsynced(r.to_string()), + |t| Resolved::Local(t.id.clone()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn ticket(dir: &Path, name: &str, frontmatter: &str) -> Ticket { + let path = dir.join(name); + std::fs::write(&path, format!("---\n{frontmatter}\n---\n\n# Task: t\n")).unwrap(); + Ticket::from_file(&path).unwrap() + } + + fn tickets(dir: &Path) -> Vec { + vec![ + ticket( + dir, + "20260101-1200-FEAT-demo-a.md", + "id: FEAT-0003\nstatus: queued", + ), + ticket( + dir, + "20260101-1200-FIX-demo-b.md", + "id: FIX-PROJ12\nstatus: completed\nexternal_id: PROJ-12\nexternal_provider: jira", + ), + ] + } + + #[test] + fn test_provider_ref_only_matches_known_providers() { + assert_eq!(provider_ref("jira:PROJ-12"), Some(("jira", "PROJ-12"))); + assert_eq!( + provider_ref("github:octo/hello#9"), + Some(("github", "octo/hello#9")) + ); + assert_eq!(provider_ref("FEAT-0003"), None); + assert_eq!(provider_ref("http://example.com"), None); + } + + #[test] + fn test_resolve_local_id() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + resolve("FEAT-0003", &tickets(dir.path())), + Resolved::Local("FEAT-0003".into()) + ); + } + + #[test] + fn test_resolve_provider_ref_ignores_local_type_prefix() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + resolve("jira:PROJ-12", &tickets(dir.path())), + Resolved::Local("FIX-PROJ12".into()) + ); + } + + #[test] + fn test_resolve_wrong_provider_or_missing_is_unsynced() { + let dir = tempfile::tempdir().unwrap(); + let all = tickets(dir.path()); + assert_eq!( + resolve("linear:PROJ-12", &all), + Resolved::Unsynced("linear:PROJ-12".into()) + ); + assert_eq!( + resolve("FEAT-9999", &all), + Resolved::Unsynced("FEAT-9999".into()) + ); + } +} diff --git a/src/queue/ids.rs b/src/queue/ids.rs new file mode 100644 index 00000000..a8e89730 --- /dev/null +++ b/src/queue/ids.rs @@ -0,0 +1,98 @@ +//! Ticket id allocation and filename slugs. + +use std::path::{Path, PathBuf}; + +/// Next sequential id for `type_key`: one past the highest `TYPE-NNNN` in `existing`. +pub fn next_ticket_id<'a>(type_key: &str, existing: impl IntoIterator) -> String { + let prefix = format!("{type_key}-"); + let max = existing + .into_iter() + .filter_map(|id| id.strip_prefix(&prefix)?.parse::().ok()) + .max() + .unwrap_or(0); + format!("{type_key}-{:04}", max + 1) +} + +/// Lowercase ASCII slug of `s`, dash-separated, cut at a word boundary within `max_len`. +pub fn slugify(s: &str, max_len: usize) -> String { + let slug = s + .to_ascii_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|w| !w.is_empty()) + .collect::>() + .join("-"); + if slug.len() <= max_len { + return slug; + } + let truncated = &slug[..max_len]; + truncated + .rfind('-') + .map_or(truncated, |dash| &truncated[..dash]) + .to_string() +} + +/// First path `{stem}.md`, `{stem}-2.md`, ... that does not exist in `dir`. +pub fn unique_path(dir: &Path, stem: &str) -> PathBuf { + (1..=u32::MAX) + .map(|n| match n { + 1 => dir.join(format!("{stem}.md")), + n => dir.join(format!("{stem}-{n}.md")), + }) + .find(|p| !p.exists()) + .unwrap_or_else(|| dir.join(format!("{stem}.md"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_next_ticket_id_starts_at_one() { + assert_eq!(next_ticket_id("FEAT", []), "FEAT-0001"); + } + + #[test] + fn test_next_ticket_id_continues_past_max_and_ignores_other_types() { + let existing = [ + "FEAT-0007", + "FEAT-0002", + "FIX-0099", + "FEAT-PROJ12", + "FEATURE-0050", + ]; + assert_eq!(next_ticket_id("FEAT", existing), "FEAT-0008"); + } + + #[test] + fn test_next_ticket_id_grows_past_four_digits() { + assert_eq!(next_ticket_id("TASK", ["TASK-9999"]), "TASK-10000"); + } + + #[test] + fn test_slugify() { + assert_eq!(slugify("Hello World", 50), "hello-world"); + assert_eq!(slugify("Fix: Login Bug!", 50), "fix-login-bug"); + assert_eq!(slugify(" Multiple Spaces ", 50), "multiple-spaces"); + } + + #[test] + fn test_slugify_truncates_at_word_boundary() { + let slug = slugify("This is a very long string that should be truncated", 30); + assert!(slug.len() <= 30); + assert!(!slug.ends_with('-')); + } + + #[test] + fn test_slugify_non_ascii_never_panics() { + assert_eq!(slugify("Ünïcödé façade 日本語 fix", 12), "n-c-d-fa"); + } + + #[test] + fn test_unique_path_suffixes_existing_names() { + let dir = tempfile::tempdir().unwrap(); + let first = unique_path(dir.path(), "a"); + std::fs::write(&first, "").unwrap(); + let second = unique_path(dir.path(), "a"); + assert!(second.ends_with("a-2.md")); + } +} diff --git a/src/queue/mod.rs b/src/queue/mod.rs index 6889a5c1..34ff975e 100644 --- a/src/queue/mod.rs +++ b/src/queue/mod.rs @@ -2,6 +2,8 @@ #![allow(unused_imports)] pub mod creator; +pub mod deps; +pub mod ids; mod ticket; mod watcher; @@ -37,6 +39,15 @@ impl TicketColumn { } } +/// Launch order: issuetype rank, then the ticket's own priority, then FIFO. +pub fn queue_order_key(config: &Config, ticket: &Ticket) -> (usize, TicketPriority, String) { + ( + config.priority_index(&ticket.ticket_type), + ticket.priority_level(), + ticket.timestamp.clone(), + ) +} + pub struct Queue { config: Config, queue_path: PathBuf, @@ -63,15 +74,7 @@ impl Queue { pub fn list_by_priority(&self) -> Result> { let mut tickets = self.list_queue()?; - tickets.sort_by(|a, b| { - let priority_a = self.config.priority_index(&a.ticket_type); - let priority_b = self.config.priority_index(&b.ticket_type); - - priority_a - .cmp(&priority_b) - .then_with(|| a.priority_level().cmp(&b.priority_level())) - .then_with(|| a.timestamp.cmp(&b.timestamp)) - }); + tickets.sort_by_cached_key(|t| queue_order_key(&self.config, t)); Ok(tickets) } diff --git a/src/queue/ticket.rs b/src/queue/ticket.rs index 700c2d67..f5fb2877 100644 --- a/src/queue/ticket.rs +++ b/src/queue/ticket.rs @@ -26,7 +26,7 @@ pub enum StepAdvanceResult { } /// LLM task metadata for delegate mode integration -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, TS)] #[ts(export)] pub struct LlmTask { /// LLM task ID (e.g., Claude delegate mode task UUID) @@ -200,6 +200,12 @@ pub struct Ticket { /// Delegator name used per completed step (`step_name` → `delegator_name`). /// Populated when a step is launched; used for bidirectional kanban activity logs. pub step_delegators: HashMap, + /// Tickets that must complete first: local ids or `provider:key` refs. + pub depends_on: Vec, + /// Campaign this ticket belongs to (e.g. `CAMP-0003`). + pub campaign: Option, + /// Upstream epic, project or parent issue as a `provider:key` ref. + pub external_parent: Option, } impl Ticket { @@ -301,6 +307,16 @@ impl Ticket { ) }; + let (depends_on, campaign, external_parent) = parse_frontmatter_mapping(&content) + .map(|(map, _)| { + ( + string_list(&map, "depends_on"), + scalar(&map, "campaign"), + scalar(&map, "external_parent"), + ) + }) + .unwrap_or_default(); + Ok(Self { filename, filepath: path.to_string_lossy().to_string(), @@ -322,6 +338,9 @@ impl Ticket { external_url, external_provider, collection, + depends_on, + campaign, + external_parent, }) } @@ -364,73 +383,64 @@ impl Ticket { /// Update a frontmatter field in the ticket file and save pub fn update_field(&mut self, field: &str, value: &str) -> Result<()> { - // Parse frontmatter - if let Some((mut frontmatter, sessions, step_delegators, llm_task, body)) = - extract_frontmatter(&self.content) - { - frontmatter.insert(field.to_string(), value.to_string()); - - // Rebuild the frontmatter - let mut yaml_lines = Vec::new(); - for (k, v) in &frontmatter { - yaml_lines.push(format!("{k}: {v}")); - } - - // Add sessions if present - if !sessions.is_empty() { - yaml_lines.push("sessions:".to_string()); - for (step, session_id) in &sessions { - yaml_lines.push(format!(" {step}: {session_id}")); - } - } - - // Add step_delegators if present - if !step_delegators.is_empty() { - yaml_lines.push("step_delegators:".to_string()); - for (step_name, delegator_name) in &step_delegators { - yaml_lines.push(format!(" {step_name}: {delegator_name}")); - } - } - - // Add llm_task if present - if llm_task.id.is_some() || llm_task.status.is_some() || !llm_task.blocked_by.is_empty() - { - yaml_lines.push("llm_task:".to_string()); - if let Some(ref id) = llm_task.id { - yaml_lines.push(format!(" id: {id}")); - } - if let Some(ref status) = llm_task.status { - yaml_lines.push(format!(" status: {status}")); - } - if !llm_task.blocked_by.is_empty() { - yaml_lines.push(" blocked_by:".to_string()); - for task_id in &llm_task.blocked_by { - yaml_lines.push(format!(" - {task_id}")); - } - } - } - - yaml_lines.sort(); // Keep consistent order + self.set_frontmatter_value(field, serde_yaml::Value::String(value.to_string()))?; + match field { + "step" => self.step = value.to_string(), + "status" => self.status = value.to_string(), + "priority" => self.priority = value.to_string(), + "worktree_path" => self.worktree_path = Some(value.to_string()), + "branch" => self.branch = Some(value.to_string()), + "external_id" => self.external_id = Some(value.to_string()), + "external_url" => self.external_url = Some(value.to_string()), + "external_provider" => self.external_provider = Some(value.to_string()), + "campaign" => self.campaign = Some(value.to_string()), + "external_parent" => self.external_parent = Some(value.to_string()), + _ => {} + } + Ok(()) + } - let new_content = format!("---\n{}\n---{}", yaml_lines.join("\n"), body); - self.content = new_content.clone(); + /// Replace `depends_on`; an empty list removes the key. + pub fn set_depends_on(&mut self, refs: &[String]) -> Result<()> { + let value = if refs.is_empty() { + serde_yaml::Value::Null + } else { + serde_yaml::Value::Sequence( + refs.iter() + .map(|r| serde_yaml::Value::String(r.clone())) + .collect(), + ) + }; + self.set_frontmatter_value("depends_on", value)?; + self.depends_on = refs.to_vec(); + Ok(()) + } - // Update the in-memory field - match field { - "step" => self.step = value.to_string(), - "status" => self.status = value.to_string(), - "priority" => self.priority = value.to_string(), - "worktree_path" => self.worktree_path = Some(value.to_string()), - "branch" => self.branch = Some(value.to_string()), - "external_id" => self.external_id = Some(value.to_string()), - "external_url" => self.external_url = Some(value.to_string()), - "external_provider" => self.external_provider = Some(value.to_string()), - _ => {} - } + /// Assign this ticket to a campaign, or remove it with `None`. + pub fn set_campaign(&mut self, campaign: Option<&str>) -> Result<()> { + let value = campaign.map_or(serde_yaml::Value::Null, |c| { + serde_yaml::Value::String(c.to_string()) + }); + self.set_frontmatter_value("campaign", value)?; + self.campaign = campaign.map(String::from); + Ok(()) + } - // Write back to file - fs::write(&self.filepath, new_content).context("Failed to write ticket file")?; + /// Set one frontmatter key in place, preserving every other key; `Null` removes it. + fn set_frontmatter_value(&mut self, key: &str, value: serde_yaml::Value) -> Result<()> { + let Some((mut map, body)) = parse_frontmatter_mapping(&self.content) else { + return Ok(()); + }; + let key = serde_yaml::Value::String(key.to_string()); + if value.is_null() { + map.remove(&key); + } else { + map.insert(key, value); } + let yaml = serde_yaml::to_string(&map).context("Failed to serialize frontmatter")?; + let new_content = format!("---\n{yaml}---{body}"); + fs::write(&self.filepath, &new_content).context("Failed to write ticket file")?; + self.content = new_content; Ok(()) } @@ -479,6 +489,22 @@ impl Ticket { schema.steps.into_iter().find(|s| s.name == self.step) } + /// Markdown after the frontmatter. + pub fn body(&self) -> &str { + parse_frontmatter_mapping(&self.content).map_or(self.content.as_str(), |(_, body)| { + body.trim_start_matches('\n') + }) + } + + /// Board column the file sits in: `queue`, `in-progress` or `completed`. + pub fn column(&self) -> String { + Path::new(&self.filepath) + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } + /// Get the display name of the current step pub fn current_step_display_name(&self) -> String { self.current_step_schema() @@ -872,6 +898,29 @@ fn extract_llm_task_from_yaml(frontmatter: &HashMap) } } +/// Split content into its frontmatter mapping and the body after the closing `---`. +fn parse_frontmatter_mapping(content: &str) -> Option<(serde_yaml::Mapping, &str)> { + let after_open = content.trim_start().strip_prefix("---")?; + let end_idx = after_open.find("\n---")?; + let map = serde_yaml::from_str(after_open[..end_idx].trim()).ok()?; + Some((map, &after_open[end_idx + 4..])) +} + +fn scalar(map: &serde_yaml::Mapping, key: &str) -> Option { + map.get(key)?.as_str().map(String::from) +} + +fn string_list(map: &serde_yaml::Mapping, key: &str) -> Vec { + map.get(key) + .and_then(serde_yaml::Value::as_sequence) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + /// Extract YAML frontmatter from markdown content /// Returns the parsed frontmatter as a `HashMap`, sessions `HashMap`, `step_delegators` `HashMap`, `LlmTask`, and the content after the frontmatter #[allow(clippy::type_complexity)] @@ -1713,4 +1762,90 @@ step: deploy // Step should remain unchanged assert_eq!(ticket.step, "deploy"); } + + fn write_dag_ticket(dir: &Path, frontmatter: &str) -> Ticket { + let path = dir.join("20260101-1200-FEAT-demo-dag.md"); + fs::write( + &path, + format!("---\n{frontmatter}\n---\n\n# Feature: DAG\n"), + ) + .unwrap(); + Ticket::from_file(&path).unwrap() + } + + #[test] + fn test_from_file_parses_dag_fields() { + let dir = tempfile::tempdir().unwrap(); + let ticket = write_dag_ticket( + dir.path(), + "id: FEAT-0001\nstatus: queued\ncampaign: CAMP-0003\nexternal_parent: jira:PROJ-1\ndepends_on:\n - FEAT-0002\n - jira:PROJ-12", + ); + assert_eq!(ticket.campaign.as_deref(), Some("CAMP-0003")); + assert_eq!(ticket.external_parent.as_deref(), Some("jira:PROJ-1")); + assert_eq!(ticket.depends_on, vec!["FEAT-0002", "jira:PROJ-12"]); + } + + #[test] + fn test_update_field_preserves_lists_and_unknown_keys() { + let dir = tempfile::tempdir().unwrap(); + let mut ticket = write_dag_ticket( + dir.path(), + "id: FEAT-0001\nstatus: queued\ndepends_on:\n - FEAT-0002\ncustom:\n nested: [a, b]", + ); + ticket.update_field("status", "running").unwrap(); + let reread = Ticket::from_file(Path::new(&ticket.filepath)).unwrap(); + assert_eq!(reread.status, "running"); + assert_eq!(reread.depends_on, vec!["FEAT-0002"]); + assert!(reread.content.contains("nested"), "{}", reread.content); + assert!(reread.content.contains("# Feature: DAG")); + } + + #[test] + fn test_set_depends_on_round_trips_and_clears() { + let dir = tempfile::tempdir().unwrap(); + let mut ticket = write_dag_ticket(dir.path(), "id: FEAT-0001\nstatus: queued"); + ticket + .set_depends_on(&["linear:ENG-7".to_string(), "FEAT-0009".to_string()]) + .unwrap(); + let reread = Ticket::from_file(Path::new(&ticket.filepath)).unwrap(); + assert_eq!(reread.depends_on, vec!["linear:ENG-7", "FEAT-0009"]); + assert_eq!(reread.status, "queued"); + let mut reread = reread; + reread.set_depends_on(&[]).unwrap(); + let cleared = Ticket::from_file(Path::new(&reread.filepath)).unwrap(); + assert!(cleared.depends_on.is_empty()); + assert!(!cleared.content.contains("depends_on")); + } + + #[test] + fn test_body_strips_frontmatter() { + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("20241221-1430-FEAT-operator-body.md"); + std::fs::write( + &path, + "---\nid: FEAT-1\nstatus: queued\n---\n\n# Feature: Body\n\nText.\n", + ) + .unwrap(); + let ticket = Ticket::from_file(&path).unwrap(); + assert_eq!(ticket.body(), "# Feature: Body\n\nText.\n"); + } + + #[test] + fn test_body_without_frontmatter_is_the_whole_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("20241221-1430-FEAT-operator-plain.md"); + std::fs::write(&path, "# Feature: Plain\n").unwrap(); + let ticket = Ticket::from_file(&path).unwrap(); + assert_eq!(ticket.body(), "# Feature: Plain\n"); + } + + #[test] + fn test_column_is_the_parent_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path().join("in-progress"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("20241221-1430-FEAT-operator-col.md"); + std::fs::write(&path, "---\nid: FEAT-2\n---\n# Feature: Col\n").unwrap(); + assert_eq!(Ticket::from_file(&path).unwrap().column(), "in-progress"); + } } diff --git a/src/rest/directory.rs b/src/rest/directory.rs index e407fa36..286b4676 100644 --- a/src/rest/directory.rs +++ b/src/rest/directory.rs @@ -12,7 +12,6 @@ //! matching, so adoption never cross-wires two repos that share a basename. //! The full path is never exposed over the wire. -use std::fmt::Write as _; use std::path::Path; use sha2::{Digest, Sha256}; @@ -38,13 +37,8 @@ pub fn directory_identity(tickets_path: &Path) -> (String, String) { let mut hasher = Sha256::new(); hasher.update(canonical.to_string_lossy().as_bytes()); - let digest = hasher.finalize(); - // First 6 bytes -> 12 hex chars. Enough to make accidental collisions - // between distinct absolute paths negligible without pulling in `hex`. - let mut directory_id = String::with_capacity(12); - for byte in digest.iter().take(6) { - let _ = write!(directory_id, "{byte:02x}"); - } + // First 6 bytes -> 12 hex chars; collisions between distinct paths are negligible. + let directory_id = hex::encode(&hasher.finalize()[..6]); (directory_name, directory_id) } diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index b5b5044f..fa4c2161 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use utoipa::ToSchema; +use crate::agents::connect::{Attention, ConnectPlan}; use crate::queue::{TicketPriority, TicketStatus}; // ============================================================================= @@ -67,6 +68,10 @@ pub struct KanbanTicketCard { /// for co-located clients; remote clients treat tickets as API-only) #[serde(default)] pub filename: String, + /// Set when the ticket's agent needs the operator + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub attention: Option, } /// Kanban board response with tickets grouped by column @@ -153,6 +158,14 @@ pub struct ActiveAgentResponse { /// Session pane reference (e.g. cmux surface, zellij pane) #[serde(skip_serializing_if = "Option::is_none")] pub session_pane_ref: Option, + /// Last status message from the agent + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message: Option, + /// Ways to open the agent's session + pub connect: ConnectPlan, + /// Set when the agent needs the operator + #[serde(skip_serializing_if = "Option::is_none")] + pub attention: Option, } /// Response for active agents list @@ -218,6 +231,26 @@ pub struct AgentDetailResponse { pub worktree_path: Option, /// Whether this is a paired (interactive) agent pub paired: bool, + /// Terminal session name + #[serde(skip_serializing_if = "Option::is_none")] + pub session_name: Option, + /// Remote host the agent runs on (None = local) + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_host: Option, + /// Execution target the agent launched on + #[serde(skip_serializing_if = "Option::is_none")] + pub target_name: Option, + /// Last status message from the agent + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message: Option, + /// Last time the session output changed (ISO 8601) + #[serde(skip_serializing_if = "Option::is_none")] + pub last_content_change: Option, + /// Ways to open the agent's session + pub connect: ConnectPlan, + /// Set when the agent needs the operator + #[serde(skip_serializing_if = "Option::is_none")] + pub attention: Option, } // ============================================================================= @@ -461,6 +494,12 @@ pub struct KanbanSyncResponse { pub created: Vec, /// Ticket IDs that were skipped (already exist) pub skipped: Vec, + /// Existing tickets whose synced relationships were refreshed + #[serde(default)] + pub updated: Vec, + /// Campaign the synced tickets were stamped with (scoped syncs) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub campaign: Option, /// Error messages for failed syncs pub errors: Vec, /// Total count of issues processed @@ -543,6 +582,125 @@ pub struct TicketDetailResponse { /// Provider name (e.g., "jira", "linear") #[serde(skip_serializing_if = "Option::is_none")] pub external_provider: Option, + /// Markdown after the frontmatter + pub body: String, + /// Board column: `queue`, `in-progress` or `completed` + pub column: String, + /// Campaign this ticket belongs to (e.g. `CAMP-0001`) + #[serde(skip_serializing_if = "Option::is_none")] + pub campaign: Option, + /// Raw `depends_on` references + pub depends_on: Vec, + /// `depends_on` resolved against local tickets + pub dependencies: Vec, + /// Local tickets whose `depends_on` names this ticket + pub dependents: Vec, + /// Upstream epic, project or parent (`provider:key`) + #[serde(skip_serializing_if = "Option::is_none")] + pub external_parent: Option, + /// Collection the ticket's issue type comes from + #[serde(skip_serializing_if = "Option::is_none")] + pub collection: Option, + /// Delegate-mode task metadata + pub llm_task: crate::queue::LlmTask, + /// The agent working this ticket, if any + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +/// One `depends_on` reference and where it points. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct TicketDependency { + /// The reference as written (local id or `provider:key`) + pub reference: String, + /// Local ticket it resolves to; absent while unsynced + #[serde(skip_serializing_if = "Option::is_none")] + pub ticket_id: Option, + /// Column of the resolved ticket + #[serde(skip_serializing_if = "Option::is_none")] + pub column: Option, + /// Whether the dependency is completed + pub satisfied: bool, +} + +/// The agent working a ticket, as shown on the ticket page. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct TicketAgent { + pub id: String, + /// running, `awaiting_input`, completing or orphaned + pub status: String, + pub paired: bool, + /// RFC 3339 start time + pub started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_step: Option, + pub completed_steps: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub review_state: Option, + /// tmux, vscode, cmux or zellij + #[serde(skip_serializing_if = "Option::is_none")] + pub session_wrapper: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_window_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_context_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_pane_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_status: Option, + /// SSH host the agent runs on, when remote + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_tool: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub llm_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message: Option, + /// Shell command that attaches to the session (tmux) + #[serde(skip_serializing_if = "Option::is_none")] + pub attach_command: Option, +} + +impl From<&crate::state::AgentState> for TicketAgent { + fn from(a: &crate::state::AgentState) -> Self { + Self { + id: a.id.clone(), + status: a.status.clone(), + paired: a.paired, + started_at: a.started_at.to_rfc3339(), + current_step: a.current_step.clone(), + completed_steps: a.completed_steps.clone(), + review_state: a.review_state.clone(), + session_wrapper: a.session_wrapper.clone(), + session_name: a.session_name.clone(), + session_window_ref: a.session_window_ref.clone(), + session_context_ref: a.session_context_ref.clone(), + session_pane_ref: a.session_pane_ref.clone(), + worktree_path: a.worktree_path.clone(), + pr_url: a.pr_url.clone(), + pr_number: a.pr_number, + pr_status: a.pr_status.clone(), + remote_host: a.remote_host.clone(), + target_name: a.target_name.clone(), + llm_tool: a.llm_tool.clone(), + llm_model: a.llm_model.clone(), + last_message: a.last_message.clone(), + attach_command: None, + } + } } /// Request to update a ticket's status @@ -590,6 +748,7 @@ mod tests { #[test] fn test_kanban_ticket_card_step_display_name_absent_when_none() { let card = KanbanTicketCard { + attention: None, filename: String::new(), id: "FEAT-1".to_string(), summary: "Add thing".to_string(), @@ -608,6 +767,7 @@ mod tests { #[test] fn test_kanban_ticket_card_step_display_name_present_when_set() { let card = KanbanTicketCard { + attention: None, filename: String::new(), id: "FEAT-1".to_string(), summary: "Add thing".to_string(), @@ -651,6 +811,14 @@ mod tests { assert_eq!(parsed.completed, 7); } + fn empty_plan() -> ConnectPlan { + ConnectPlan { + phase: crate::agents::connect::SessionPhase::Gone, + preferred: None, + options: vec![], + } + } + #[test] fn test_active_agent_response_session_fields_absent_when_none() { let agent = ActiveAgentResponse { @@ -666,6 +834,9 @@ mod tests { session_window_ref: None, session_context_ref: None, session_pane_ref: None, + last_message: None, + connect: empty_plan(), + attention: None, }; let json = serde_json::to_string(&agent).unwrap(); assert!(!json.contains("current_step")); @@ -696,6 +867,13 @@ mod tests { completed_steps: vec![], worktree_path: None, paired: false, + session_name: None, + remote_host: None, + target_name: None, + last_message: None, + last_content_change: None, + connect: empty_plan(), + attention: None, }; let json = serde_json::to_string(&detail).unwrap(); // skip_serializing_if optionals are omitted... @@ -770,6 +948,16 @@ mod tests { external_id: None, external_url: None, external_provider: None, + body: "# Ticket".to_string(), + column: "in-progress".to_string(), + campaign: None, + depends_on: Vec::new(), + dependencies: Vec::new(), + dependents: Vec::new(), + external_parent: None, + collection: None, + llm_task: crate::queue::LlmTask::default(), + agent: None, }; let json = serde_json::to_string(&detail).unwrap(); let parsed: TicketDetailResponse = serde_json::from_str(&json).unwrap(); diff --git a/src/rest/dto/campaigns.rs b/src/rest/dto/campaigns.rs new file mode 100644 index 00000000..bec21fbe --- /dev/null +++ b/src/rest/dto/campaigns.rs @@ -0,0 +1,53 @@ +//! DTOs for ticket batches and campaigns. Domain views live in `crate::campaigns`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use utoipa::ToSchema; + +use crate::campaigns::model::Campaign; + +/// Move a ticket into or out of a campaign. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AssignCampaignRequest { + /// Campaign id; null removes the ticket from its campaign. + #[serde(default)] + pub campaign: Option, + /// Replaces the ticket's `depends_on` when present. + #[serde(default)] + pub depends_on: Option>, +} + +/// Launch a campaign, or preview the launch. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct LaunchCampaignRequest { + /// Report the plan, policy and violations without starting. + #[serde(default)] + pub dry_run: bool, +} + +/// A campaign with its member count. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignSummary { + pub campaign: Campaign, + pub members: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignListResponse { + pub campaigns: Vec, + pub total: usize, +} + +/// A ticket's campaign membership after an assignment. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct AssignCampaignResponse { + pub id: String, + pub campaign: Option, + pub depends_on: Vec, +} diff --git a/src/rest/dto/configuration.rs b/src/rest/dto/configuration.rs index 4bd588b4..7777e3db 100644 --- a/src/rest/dto/configuration.rs +++ b/src/rest/dto/configuration.rs @@ -117,6 +117,9 @@ pub struct DelegatorResponse { /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. #[serde(skip_serializing_if = "Option::is_none")] pub model_server: Option, + /// Name of a `[[governance]]` policy enforced on this delegator's launches. + #[serde(skip_serializing_if = "Option::is_none")] + pub governance: Option, /// Optional launch configuration #[serde(skip_serializing_if = "Option::is_none")] pub launch_config: Option, @@ -148,6 +151,9 @@ pub struct CreateDelegatorRequest { /// Name of a declared `ModelServer`. `None` means use the `llm_tool`'s implicit vendor default. #[serde(default)] pub model_server: Option, + /// Name of a `[[governance]]` policy enforced on this delegator's launches. + #[serde(default)] + pub governance: Option, /// Optional launch configuration #[serde(default)] pub launch_config: Option, @@ -754,6 +760,7 @@ mod tests { #[test] fn test_delegator_response_roundtrip() { let resp = DelegatorResponse { + governance: None, git: None, name: "claude-opus".to_string(), llm_tool: "claude".to_string(), diff --git a/src/rest/dto/integrations.rs b/src/rest/dto/integrations.rs index 23a2e8ee..a1c6c478 100644 --- a/src/rest/dto/integrations.rs +++ b/src/rest/dto/integrations.rs @@ -120,7 +120,8 @@ fn support_json(support: VerticalSupport) -> serde_json::Value { | VerticalSupport::Integration(s) | VerticalSupport::Workflows(s) | VerticalSupport::Notification(s) - | VerticalSupport::AgentRelay(s) => serde_json::json!({ + | VerticalSupport::AgentRelay(s) + | VerticalSupport::Feature(s) => serde_json::json!({ "kind": "sparse", "coverage": s.coverage.slug(), "notes": s.notes, diff --git a/src/rest/dto/mod.rs b/src/rest/dto/mod.rs index a60436f1..552ac927 100644 --- a/src/rest/dto/mod.rs +++ b/src/rest/dto/mod.rs @@ -9,6 +9,7 @@ pub mod agents; pub mod auth; +pub mod campaigns; pub mod configuration; pub mod git_onboarding; pub mod integrations; @@ -21,6 +22,7 @@ pub mod workflow; pub use agents::*; pub use auth::*; +pub use campaigns::*; pub use configuration::*; pub use git_onboarding::*; pub use integrations::*; diff --git a/src/rest/error.rs b/src/rest/error.rs index 803c0308..3b603053 100644 --- a/src/rest/error.rs +++ b/src/rest/error.rs @@ -43,6 +43,8 @@ pub enum ApiError { /// with a mismatched `Origin`. CsrfFailed(String), NotEntitled(crate::licensing::NotEntitled), + /// A launch refused by a `[[governance]]` policy. + GovernanceDenied(crate::config::governance::GovernanceViolation), } /// Error response body @@ -77,6 +79,11 @@ impl ApiError { "not_entitled", error.to_string(), ), + ApiError::GovernanceDenied(error) => ( + StatusCode::FORBIDDEN, + "governance_denied", + error.to_string(), + ), } } } @@ -112,6 +119,27 @@ impl IntoResponse for ApiError { } } +/// Pre-built error response, boxed to keep handler `Result`s small. +pub struct Rejection(Box); + +impl IntoResponse for Rejection { + fn into_response(self) -> Response { + *self.0 + } +} + +impl From for Rejection { + fn from(response: Response) -> Self { + Self(Box::new(response)) + } +} + +impl From for Rejection { + fn from(error: ApiError) -> Self { + Self(Box::new(error.into_response())) + } +} + impl From for ApiError { fn from(err: std::io::Error) -> Self { ApiError::InternalError(err.to_string()) @@ -126,8 +154,11 @@ impl From for ApiError { impl From for ApiError { fn from(err: anyhow::Error) -> Self { - match err.downcast_ref::() { - Some(error) => Self::NotEntitled(error.clone()), + if let Some(error) = err.downcast_ref::() { + return Self::NotEntitled(error.clone()); + } + match err.downcast_ref::() { + Some(error) => Self::GovernanceDenied(error.clone()), None => Self::InternalError(err.to_string()), } } @@ -166,6 +197,22 @@ mod tests { ); } + #[tokio::test] + async fn test_governance_denial_is_forbidden_with_stable_code() { + let error = crate::config::governance::GovernanceViolation { + policy: "local-only".into(), + reason: "target 'coder' is not allowed".into(), + }; + let response = + ApiError::from(anyhow::Error::new(error).context("launch rejected")).into_response(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body: ErrorResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(body.error, "governance_denied"); + assert!(body.message.contains("local-only")); + assert!(body.feature.is_none()); + } + #[tokio::test] async fn test_not_found_response() { let error = ApiError::NotFound("Type 'FOO' not found".to_string()); diff --git a/src/rest/middleware/auth.rs b/src/rest/middleware/auth.rs index fb47bf74..63cdaf04 100644 --- a/src/rest/middleware/auth.rs +++ b/src/rest/middleware/auth.rs @@ -19,7 +19,7 @@ use crate::auth::scope::{required_access, Access, Principal}; use crate::auth::store::RateLimitDecision; use crate::auth::tokens::{AUDIENCE_API, AUDIENCE_CALLBACK}; use crate::rest::dto::auth::{PrincipalKind, Scope}; -use crate::rest::error::ApiError; +use crate::rest::error::{ApiError, Rejection}; use crate::rest::state::ApiState; /// Name of the browser session cookie. @@ -195,7 +195,7 @@ pub async fn authorize( State(state): State, request: Request, next: Next, -) -> Result { +) -> Result { let method = request.method().clone(); let path = request.uri().path().to_string(); let headers = request.headers().clone(); @@ -220,8 +220,7 @@ pub async fn authorize( // Unknown or unclassified: deny. `tests/route_scope_parity.rs` makes // this unreachable for mounted routes. return Err( - ApiError::Unauthorized("this endpoint requires authentication".to_string()) - .into_response(), + ApiError::Unauthorized("this endpoint requires authentication".to_string()).into(), ); }; @@ -231,15 +230,12 @@ pub async fn authorize( }; let Some(principal) = resolve_principal(&state, &headers).await else { - return Err( - ApiError::Unauthorized("no valid credential was presented".to_string()).into_response(), - ); + return Err(ApiError::Unauthorized("no valid credential was presented".to_string()).into()); }; if !principal.has_scope(required) { return Err( - ApiError::Forbidden(format!("this endpoint requires the `{required}` scope")) - .into_response(), + ApiError::Forbidden(format!("this endpoint requires the `{required}` scope")).into(), ); } @@ -249,15 +245,11 @@ pub async fn authorize( if principal.kind == PrincipalKind::Session && is_mutation(&method) { let config = state.config(); if !origin_is_acceptable(&headers, &config.rest_api.cors_origins) { - return Err( - ApiError::CsrfFailed("request Origin is not allowed".to_string()).into_response(), - ); + return Err(ApiError::CsrfFailed("request Origin is not allowed".to_string()).into()); } let Some(session_id) = principal.session_id.clone() else { - return Err( - ApiError::CsrfFailed("session is not identifiable".to_string()).into_response(), - ); + return Err(ApiError::CsrfFailed("session is not identifiable".to_string()).into()); }; let Some(csrf) = headers .get(CSRF_HEADER) @@ -267,18 +259,16 @@ pub async fn authorize( return Err(ApiError::CsrfFailed(format!( "cookie-authenticated mutations require the `{CSRF_HEADER}` header" )) - .into_response()); + .into()); }; let store = state.auth.store.clone(); let ok = tokio::task::spawn_blocking(move || store.verify_csrf(&session_id, &csrf)) .await - .map_err(|_| { - ApiError::InternalError("CSRF verification task failed".to_string()).into_response() - })? + .map_err(|_| ApiError::InternalError("CSRF verification task failed".to_string()))? .unwrap_or(false); if !ok { - return Err(ApiError::CsrfFailed("CSRF token is invalid".to_string()).into_response()); + return Err(ApiError::CsrfFailed("CSRF token is invalid".to_string()).into()); } } @@ -327,11 +317,11 @@ where { type Rejection = std::convert::Infallible; - async fn from_request_parts( + fn from_request_parts( parts: &mut axum::http::request::Parts, _state: &S, - ) -> Result { - Ok(Authenticated( + ) -> impl std::future::Future> { + std::future::ready(Ok(Authenticated( parts .extensions .get::() @@ -345,7 +335,7 @@ where ticket_id: None, step: None, }), - )) + ))) } } diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 8ef642b7..2b7ee4e2 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -96,7 +96,7 @@ fn auth_router() -> OpenApiRouter { .routes(routes!(routes::auth::revoke_access_key)) } -/// Configuration registry, licence and remote-target routes. +/// Configuration registry, licence, remote-target and campaign routes. /// /// Split out of [`documented_router`] for the same reason as [`auth_router`]: /// this is the entitlement-relevant subset, where every mutation either changes @@ -116,6 +116,13 @@ fn premium_router() -> OpenApiRouter { .routes(routes!(routes::targets::list, routes::targets::create)) .routes(routes!(routes::targets::update, routes::targets::remove)) .routes(routes!(routes::targets::probe)) + // Campaigns. + .routes(routes!(routes::campaigns::list, routes::campaigns::create)) + .routes(routes!(routes::campaigns::get_one)) + .routes(routes!(routes::campaigns::assign)) + .routes(routes!(routes::campaigns::launch)) + .routes(routes!(routes::campaigns::pause)) + .routes(routes!(routes::campaigns::status)) } fn first_run_router() -> OpenApiRouter { @@ -188,12 +195,15 @@ fn documented_router() -> OpenApiRouter { .routes(routes!(routes::agents::approve_review)) .routes(routes!(routes::agents::reject_review)) .routes(routes!(routes::agents::focus_session)) + .routes(routes!(routes::agents::connect_terminal)) // Project endpoints .routes(routes!(routes::projects::list)) .routes(routes!(routes::projects::assess)) // Ticket endpoints .routes(routes!(routes::tickets::get_one)) .routes(routes!(routes::tickets::create)) + .routes(routes!(routes::campaigns::batch_preview)) + .routes(routes!(routes::campaigns::batch_create)) .routes(routes!(routes::tickets::update_status)) // External alert -> investigation .routes(routes!(routes::tickets::create_alert)) diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index cb54b63c..7f402f4f 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -244,6 +244,7 @@ use crate::rest::error::ErrorResponse; (name = "Collections", description = "Issue type collection management"), (name = "Tickets", description = "Ticket CRUD and status management"), (name = "Launch", description = "Ticket launch operations"), + (name = "Campaigns", description = "Campaigns: epics of tickets planned into waves and launched together (Premium)"), (name = "Workflow", description = "Export tickets to Claude dynamic workflows"), (name = "Skills", description = "Skill discovery across LLM tools"), (name = "Delegators", description = "Agent delegator CRUD operations"), diff --git a/src/rest/routes/agents.rs b/src/rest/routes/agents.rs index 49d729a4..3da9c843 100644 --- a/src/rest/routes/agents.rs +++ b/src/rest/routes/agents.rs @@ -8,13 +8,50 @@ use axum::{ }; use crate::agents::cmux::{CmuxClient, SystemCmuxClient}; +use crate::agents::connect::{ + self, Attention, ConnectContext, ConnectPlan, ConnectProbes, ConnectTerminal, RemoteSession, +}; +use crate::agents::{SystemTmuxClient, TmuxClient, TmuxError}; +use crate::config::Config; use crate::rest::dto::{ ActiveAgentResponse, ActiveAgentsResponse, AgentDetailResponse, RejectReviewRequest, ReviewResponse, }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; -use crate::state::State as OperatorState; +use crate::state::{AgentState, State as OperatorState}; + +fn connect_context<'a>( + config: &'a Config, + agent: &'a AgentState, + remote: Option<&'a RemoteSession>, + probes: &'a ConnectProbes, +) -> ConnectContext<'a> { + ConnectContext { + agent, + remote, + tmux_socket: connect::tmux_socket(config), + preferred: config.sessions.connect.preferred, + can_open_terminal: config.sessions.connect.terminal_command.is_some(), + probes, + } +} + +fn agent_connect( + config: &Config, + agent: &AgentState, + probes: &ConnectProbes, +) -> (ConnectPlan, Option) { + let remote = connect::resolve_remote(config, agent); + let plan = connect::connect_plan(&connect_context(config, agent, remote.as_ref(), probes)); + (plan, connect::attention(agent, chrono::Utc::now())) +} + +async fn probe(config: std::sync::Arc) -> Result { + tokio::task::spawn_blocking(move || connect::probe(&config)) + .await + .map_err(|e| ApiError::InternalError(format!("probe task failed: {e}"))) +} /// Get all active agents /// @@ -29,9 +66,10 @@ use crate::state::State as OperatorState; ) )] pub async fn active(State(state): State) -> Result, ApiError> { - // Load operator state from state.json - let operator_state = OperatorState::load(&state.config()) + let config = state.config(); + let operator_state = OperatorState::load(&config) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; + let probes = probe(std::sync::Arc::clone(&config)).await?; // Map AgentState to ActiveAgentResponse let agents: Vec = operator_state @@ -40,23 +78,29 @@ pub async fn active(State(state): State) -> Result, Path(agent_id): Path, ) -> Result, ApiError> { - let operator_state = OperatorState::load(&state.config()) + let config = state.config(); + let operator_state = OperatorState::load(&config) .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; let agent = operator_state @@ -93,6 +138,8 @@ pub async fn get_detail( .iter() .find(|a| a.id == agent_id) .ok_or_else(|| ApiError::NotFound(format!("Agent '{agent_id}' not found")))?; + let probes = probe(std::sync::Arc::clone(&config)).await?; + let (connect, attention) = agent_connect(&config, agent, &probes); Ok(Json(AgentDetailResponse { id: agent.id.clone(), @@ -113,9 +160,55 @@ pub async fn get_detail( completed_steps: agent.completed_steps.clone(), worktree_path: agent.worktree_path.clone(), paired: agent.paired, + session_name: agent.session_name.clone(), + remote_host: agent.remote_host.clone(), + target_name: agent.target_name.clone(), + last_message: agent.last_message.clone(), + last_content_change: agent.last_content_change.map(|t| t.to_rfc3339()), + connect, + attention, })) } +/// What an IDE extension runs after opening an agent's workspace +/// +/// Returns the attach command while the session is live, or the LLM resume +/// command once it is gone. Deep links never carry commands; this is the only source. +#[utoipa::path( + operation_id = "agents_connect_terminal", + get, + path = "/api/v1/agents/{agent_id}/connect/terminal", + tag = "Agents", + params( + ("agent_id" = String, Path, description = "The agent ID to connect to") + ), + responses( + (status = 200, description = "Terminal to open", body = ConnectTerminal), + (status = 404, description = "Agent not found") + ) +)] +pub async fn connect_terminal( + State(state): State, + Path(agent_id): Path, +) -> Result, ApiError> { + let config = state.config(); + let operator_state = OperatorState::load(&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")))?; + let remote = connect::resolve_remote(&config, agent); + let probes = ConnectProbes::default(); + Ok(Json(connect::connect_terminal(&connect_context( + &config, + agent, + remote.as_ref(), + &probes, + )))) +} + /// Approve an agent's pending review /// /// Clears the review state and signals the agent to continue. @@ -232,12 +325,9 @@ 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. +/// cmux focuses the agent's workspace; tmux switches the attached client to the +/// agent's session and window. zellij can't be focused from outside, and VS Code +/// focuses through its extension's URI handler instead. #[utoipa::path( operation_id = "agents_focus_session", post, @@ -249,6 +339,7 @@ pub async fn reject_review( responses( (status = 200, description = "Session focused"), (status = 400, description = "Wrapper unsupported, or no session refs to focus"), + (status = 409, description = "Session not reachable; attach to it instead"), (status = 404, description = "Agent not found") ) )] @@ -294,13 +385,81 @@ pub async fn focus_session( .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") + Some("tmux") | None if agent.status != "orphaned" => { + let tmux_agent = agent.clone(); + let client = SystemTmuxClient::for_config(&state.config()); + let focused = tokio::task::spawn_blocking(move || focus_tmux(&client, &tmux_agent)) + .await + .map_err(|e| ApiError::InternalError(format!("focus task failed: {e}")))?; + match focused { + Err(ApiError::Conflict(_)) if can_open_terminal(&state) => { + open_terminal(&state, agent) + } + other => other, + } + } + Some("zellij" | "tmux") | None if can_open_terminal(&state) => open_terminal(&state, agent), + Some("zellij") => Err(ApiError::Conflict(format!( + "zellij sessions can't be focused from here; attach with `zellij attach {}`", + agent.session_name.as_deref().unwrap_or("") + ))), + Some("tmux") | None => Err(ApiError::Conflict(format!( + "Agent '{agent_id}' has no live session; resume it from the agent page" + ))), + Some(other) => Err(ApiError::BadRequest(format!( + "Focus is not supported for session wrapper '{other}'" ))), } } +fn can_open_terminal(state: &ApiState) -> bool { + state.config().sessions.connect.terminal_command.is_some() +} + +/// Open the configured native terminal on the agent's attach or resume command. +fn open_terminal(state: &ApiState, agent: &AgentState) -> Result<(), ApiError> { + let config = state.config(); + let template = config + .sessions + .connect + .terminal_command + .as_deref() + .ok_or_else(|| ApiError::BadRequest("sessions.connect.terminal_command is unset".into()))?; + let remote = connect::resolve_remote(&config, agent); + let probes = ConnectProbes::default(); + let command = + connect::local_command(&connect_context(&config, agent, remote.as_ref(), &probes)) + .ok_or_else(|| { + ApiError::Conflict(format!( + "Agent '{}' has no session to attach or resume", + agent.id + )) + })?; + std::process::Command::new("sh") + .arg("-c") + .arg(connect::terminal_invocation(template, &command)) + .spawn() + .map(drop) + .map_err(|e| ApiError::InternalError(format!("Failed to open terminal: {e}"))) +} + +/// Switch the attached tmux client to the agent's session and window. +fn focus_tmux(client: &impl TmuxClient, agent: &AgentState) -> Result<(), ApiError> { + let session = agent.session_name.as_deref().ok_or_else(|| { + ApiError::BadRequest(format!("Agent '{}' has no tmux session to focus", agent.id)) + })?; + let target = agent.session_context_ref.as_deref().map_or_else( + || session.to_string(), + |window| format!("{session}:{window}"), + ); + client.switch_client(&target).map_err(|e| match e { + TmuxError::SessionNotFound(_) | TmuxError::CommandFailed(_) => ApiError::Conflict(format!( + "could not focus tmux session '{session}' ({e}); attach with `tmux attach -t {session}`" + )), + other => ApiError::InternalError(format!("tmux focus failed: {other}")), + }) +} + /// Write a review signal file for the agent to pick up fn write_review_signal( state: &ApiState, @@ -367,4 +526,55 @@ mod tests { let result = focus_session(State(state), Path("no-such-agent".to_string())).await; assert!(matches!(result, Err(ApiError::NotFound(_)))); } + + #[tokio::test] + async fn test_connect_terminal_unknown_agent_is_404() { + 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 = connect_terminal(State(state), Path("no-such-agent".to_string())).await; + assert!(matches!(result, Err(ApiError::NotFound(_)))); + } + + fn tmux_agent(session_name: Option<&str>, window: Option<&str>) -> AgentState { + let mut agent: AgentState = serde_json::from_value(serde_json::json!({ + "id": "a", "ticket_id": "FEAT-1", "ticket_type": "FEAT", "project": "api", + "status": "running", "started_at": "2026-01-01T00:00:00Z", + "last_activity": "2026-01-01T00:00:00Z", "paired": false + })) + .unwrap(); + agent.session_wrapper = Some("tmux".into()); + agent.session_name = session_name.map(String::from); + agent.session_context_ref = window.map(String::from); + agent + } + + #[test] + fn test_focus_tmux_switches_the_client_to_the_agent_window() { + let client = crate::agents::MockTmuxClient::new(); + client.add_session("op-feat-1", "/tmp"); + focus_tmux(&client, &tmux_agent(Some("op-feat-1"), Some("2"))).unwrap(); + let log = client.command_log.lock().unwrap(); + let switch = log + .iter() + .find(|c| c.operation == "switch_client") + .expect("switched"); + assert_eq!(switch.args, vec!["op-feat-1:2"]); + } + + #[test] + fn test_focus_tmux_without_a_session_name_is_a_bad_request() { + let client = crate::agents::MockTmuxClient::new(); + let result = focus_tmux(&client, &tmux_agent(None, None)); + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } + + #[test] + fn test_focus_tmux_for_a_gone_session_is_a_conflict() { + let client = crate::agents::MockTmuxClient::new(); + let result = focus_tmux(&client, &tmux_agent(Some("op-gone"), None)); + assert!(matches!(result, Err(ApiError::Conflict(_)))); + } } diff --git a/src/rest/routes/auth.rs b/src/rest/routes/auth.rs index f60797d7..c1b4ac96 100644 --- a/src/rest/routes/auth.rs +++ b/src/rest/routes/auth.rs @@ -24,7 +24,7 @@ use crate::rest::dto::auth::{ OAuthErrorResponse, ResetPasswordRequest, ResetPasswordResponse, RevokeAccessKeyResponse, Scope, SessionListResponse, TokenRequest, TokenResponse, }; -use crate::rest::error::ApiError; +use crate::rest::error::{ApiError, Rejection}; use crate::rest::middleware::auth::{enforce_backoff, Authenticated, SESSION_COOKIE}; use crate::rest::state::ApiState; @@ -146,15 +146,13 @@ pub async fn bootstrap_status( pub async fn bootstrap_submit( State(state): State, Json(req): Json, -) -> Result, Response> { +) -> Result, Rejection> { if let Some(limited) = enforce_backoff(&state, BUCKET_BOOTSTRAP).await { - return Err(limited); + return Err(limited.into()); } let s = store(&state); - let current = blocking(move || s.bootstrap_state()) - .await - .map_err(IntoResponse::into_response)?; + let current = blocking(move || s.bootstrap_state()).await?; let mounted = mounted_bootstrap_password(); @@ -162,7 +160,7 @@ pub async fn bootstrap_submit( BootstrapState::Complete => Err(ApiError::Conflict( "the admin account already exists; use login".to_string(), ) - .into_response()), + .into()), BootstrapState::Uninitialized => { // When a bootstrap secret is mounted, it must be presented. That is @@ -180,19 +178,17 @@ pub async fn bootstrap_submit( return Err(ApiError::Unauthorized( "the temporary password is incorrect".to_string(), ) - .into_response()); + .into()); } } if let Some(rejected) = rejected_password(&req.new_password) { - return Err(rejected); + return Err(rejected.into()); } let password = req.new_password.clone(); let s = store(&state); - let created = blocking(move || s.create_admin(&password, false)) - .await - .map_err(IntoResponse::into_response)?; + let created = blocking(move || s.create_admin(&password, false)).await?; let s = store(&state); if !created { @@ -200,7 +196,7 @@ pub async fn bootstrap_submit( return Err(ApiError::Conflict( "the admin account was created concurrently".to_string(), ) - .into_response()); + .into()); } let _ = blocking(move || { s.clear_rate_limit(BUCKET_BOOTSTRAP)?; @@ -219,9 +215,7 @@ pub async fn bootstrap_submit( // proving possession of it. let provided = req.temporary_password.clone().unwrap_or_default(); let s = store(&state); - let ok = blocking(move || s.verify_admin_password(&provided)) - .await - .map_err(IntoResponse::into_response)?; + let ok = blocking(move || s.verify_admin_password(&provided)).await?; if !ok { let s = store(&state); let _ = blocking(move || { @@ -232,11 +226,11 @@ pub async fn bootstrap_submit( return Err(ApiError::Unauthorized( "the temporary password is incorrect".to_string(), ) - .into_response()); + .into()); } if let Some(rejected) = rejected_password(&req.new_password) { - return Err(rejected); + return Err(rejected.into()); } let password = req.new_password.clone(); @@ -246,8 +240,7 @@ pub async fn bootstrap_submit( s.clear_rate_limit(BUCKET_BOOTSTRAP)?; s.audit("bootstrap", Some("password set"), true) }) - .await - .map_err(IntoResponse::into_response)?; + .await?; Ok(Json(BootstrapSubmitResponse { state: BootstrapState::Complete, @@ -286,26 +279,24 @@ fn session_cookie(token: &str) -> String { pub async fn login( State(state): State, Json(req): Json, -) -> Result { +) -> Result { if let Some(limited) = enforce_backoff(&state, BUCKET_LOGIN).await { - return Err(limited); + return Err(limited.into()); } if !valid_username(&req.username) || req.password.chars().count() > crate::auth::password::MAX_PASSWORD_LENGTH { - return Err(reject_login(&state, "invalid credentials").await); + return Err(reject_login(&state, "invalid credentials").await.into()); } let username = req.username.clone(); let password = req.password.clone(); let s = store(&state); - let ok = blocking(move || s.verify_credentials(&username, &password)) - .await - .map_err(IntoResponse::into_response)?; + let ok = blocking(move || s.verify_credentials(&username, &password)).await?; if !ok { - return Err(reject_login(&state, "invalid credentials").await); + return Err(reject_login(&state, "invalid credentials").await.into()); } let s = store(&state); @@ -315,8 +306,7 @@ pub async fn login( s.audit("login", None, true)?; Ok(session) }) - .await - .map_err(IntoResponse::into_response)?; + .await?; let body = LoginResponse { scopes: Scope::ALL.to_vec(), @@ -375,25 +365,24 @@ pub async fn forgot_password( pub async fn reset_password( State(state): State, Json(req): Json, -) -> Result, Response> { +) -> Result, Rejection> { if let Some(limited) = enforce_backoff(&state, BUCKET_PASSWORD_RESET).await { - return Err(limited); + return Err(limited.into()); } if !valid_username(&req.username) || req.current_password.chars().count() > crate::auth::password::MAX_PASSWORD_LENGTH { - return Err(reject_password_reset(&state).await); + return Err(reject_password_reset(&state).await.into()); } crate::auth::password::validate_password(&req.new_password) - .map_err(|error| ApiError::ValidationError(error.to_string()).into_response())?; + .map_err(|error| ApiError::ValidationError(error.to_string()))?; let s = store(&state); let changed = blocking(move || s.reset_password(&req.username, &req.current_password, &req.new_password)) - .await - .map_err(IntoResponse::into_response)?; + .await?; if !changed { - return Err(reject_password_reset(&state).await); + return Err(reject_password_reset(&state).await.into()); } let s = store(&state); @@ -566,9 +555,9 @@ pub async fn device_code( State(state): State, headers: HeaderMap, Json(req): Json, -) -> Result, Response> { +) -> Result, Rejection> { if let Some(limited) = enforce_backoff(&state, BUCKET_DEVICE_CODE).await { - return Err(limited); + return Err(limited.into()); } if !valid_client_id(&req.client_id) { record_bucket_failure(&state, BUCKET_DEVICE_CODE).await; @@ -576,7 +565,7 @@ pub async fn device_code( StatusCode::BAD_REQUEST, OAuthErrorCode::InvalidClient, "client_id must contain 1 to 128 letters, numbers, periods, underscores, colons, or hyphens", - )); + ).into()); } // An IDE client acts as the human admin, so it receives every scope. A @@ -596,8 +585,7 @@ pub async fn device_code( s.record_failure(BUCKET_DEVICE_CODE)?; Ok(pair) }) - .await - .map_err(IntoResponse::into_response)?; + .await?; let host = headers .get(header::HOST) @@ -668,9 +656,9 @@ pub async fn device_approve( pub async fn token( State(state): State, Json(req): Json, -) -> Result, Response> { +) -> Result, Rejection> { if let Some(limited) = enforce_backoff(&state, BUCKET_TOKEN).await { - return Err(limited); + return Err(limited.into()); } let (scopes, refresh_token) = match req { @@ -684,20 +672,18 @@ pub async fn token( OAuthErrorCode::InvalidClient, "client_id is invalid", ) - .await); + .await + .into()); } let s = store(&state); - let outcome = blocking(move || s.poll_device(&code, &client_id)) - .await - .map_err(IntoResponse::into_response)?; + let outcome = blocking(move || s.poll_device(&code, &client_id)).await?; match outcome { DevicePollOutcome::Approved { client_id, scopes } => { let s = store(&state); let owned = scopes.clone(); - let refresh = blocking(move || s.create_refresh_family(&client_id, &owned)) - .await - .map_err(IntoResponse::into_response)?; + let refresh = + blocking(move || s.create_refresh_family(&client_id, &owned)).await?; (scopes, Some(refresh)) } DevicePollOutcome::Pending => { @@ -705,14 +691,16 @@ pub async fn token( StatusCode::BAD_REQUEST, OAuthErrorCode::AuthorizationPending, "the user has not yet approved this device", - )) + ) + .into()) } DevicePollOutcome::SlowDown => { return Err(oauth_error( StatusCode::BAD_REQUEST, OAuthErrorCode::SlowDown, "polling faster than the advertised interval", - )) + ) + .into()) } DevicePollOutcome::Denied => { return Err(reject_token( @@ -720,7 +708,8 @@ pub async fn token( OAuthErrorCode::AccessDenied, "the user declined this device", ) - .await) + .await + .into()) } DevicePollOutcome::Expired => { return Err(reject_token( @@ -728,7 +717,8 @@ pub async fn token( OAuthErrorCode::ExpiredToken, "the device code has expired or was already used", ) - .await) + .await + .into()) } } } @@ -743,12 +733,11 @@ pub async fn token( OAuthErrorCode::InvalidClient, "client_id is invalid", ) - .await); + .await + .into()); } let s = store(&state); - let outcome = blocking(move || s.redeem_refresh_token(&token, &client_id)) - .await - .map_err(IntoResponse::into_response)?; + let outcome = blocking(move || s.redeem_refresh_token(&token, &client_id)).await?; match outcome { RefreshOutcome::Rotated { @@ -766,7 +755,8 @@ pub async fn token( StatusCode::BAD_REQUEST, OAuthErrorCode::InvalidGrant, "this refresh token was already used; the token family has been revoked", - )); + ) + .into()); } RefreshOutcome::Invalid => { return Err(reject_token( @@ -774,16 +764,15 @@ pub async fn token( OAuthErrorCode::InvalidGrant, "the refresh token is invalid, expired, or revoked", ) - .await) + .await + .into()) } } } TokenRequest::AccessKey { access_key: key } => { let s = store(&state); - let scopes = blocking(move || s.redeem_access_key(&key)) - .await - .map_err(IntoResponse::into_response)?; + let scopes = blocking(move || s.redeem_access_key(&key)).await?; let Some(scopes) = scopes else { let s = store(&state); let _ = blocking(move || { @@ -795,7 +784,8 @@ pub async fn token( StatusCode::BAD_REQUEST, OAuthErrorCode::InvalidGrant, "the access key is invalid, expired, or revoked", - )); + ) + .into()); }; // An access key is re-presented on each exchange, so it produces no // refresh token - there is nothing to refresh. @@ -813,7 +803,7 @@ pub async fn token( .auth .signing_key .sign(&claims) - .map_err(|e| ApiError::InternalError(e.to_string()).into_response())?; + .map_err(|e| ApiError::InternalError(e.to_string()))?; let s = store(&state); let _ = blocking(move || s.clear_rate_limit(BUCKET_TOKEN)).await; @@ -954,7 +944,7 @@ mod tests { use crate::auth::tokens::ACCESS_TOKEN_TTL; use crate::config::Config; use crate::rest::dto::auth::*; - use crate::rest::error::ApiError; + use crate::rest::error::{ApiError, Rejection}; use crate::rest::middleware::auth::{Authenticated, CSRF_HEADER, SESSION_COOKIE}; use crate::rest::state::ApiState; @@ -1003,10 +993,10 @@ mod tests { (dir, state) } - fn settle(result: Result) -> Response { + fn settle(result: Result) -> Response { match result { Ok(body) => body.into_response(), - Err(response) => response, + Err(rejection) => rejection.into_response(), } } diff --git a/src/rest/routes/campaigns.rs b/src/rest/routes/campaigns.rs new file mode 100644 index 00000000..cb81aa97 --- /dev/null +++ b/src/rest/routes/campaigns.rs @@ -0,0 +1,250 @@ +//! Ticket batches (free) and campaigns (Premium). + +use axum::{ + extract::{Path, State}, + Json, +}; + +use crate::campaigns::batch::{self, BatchCreated, BatchError, BatchPreview, BatchRequest}; +use crate::campaigns::model::Campaign; +use crate::campaigns::service::{ + self, CampaignDetail, CampaignLaunchOutcome, CampaignStatusView, NewCampaignInput, +}; +use crate::campaigns::{members, CampaignStore}; +use crate::config::Config; +use crate::licensing::{self, PremiumFeature}; +use crate::queue::Queue; +use crate::rest::dto::{ + AssignCampaignRequest, AssignCampaignResponse, CampaignListResponse, CampaignSummary, + LaunchCampaignRequest, +}; +use crate::rest::{error::ApiError, state::ApiState}; +use crate::services::campaign_runner::{self, LauncherPort, StartError, TickReport}; + +fn entitled(config: &Config) -> Result<(), ApiError> { + licensing::require_premium(config, PremiumFeature::Campaigns)?; + Ok(()) +} + +/// Map a campaign operation's failure to its HTTP shape. +fn campaign_error(error: anyhow::Error) -> ApiError { + if let Some(e) = error.downcast_ref::() { + return ApiError::NotEntitled(e.clone()); + } + match error.downcast_ref::() { + Some(StartError::Lifecycle(m)) => return ApiError::Conflict(m.clone()), + Some(StartError::Invalid(m)) => return ApiError::ValidationError(m.clone()), + Some(StartError::NotEntitled) | None => {} + } + let message = error.to_string(); + if message.contains("not found") { + ApiError::NotFound(message) + } else { + ApiError::ValidationError(message) + } +} + +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(work) + .await + .map_err(|e| ApiError::InternalError(e.to_string()))? +} + +fn all_tickets(config: &Config) -> Result, ApiError> { + let queue = Queue::new(config).map_err(|e| ApiError::InternalError(e.to_string()))?; + let mut all = Vec::new(); + for list in [ + queue.list_queue(), + queue.list_in_progress(), + queue.list_completed(), + ] { + all.extend(list.map_err(|e| ApiError::InternalError(e.to_string()))?); + } + Ok(all) +} + +/// Preview a batch: allocated ids, resolved dependencies, waves and errors. Writes nothing. +#[utoipa::path(post, path = "/api/v1/tickets/batch/preview", operation_id = "tickets_batch_preview", + tag = "Tickets", request_body = BatchRequest, responses((status = 200, body = BatchPreview)))] +pub async fn batch_preview( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + blocking(move || { + let existing = all_tickets(&config)?; + let campaign_exists = request + .campaign + .as_deref() + .is_some_and(|id| CampaignStore::new(&config).get(id).is_some()); + Ok(Json(batch::preview( + &config, + &existing, + &request, + campaign_exists, + ))) + }) + .await +} + +/// Create every ticket in a batch, or none. +#[utoipa::path(post, path = "/api/v1/tickets/batch", operation_id = "tickets_batch_create", + tag = "Tickets", request_body = BatchRequest, + responses((status = 200, body = BatchCreated), (status = 400, description = "Batch invalid; the message carries the preview JSON and nothing is written")))] +pub async fn batch_create( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + blocking(move || match batch::create(&config, &request) { + Ok(created) => Ok(Json(created)), + Err(BatchError::Invalid(preview)) => Err(ApiError::ValidationError( + serde_json::to_string(&preview).unwrap_or_else(|_| "batch is invalid".into()), + )), + Err(e @ BatchError::Write(_)) => Err(ApiError::InternalError(e.to_string())), + }) + .await +} + +#[utoipa::path(get, path = "/api/v1/campaigns", operation_id = "campaigns_list", tag = "Campaigns", + responses((status = 200, body = CampaignListResponse), (status = 402, description = "Premium required")))] +pub async fn list(State(state): State) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + blocking(move || { + let all = all_tickets(&config)?; + let campaigns: Vec = CampaignStore::new(&config) + .list() + .into_iter() + .map(|campaign| CampaignSummary { + members: members(&campaign.id, &all).len(), + campaign, + }) + .collect(); + Ok(Json(CampaignListResponse { + total: campaigns.len(), + campaigns, + })) + }) + .await +} + +#[utoipa::path(post, path = "/api/v1/campaigns", operation_id = "campaigns_create", tag = "Campaigns", + request_body = NewCampaignInput, + responses((status = 200, body = Campaign), (status = 402, description = "Premium required")))] +pub async fn create( + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + blocking(move || { + service::create_campaign(&config, input) + .map(Json) + .map_err(campaign_error) + }) + .await +} + +#[utoipa::path(get, path = "/api/v1/campaigns/{id}", operation_id = "campaigns_get", tag = "Campaigns", + params(("id" = String, Path)), + responses((status = 200, body = CampaignDetail), (status = 402, description = "Premium required")))] +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + blocking(move || { + service::detail(&config, &id) + .map(Json) + .map_err(campaign_error) + }) + .await +} + +#[utoipa::path(put, path = "/api/v1/tickets/{id}/campaign", operation_id = "tickets_assign_campaign", + tag = "Campaigns", params(("id" = String, Path)), request_body = AssignCampaignRequest, + responses((status = 200, body = AssignCampaignResponse), (status = 402, description = "Premium required")))] +pub async fn assign( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + blocking(move || { + service::assign_ticket( + &config, + &id, + request.campaign.as_deref(), + request.depends_on, + ) + .map(|ticket| { + Json(AssignCampaignResponse { + id: ticket.id, + campaign: ticket.campaign, + depends_on: ticket.depends_on, + }) + }) + .map_err(campaign_error) + }) + .await +} + +/// Start a campaign and launch its first wave; `dry_run` only reports. +#[utoipa::path(post, path = "/api/v1/campaigns/{id}/launch", operation_id = "campaigns_launch", + tag = "Campaigns", params(("id" = String, Path)), request_body = LaunchCampaignRequest, + responses((status = 200, body = CampaignLaunchOutcome), (status = 402, description = "Premium required"), + (status = 409, description = "Campaign cannot start from its current status")))] +pub async fn launch( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + let port = LauncherPort::new(config.clone()); + service::launch(&config, &port, &id, request.dry_run) + .await + .map(Json) + .map_err(campaign_error) +} + +/// Stop new launches; running agents continue. +#[utoipa::path(post, path = "/api/v1/campaigns/{id}/pause", operation_id = "campaigns_pause", + tag = "Campaigns", params(("id" = String, Path)), + responses((status = 200, body = TickReport), (status = 402, description = "Premium required")))] +pub async fn pause( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + blocking(move || { + campaign_runner::pause_campaign(&config, &id, "paused by operator") + .map(Json) + .map_err(campaign_error) + }) + .await +} + +#[utoipa::path(get, path = "/api/v1/campaigns/{id}/status", operation_id = "campaigns_status", + tag = "Campaigns", params(("id" = String, Path)), + responses((status = 200, body = CampaignStatusView), (status = 402, description = "Premium required")))] +pub async fn status( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let config = (*state.config()).clone(); + entitled(&config)?; + blocking(move || { + let port = LauncherPort::new(config.clone()); + service::status(&config, &port, &id) + .map(Json) + .map_err(campaign_error) + }) + .await +} diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index b809c056..09ede852 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -92,6 +92,7 @@ pub async fn create( display_name: req.display_name, model_properties: req.model_properties, model_server: req.model_server, + governance: req.governance, launch_config: req.launch_config.map(dto_to_launch_config), remote_agent: req.remote_agent, x_agnt: None, @@ -196,6 +197,7 @@ fn delegator_to_response(d: &Delegator) -> DelegatorResponse { display_name: d.display_name.clone(), model_properties: d.model_properties.clone(), model_server: d.model_server.clone(), + governance: d.governance.clone(), launch_config: d.launch_config.as_ref().map(launch_config_to_dto), remote_agent: d.remote_agent.clone(), } @@ -255,6 +257,7 @@ pub async fn create_from_tool( display_name: req.display_name, model_properties: std::collections::HashMap::new(), model_server: req.model_server, + governance: None, launch_config: req.launch_config.map(dto_to_launch_config), remote_agent: None, x_agnt: None, @@ -304,6 +307,7 @@ pub async fn update( display_name: req.display_name, model_properties: req.model_properties, model_server: req.model_server, + governance: req.governance, launch_config: req.launch_config.map(dto_to_launch_config), remote_agent: req.remote_agent, x_agnt: existing.x_agnt.clone(), @@ -411,6 +415,7 @@ mod tests { async fn test_list_with_delegators() { let mut config = Config::default(); config.delegators.push(Delegator { + governance: None, git: None, name: "test-delegator".to_string(), llm_tool: "claude".to_string(), @@ -444,6 +449,7 @@ mod tests { async fn test_get_one_found() { let mut config = Config::default(); config.delegators.push(Delegator { + governance: None, git: None, name: "my-delegator".to_string(), llm_tool: "codex".to_string(), @@ -476,6 +482,7 @@ mod tests { async fn test_get_one_with_extended_launch_config() { let mut config = Config::default(); config.delegators.push(Delegator { + governance: None, git: None, name: "full-config".to_string(), llm_tool: "claude".to_string(), @@ -541,6 +548,7 @@ mod tests { async fn export_profile_returns_agent_profile() { let mut config = Config::default(); config.delegators.push(Delegator { + governance: None, git: None, name: "claude-opus".to_string(), llm_tool: "claude".to_string(), @@ -576,6 +584,7 @@ mod tests { // covered by the unit tests in `config::agent_profile`. let mut config = Config::default(); config.delegators.push(Delegator { + governance: None, git: None, name: "dup".to_string(), llm_tool: "claude".to_string(), diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 29929e5c..c003dc5f 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -986,6 +986,7 @@ mod tests { fn test_build_launch_options_delegator_propagates_all_fields() { let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + governance: None, git: None, name: "full-delegator".to_string(), llm_tool: "claude".to_string(), @@ -1043,6 +1044,7 @@ mod tests { fn test_build_launch_options_delegator_none_overrides_inherit() { let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + governance: None, git: None, name: "minimal".to_string(), llm_tool: "claude".to_string(), @@ -1094,6 +1096,7 @@ mod tests { fn make_delegator(name: &str, tool: &str, model: &str) -> crate::config::Delegator { crate::config::Delegator { + governance: None, git: None, name: name.to_string(), llm_tool: tool.to_string(), @@ -1253,6 +1256,7 @@ mod tests { #[test] fn test_build_launch_options_step_agent_applies_launch_config() { let state = make_state_with_delegators(vec![crate::config::Delegator { + governance: None, git: None, name: "codex-auto".to_string(), llm_tool: "codex".to_string(), @@ -1312,6 +1316,9 @@ mod tests { let worktree = temp_dir.path().join("worktree"); std::fs::create_dir_all(&worktree).unwrap(); Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "multi.md".to_string(), filepath: worktree.join("multi.md").to_string_lossy().to_string(), timestamp: "20241221-1430".to_string(), diff --git a/src/rest/routes/mod.rs b/src/rest/routes/mod.rs index 6c8bfe2c..c7fff6a4 100644 --- a/src/rest/routes/mod.rs +++ b/src/rest/routes/mod.rs @@ -2,6 +2,7 @@ pub mod agents; pub mod auth; +pub mod campaigns; pub mod collections; pub mod configuration; pub mod delegators; diff --git a/src/rest/routes/queue.rs b/src/rest/routes/queue.rs index 51ca7172..8d1ba8c5 100644 --- a/src/rest/routes/queue.rs +++ b/src/rest/routes/queue.rs @@ -10,6 +10,7 @@ use axum::{ }; use chrono::Utc; +use crate::agents::connect; use crate::config::Config; use crate::queue::{Queue, Ticket, TicketStatus}; use crate::rest::dto::{ @@ -18,7 +19,7 @@ use crate::rest::dto::{ }; use crate::rest::error::ApiError; use crate::rest::state::ApiState; -use crate::state::State as OperatorState; +use crate::state::{AgentState, State as OperatorState}; /// Convert a Ticket to a `KanbanTicketCard` fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { @@ -33,6 +34,19 @@ fn ticket_to_card(ticket: &Ticket) -> KanbanTicketCard { priority: ticket.priority_level(), timestamp: ticket.timestamp.clone(), filename: ticket.filename.clone(), + attention: None, + } +} + +/// Attach each active card's agent attention reason. +fn annotate_attention(agents: &[AgentState], column: &mut [KanbanTicketCard]) { + let now = Utc::now(); + for card in column { + card.attention = agents + .iter() + .find(|a| a.ticket_id == card.id) + .and_then(|a| connect::attention(a, now)) + .map(|a| a.reason); } } @@ -112,6 +126,11 @@ pub async fn kanban(State(state): State) -> Result) -> Result, Path(ticket_id): Path, ) -> Result, ApiError> { - let queue = Queue::new(&state.config()).map_err(|e| ApiError::InternalError(e.to_string()))?; - + let config = state.config(); + let queue = Queue::new(&config).map_err(|e| ApiError::InternalError(e.to_string()))?; let ticket = find_ticket_anywhere(&queue, &ticket_id)?; - let step_display_name = ticket.current_step_display_name(); + let all = all_tickets(&queue)?; + let operator_state = crate::state::State::load(&config) + .map_err(|e| ApiError::InternalError(format!("Failed to load state: {e}")))?; + let agent = operator_state.agent_by_ticket(&ticket.id); + Ok(Json(ticket_detail(&config, &ticket, &all, agent))) +} - 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 +fn all_tickets(queue: &Queue) -> Result, ApiError> { + let internal = |e: anyhow::Error| ApiError::InternalError(e.to_string()); + let mut all = queue.list_queue().map_err(internal)?; + all.extend(queue.list_in_progress().map_err(internal)?); + all.extend(queue.list_completed().map_err(internal)?); + Ok(all) +} + +fn dependency(reference: &str, all: &[Ticket]) -> TicketDependency { + let local = match deps::resolve(reference, all) { + deps::Resolved::Local(id) => all.iter().find(|t| t.id == id), + deps::Resolved::Unsynced(_) => None, + }; + let column = local.map(Ticket::column); + TicketDependency { + reference: reference.to_string(), + ticket_id: local.map(|t| t.id.clone()), + satisfied: column.as_deref() == Some("completed"), + column, + } +} + +fn dependents(ticket: &Ticket, all: &[Ticket]) -> Vec { + all.iter() + .filter(|t| { + t.depends_on + .iter() + .any(|r| deps::resolve(r, all) == deps::Resolved::Local(ticket.id.clone())) + }) + .map(|t| t.id.clone()) + .collect() +} + +/// Shell command that attaches to the agent's session; tmux only. +fn attach_command(config: &Config, agent: &crate::state::AgentState) -> Option { + let session = agent.session_name.as_deref()?; + let wrapper = agent.session_wrapper.as_deref().unwrap_or("tmux"); + (wrapper == "tmux").then(|| { + let socket = config.tmux.config_generated && config.tmux_config_path().exists(); + let flag = if socket { + format!("-L {OPERATOR_SOCKET} ") } 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, - })) + String::new() + }; + format!("tmux {flag}attach -t {session}") + }) +} + +/// Full detail of one ticket, its dependencies resolved against `all`. +fn ticket_detail( + config: &Config, + ticket: &Ticket, + all: &[Ticket], + agent: Option<&crate::state::AgentState>, +) -> TicketDetailResponse { + let step_display_name = ticket.current_step_display_name(); + TicketDetailResponse { + id: ticket.id.clone(), + summary: ticket.summary.clone(), + ticket_type: ticket.ticket_type.clone(), + project: ticket.project.clone(), + status: ticket.status.clone(), + step: ticket.step.clone(), + step_display_name: (!step_display_name.is_empty()).then_some(step_display_name), + priority: ticket.priority.clone(), + timestamp: ticket.timestamp.clone(), + content: ticket.content.clone(), + filename: ticket.filename.clone(), + filepath: ticket.filepath.clone(), + sessions: ticket.sessions.clone(), + step_delegators: ticket.step_delegators.clone(), + worktree_path: ticket.worktree_path.clone(), + branch: ticket.branch.clone(), + external_id: ticket.external_id.clone(), + external_url: ticket.external_url.clone(), + external_provider: ticket.external_provider.clone(), + body: ticket.body().to_string(), + column: ticket.column(), + campaign: ticket.campaign.clone(), + depends_on: ticket.depends_on.clone(), + dependencies: ticket + .depends_on + .iter() + .map(|r| dependency(r, all)) + .collect(), + dependents: dependents(ticket, all), + external_parent: ticket.external_parent.clone(), + collection: ticket.collection.clone(), + llm_task: ticket.llm_task.clone(), + agent: agent.map(|a| TicketAgent { + attach_command: attach_command(config, a), + ..TicketAgent::from(a) + }), + } } /// Update a ticket's status @@ -221,13 +298,14 @@ pub async fn create( /// on top of the template's generated defaults (so the ticket gets a real id, /// branch, etc.), writes it to the queue via the embedded-template /// [`TicketCreator`], and reloads it. Used by both `create` and `create_alert`. -async fn create_ticket_from_values( +pub(crate) async fn create_ticket_from_values( state: &ApiState, template_type: TemplateType, values: HashMap, ) -> Result<(Ticket, std::path::PathBuf), ApiError> { let config = (*state.config()).clone(); let path = tokio::task::spawn_blocking(move || -> Result { + let _guard = TicketCreator::creation_lock(); let creator = TicketCreator::new(&config); let project = values.get("project").cloned().unwrap_or_default(); // Start from generated defaults (id, created, branch, step, status), @@ -293,7 +371,6 @@ pub async fn create_alert( #[cfg(test)] mod tests { use super::*; - use crate::config::Config; use std::path::PathBuf; fn make_state() -> ApiState { @@ -516,4 +593,133 @@ mod tests { assert!(!resp.id.is_empty(), "investigation has an id"); assert!(resp.filename.contains(".md"), "filename: {}", resp.filename); } + + fn write_ticket(root: &std::path::Path, column: &str, name: &str, frontmatter: &str) -> Ticket { + let dir = root.join(column); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write( + &path, + format!("---\n{frontmatter}\n---\n\n# Body of {name}\n"), + ) + .unwrap(); + Ticket::from_file(&path).unwrap() + } + + fn agent_for(ticket_id: &str) -> crate::state::AgentState { + serde_json::from_value(serde_json::json!({ + "id": "agent-1", "ticket_id": ticket_id, "ticket_type": "FEAT", "project": "api", + "status": "running", "started_at": "2026-01-01T00:00:00Z", + "last_activity": "2026-01-01T00:00:00Z", "paired": false, + "session_wrapper": "tmux", "session_name": "op-feat-2", + "current_step": "build", "completed_steps": ["plan"], + "pr_url": "https://example.test/pr/9", "pr_number": 9 + })) + .unwrap() + } + + #[test] + fn test_ticket_detail_resolves_dependencies_and_dependents() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + let done = write_ticket( + root, + "completed", + "20260101-0000-FEAT-api-a.md", + "id: FEAT-1\nstatus: completed", + ); + let me = write_ticket( + root, + "in-progress", + "20260101-0001-FEAT-api-b.md", + "id: FEAT-2\ncampaign: CAMP-0001\ndepends_on:\n - FEAT-1\n - jira:PROJ-9", + ); + let child = write_ticket( + root, + "queue", + "20260101-0002-FEAT-api-c.md", + "id: FEAT-3\ndepends_on:\n - FEAT-2", + ); + let all = vec![done, me.clone(), child]; + + let detail = ticket_detail(&Config::default(), &me, &all, None); + + assert_eq!(detail.body, "# Body of 20260101-0001-FEAT-api-b.md\n"); + assert_eq!(detail.column, "in-progress"); + assert_eq!(detail.campaign.as_deref(), Some("CAMP-0001")); + assert_eq!(detail.depends_on, vec!["FEAT-1", "jira:PROJ-9"]); + assert_eq!( + detail.dependencies, + vec![ + TicketDependency { + reference: "FEAT-1".into(), + ticket_id: Some("FEAT-1".into()), + column: Some("completed".into()), + satisfied: true, + }, + TicketDependency { + reference: "jira:PROJ-9".into(), + ticket_id: None, + column: None, + satisfied: false, + }, + ] + ); + assert_eq!(detail.dependents, vec!["FEAT-3"]); + assert!(detail.agent.is_none()); + } + + #[test] + fn test_ticket_detail_carries_the_live_agent() { + let temp = tempfile::TempDir::new().unwrap(); + let me = write_ticket( + temp.path(), + "in-progress", + "20260101-0001-FEAT-api-b.md", + "id: FEAT-2", + ); + let agent = agent_for("FEAT-2"); + + let detail = ticket_detail( + &Config::default(), + &me, + std::slice::from_ref(&me), + Some(&agent), + ); + + let live = detail.agent.expect("agent"); + assert_eq!(live.id, "agent-1"); + assert_eq!(live.session_wrapper.as_deref(), Some("tmux")); + assert_eq!(live.session_name.as_deref(), Some("op-feat-2")); + assert_eq!(live.completed_steps, vec!["plan"]); + assert_eq!(live.pr_number, Some(9)); + assert_eq!( + live.attach_command.as_deref(), + Some("tmux attach -t op-feat-2") + ); + } + + #[test] + fn test_attach_command_uses_the_operator_socket_when_configured() { + let temp = tempfile::TempDir::new().unwrap(); + let mut config = Config::default(); + config.paths.tickets = temp.path().to_string_lossy().into_owned(); + config.tmux.config_generated = true; + let conf = config.tmux_config_path(); + std::fs::create_dir_all(conf.parent().unwrap()).unwrap(); + std::fs::write(conf, "").unwrap(); + assert_eq!( + attach_command(&config, &agent_for("FEAT-2")).as_deref(), + Some("tmux -L operator attach -t op-feat-2") + ); + } + + #[test] + fn test_attach_command_is_absent_for_other_wrappers() { + let mut agent = agent_for("FEAT-2"); + agent.session_wrapper = Some("zellij".into()); + assert_eq!(attach_command(&Config::default(), &agent), None); + agent.session_wrapper = Some("cmux".into()); + assert_eq!(attach_command(&Config::default(), &agent), None); + } } diff --git a/src/rest/state.rs b/src/rest/state.rs index 4356b9ab..eecb8f00 100644 --- a/src/rest/state.rs +++ b/src/rest/state.rs @@ -301,6 +301,7 @@ mod tests { type_mappings: HashMap::new(), bidirectional: true, ticket_project: None, + scope: None, }; let _ = &mut project_sync; // suppress unused_mut if needed diff --git a/src/schemas/issuetype_schema.json b/src/schemas/issuetype_schema.json index 984598f8..d363a582 100644 --- a/src/schemas/issuetype_schema.json +++ b/src/schemas/issuetype_schema.json @@ -1374,7 +1374,7 @@ ] }, "ItemSource": { - "description": "Where a pipeline's iterated items come from. The variant determines *when*\nthe list resolves: export-time (a literal array → static fan-out width in\nthe compiled graph) vs runtime (an identifier → symbolic width).", + "description": "Where a pipeline's iterated items come from. The variant determines *when*\nthe list resolves: export-time (a literal array -> static fan-out width in\nthe compiled graph) vs runtime (an identifier -> symbolic width).", "oneOf": [ { "description": "The configured/relevant projects (`config.discover_projects()`),\nresolved to a literal array at export time. The \"plan work across many\nprojects\" mechanism.", diff --git a/src/schemas/ticket_metadata.schema.json b/src/schemas/ticket_metadata.schema.json index a7f43f73..c14f5c95 100644 --- a/src/schemas/ticket_metadata.schema.json +++ b/src/schemas/ticket_metadata.schema.json @@ -67,6 +67,26 @@ "description": "Provider name for the external issue (e.g., jira, linear)", "examples": ["jira", "linear"] }, + "campaign": { + "type": "string", + "description": "Campaign this ticket belongs to. A campaign groups tickets into one planned body of work.", + "pattern": "^CAMP-\\d+$", + "examples": ["CAMP-0003"] + }, + "depends_on": { + "type": "array", + "description": "Tickets that must complete before this one can launch. Each entry is a local ticket id, or a `provider:key` reference to an upstream issue (kept even when that issue is not synced). Provider references are refreshed on every kanban sync; local ids are never touched by sync.", + "items": { + "type": "string", + "pattern": "^([A-Z][A-Z0-9_]*-\\w+|(jira|linear|github|openspec):.+)$" + }, + "examples": [["FEAT-0012", "jira:PROJ-12", "linear:ENG-7", "github:octo/hello#12"]] + }, + "external_parent": { + "type": "string", + "description": "Upstream epic, project, or parent issue as a `provider:key` reference. Pulled from the kanban provider on sync.", + "examples": ["jira:PROJ-1", "linear:ENG-40", "openspec:add-dark-mode"] + }, "step_delegators": { "type": "object", "description": "Step name to delegator name mapping. Populated when a step launches; used for bidirectional kanban activity logs.", diff --git a/src/services/campaign_runner.rs b/src/services/campaign_runner.rs new file mode 100644 index 00000000..6f0e220c --- /dev/null +++ b/src/services/campaign_runner.rs @@ -0,0 +1,462 @@ +//! Drive running campaigns: plan, decide, launch, record. One owner: the API server. + +use std::collections::{BTreeMap, BTreeSet}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tokio::sync::Notify; +use ts_rs::TS; + +use crate::agents::delegator_resolution::resolve_launch_options; +use crate::agents::Launcher; +use crate::campaigns::model::{Campaign, CampaignStatus}; +use crate::campaigns::runner::{ + next_launches, BlockReason, RunTransition, Slots, TickDecision, TickInput, +}; +use crate::campaigns::{members, plan, workspace, CampaignStore}; +use crate::config::{governance, Config, GovernancePolicy, SessionWrapperType}; +use crate::licensing::{self, PremiumFeature}; +use crate::queue::{Queue, Ticket}; +use crate::startup::templates::load_registry; +use crate::state::State; + +/// The runner's view of the outside world. +pub trait CampaignPort: Sync { + /// Launch one ticket; returns the agent id. + fn launch( + &self, + campaign: &Campaign, + ticket: &Ticket, + ) -> impl Future> + Send; + fn entitled(&self) -> bool; + /// Ticket ids with a live agent. + fn live_agents(&self) -> BTreeSet; + fn slots(&self) -> Slots; +} + +/// What one tick did, or would do when assessed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] +#[ts(export)] +pub struct TickReport { + pub status: CampaignStatus, + pub launched: Vec, + pub awaiting_operator: Vec, + pub stalled: Vec, + pub blocked: BTreeMap, + pub pause_reason: Option, +} + +impl TickReport { + fn idle(status: CampaignStatus) -> Self { + Self { + status, + launched: Vec::new(), + awaiting_operator: Vec::new(), + stalled: Vec::new(), + blocked: BTreeMap::new(), + pause_reason: None, + } + } +} + +/// Refuse wrappers whose sessions need a connected client. +pub fn validate_campaign_launchable(config: &Config) -> Result<()> { + if config.sessions.wrapper == SessionWrapperType::Vscode { + bail!("campaigns launch unattended; the vscode wrapper needs a connected client"); + } + Ok(()) +} + +fn all_tickets(config: &Config) -> Result> { + let queue = Queue::new(config)?; + let mut all = queue.list_queue()?; + all.extend(queue.list_in_progress()?); + all.extend(queue.list_completed()?); + Ok(all) +} + +fn campaign_policy(config: &Config, campaign: &Campaign) -> Result { + crate::campaigns::service::policy(config, campaign) +} + +/// Why a campaign cannot start. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum StartError { + #[error("Campaigns requires a valid Premium license for this configuration")] + NotEntitled, + /// The campaign's lifecycle forbids starting (running, completed, cancelled). + #[error("{0}")] + Lifecycle(String), + /// The configuration or plan cannot run (wrapper, policy, cycle). + #[error("{0}")] + Invalid(String), +} + +/// Every reason `campaign` cannot start now; empty means it can. +pub fn preflight( + config: &Config, + port: &impl CampaignPort, + campaign: &Campaign, +) -> Vec { + let id = &campaign.id; + let mut problems = Vec::new(); + match campaign.status { + CampaignStatus::Draft | CampaignStatus::Ready | CampaignStatus::Paused => {} + CampaignStatus::Running => { + problems.push(StartError::Lifecycle(format!( + "campaign {id} is already running" + ))); + } + CampaignStatus::Completed => { + problems.push(StartError::Lifecycle(format!("campaign {id} is completed"))); + } + CampaignStatus::Cancelled => { + problems.push(StartError::Lifecycle(format!("campaign {id} is cancelled"))); + } + } + if !port.entitled() { + problems.push(StartError::NotEntitled); + } + if let Err(e) = validate_campaign_launchable(config) { + problems.push(StartError::Invalid(e.to_string())); + } + if let Err(e) = campaign_policy(config, campaign) { + problems.push(StartError::Invalid(e.to_string())); + } + match all_tickets(config) { + Ok(all) => { + if let Err(e) = plan::plan(config, &members(id, &all), &all) { + problems.push(StartError::Invalid(e.to_string())); + } + } + Err(e) => problems.push(StartError::Invalid(e.to_string())), + } + problems +} + +/// Move a draft, ready or paused campaign to running after checking it can run unattended. +pub fn start_campaign(config: &Config, port: &impl CampaignPort, id: &str) -> Result { + let store = CampaignStore::new(config); + let campaign = store + .get(id) + .with_context(|| format!("campaign {id} not found"))?; + if let Some(problem) = preflight(config, port, &campaign).into_iter().next() { + return Err(match problem { + StartError::NotEntitled => licensing::NotEntitled { + feature: PremiumFeature::Campaigns, + } + .into(), + other => other.into(), + }); + } + State::mutate(config, |state| { + let run = state.campaign_runs.entry(id.to_string()).or_default(); + run.started_at.get_or_insert_with(chrono::Utc::now); + run.pause_reason = None; + })?; + store.set_status(id, CampaignStatus::Running) +} + +/// Stop new launches; agents already running are untouched. +pub fn pause_campaign(config: &Config, id: &str, reason: &str) -> Result { + CampaignStore::new(config) + .get(id) + .with_context(|| format!("campaign {id} not found"))?; + pause(config, id, reason.to_string()) +} + +fn pause(config: &Config, id: &str, reason: String) -> Result { + State::mutate(config, |state| { + let run = state.campaign_runs.entry(id.to_string()).or_default(); + run.pause_reason = Some(reason.clone()); + })?; + CampaignStore::new(config).set_status(id, CampaignStatus::Paused)?; + Ok(TickReport { + pause_reason: Some(reason), + ..TickReport::idle(CampaignStatus::Paused) + }) +} + +struct Ready { + campaign: Campaign, + members: Vec, + decision: TickDecision, +} + +/// `Ok(Err(reason))` when the run cannot proceed: unknown policy or a dependency cycle. +fn decide( + config: &Config, + port: &impl CampaignPort, + campaign: Campaign, +) -> Result> { + let id = campaign.id.clone(); + let policy = match campaign_policy(config, &campaign) { + Ok(policy) => policy, + Err(e) => return Ok(Err(e.to_string())), + }; + let all = all_tickets(config)?; + let member_list = members(&id, &all); + let planned = match plan::plan(config, &member_list, &all) { + Ok(planned) => planned, + Err(e) => return Ok(Err(e.to_string())), + }; + let run = State::load(config)? + .campaign_runs + .get(&id) + .cloned() + .unwrap_or_default(); + let registry = load_registry(&config.tickets_path()); + let is_paired = + |t: &Ticket| crate::templates::is_paired_with_registry(&t.ticket_type, ®istry); + let decision = next_launches(&TickInput { + plan: &planned, + members: &member_list, + all: &all, + run: &run, + policy: &policy, + slots: port.slots(), + entitled: port.entitled(), + live: &port.live_agents(), + is_paired: &is_paired, + }); + let members = member_list.into_iter().cloned().collect(); + Ok(Ok(Ready { + campaign, + members, + decision, + })) +} + +/// What the next tick would do, without launching or writing; `launched` lists would-be launches. +pub fn assess(config: &Config, port: &impl CampaignPort, id: &str) -> Result { + let campaign = CampaignStore::new(config) + .get(id) + .with_context(|| format!("campaign {id} not found"))?; + let status = campaign.status; + Ok(match decide(config, port, campaign)? { + Err(reason) => TickReport { + pause_reason: Some(reason), + ..TickReport::idle(status) + }, + Ok(Ready { decision, .. }) => TickReport { + status, + pause_reason: match decision.transition { + Some(RunTransition::Pause(reason)) => Some(reason), + _ => None, + }, + launched: decision.launch, + awaiting_operator: decision.awaiting_operator, + stalled: decision.stalled, + blocked: decision.blocked, + }, + }) +} + +/// Advance one running campaign by one step. +pub async fn tick(config: &Config, port: &impl CampaignPort, id: &str) -> Result { + let store = CampaignStore::new(config); + let campaign = store + .get(id) + .with_context(|| format!("campaign {id} not found"))?; + if campaign.status != CampaignStatus::Running { + return Ok(TickReport::idle(campaign.status)); + } + let (campaign, member_list, decision) = match decide(config, port, campaign)? { + Err(reason) => return pause(config, id, reason), + Ok(Ready { + campaign, + members, + decision, + }) => (campaign, members, decision), + }; + + let mut transition = decision.transition.clone(); + let mut launched = Vec::new(); + for ticket_id in &decision.launch { + let Some(ticket) = member_list.iter().find(|t| &t.id == ticket_id) else { + continue; + }; + match port.launch(&campaign, ticket).await { + Ok(_) => launched.push(ticket_id.clone()), + Err(e) => { + transition = Some(RunTransition::Pause(format!( + "launch of {ticket_id} failed: {e}" + ))); + break; + } + } + } + + let pause_reason = match &transition { + Some(RunTransition::Pause(reason)) => Some(reason.clone()), + _ => None, + }; + State::mutate(config, |state| { + let run = state.campaign_runs.entry(id.to_string()).or_default(); + for ticket_id in &launched { + run.record_launch(ticket_id); + } + if let Some(reason) = &pause_reason { + run.pause_reason = Some(reason.clone()); + run.last_error = Some(reason.clone()); + } + })?; + let status = match transition { + Some(RunTransition::Complete) => CampaignStatus::Completed, + Some(RunTransition::Pause(_)) => CampaignStatus::Paused, + None => CampaignStatus::Running, + }; + if status != CampaignStatus::Running { + store.set_status(id, status)?; + } + Ok(TickReport { + status, + launched, + awaiting_operator: decision.awaiting_operator, + stalled: decision.stalled, + blocked: decision.blocked, + pause_reason, + }) +} + +/// Production port: the server-side launcher, the licence and `state.json`. +pub struct LauncherPort { + config: Config, +} + +impl LauncherPort { + pub fn new(config: Config) -> Self { + Self { config } + } +} + +/// Free capacity from the configured limits and the agents already running. +pub fn slots_from(config: &Config, state: &State) -> Slots { + let running = state.running_agents(); + let mut per_repo_busy = BTreeMap::new(); + for agent in &running { + *per_repo_busy.entry(agent.project.clone()).or_default() += 1; + } + Slots { + global: config.effective_max_agents().saturating_sub(running.len()), + per_repo_limit: config.effective_max_agents_per_repo(), + per_repo_busy, + } +} + +impl CampaignPort for LauncherPort { + async fn launch(&self, campaign: &Campaign, ticket: &Ticket) -> Result { + let mut options = + resolve_launch_options(&self.config, None, None, None, None, false, None)?; + if let Some(shared) = workspace::resolve_workspace(&self.config, campaign) { + options.target = shared.target; + } + Launcher::new(&self.config)? + .launch_with_options(ticket, options) + .await + } + + fn entitled(&self) -> bool { + licensing::entitlements(&self.config).allows(PremiumFeature::Campaigns) + } + + fn live_agents(&self) -> BTreeSet { + State::load(&self.config) + .map(|s| { + s.running_agents() + .iter() + .map(|a| a.ticket_id.clone()) + .collect() + }) + .unwrap_or_default() + } + + fn slots(&self) -> Slots { + State::load(&self.config) + .map(|s| slots_from(&self.config, &s)) + .unwrap_or_default() + } +} + +/// Wakes the runner when a ticket changes column. +pub fn wake() -> Arc { + static WAKE: std::sync::OnceLock> = std::sync::OnceLock::new(); + Arc::clone(WAKE.get_or_init(|| Arc::new(Notify::new()))) +} + +const TICK_INTERVAL: Duration = Duration::from_secs(30); + +/// Tick every running campaign on an interval and whenever a ticket moves. +pub fn spawn_campaign_runner(config: impl Fn() -> Arc + Send + 'static) { + tokio::spawn(async move { + let notify = wake(); + loop { + let config = config(); + let port = LauncherPort::new((*config).clone()); + for campaign in CampaignStore::new(&config).list() { + if campaign.status != CampaignStatus::Running { + continue; + } + if let Err(e) = tick(&config, &port, &campaign.id).await { + tracing::warn!(campaign = %campaign.id, error = %e, "campaign tick failed"); + } + } + tokio::select! { + () = notify.notified() => {} + () = tokio::time::sleep(TICK_INTERVAL) => {} + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AgentState; + + fn agent(project: &str, status: &str) -> AgentState { + let mut a: AgentState = serde_json::from_value(serde_json::json!({ + "id": "a", "ticket_id": "T-1", "ticket_type": "FEAT", "project": project, + "status": status, "started_at": "2026-01-01T00:00:00Z", + "last_activity": "2026-01-01T00:00:00Z", "paired": false + })) + .unwrap(); + a.project = project.to_string(); + a + } + + #[test] + fn test_slots_subtract_running_agents_per_repo() { + let dir = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.paths.state = dir.path().to_string_lossy().into_owned(); + config.agents.max_agents_per_repo = 2; + let mut state = State::load(&config).unwrap(); + state.agents = vec![ + agent("api", "running"), + agent("api", "running"), + agent("web", "completed"), + ]; + let slots = slots_from(&config, &state); + assert_eq!(slots.per_repo_busy.get("api"), Some(&2)); + assert_eq!(slots.per_repo_busy.get("web"), None); + assert_eq!(slots.per_repo_limit, 2); + assert_eq!( + slots.global, + config.effective_max_agents().saturating_sub(2) + ); + } + + #[test] + fn test_vscode_wrapper_is_refused() { + let mut config = Config::default(); + assert!(validate_campaign_launchable(&config).is_ok()); + config.sessions.wrapper = SessionWrapperType::Vscode; + let err = validate_campaign_launchable(&config).unwrap_err(); + assert!(err.to_string().contains("client")); + } +} diff --git a/src/services/kanban_sync.rs b/src/services/kanban_sync.rs index 2993c7d7..7f2c10f1 100644 --- a/src/services/kanban_sync.rs +++ b/src/services/kanban_sync.rs @@ -10,17 +10,20 @@ use anyhow::{Context, Result}; use chrono::Local; -use std::collections::HashSet; +use std::collections::{BTreeSet, HashMap}; use std::fs; use std::path::Path; use tracing::{debug, info, warn}; use crate::api::providers::kanban::{ - get_provider, get_provider_from_config, ExternalIssue, OpenspecProvider, + get_provider, get_provider_from_config, ExternalIssue, KanbanProvider, OpenspecProvider, + ScopeContents, }; -use crate::config::{Config, KanbanStatusMapping, ProjectSyncConfig}; +use crate::campaigns::{CampaignStore, NewCampaign}; +use crate::config::{Config, KanbanStatusMapping, ProjectSyncConfig, SyncScope}; use crate::issuetypes::kanban_type::KanbanIssueTypeRef; -use crate::queue::TicketPriority; +use crate::licensing::PremiumFeature; +use crate::queue::{Ticket, TicketPriority}; /// A collection that can be synced from a kanban provider #[derive(Debug, Clone)] @@ -42,8 +45,12 @@ pub struct SyncableCollection { pub struct SyncResult { /// Tickets that were created pub created: Vec, - /// Issues that were skipped (already exist locally) + /// Issues that were skipped (already exist locally, nothing changed) pub skipped: Vec, + /// Existing tickets whose synced relationships were refreshed + pub updated: Vec, + /// Campaign the synced tickets were stamped with (scoped syncs) + pub campaign: Option, /// Issues that failed to sync pub errors: Vec, /// Total number of issues processed @@ -59,8 +66,9 @@ impl SyncResult { /// Get a summary message pub fn summary(&self) -> String { format!( - "Created: {}, Skipped: {}, Errors: {}", + "Created: {}, Updated: {}, Skipped: {}, Errors: {}", self.created.len(), + self.updated.len(), self.skipped.len(), self.errors.len() ) @@ -168,17 +176,6 @@ impl KanbanSyncService { ) -> Result { info!("Syncing collection: {}/{}", provider_name, project_key); - let mut result = SyncResult::default(); - - // Get the provider: env-configured first, then config-backed - // (openspec has no env form and always resolves from config) - let provider = match get_provider(provider_name) { - Some(p) => p, - None => get_provider_from_config(&self.config.kanban, provider_name, project_key) - .map_err(|e| anyhow::anyhow!("Provider '{provider_name}' not configured: {e}"))?, - }; - - // Get the project config let project_config = self .get_project_config(provider_name, project_key) .ok_or_else(|| { @@ -186,6 +183,10 @@ impl KanbanSyncService { "Project '{project_key}' not configured for provider '{provider_name}'" ) })?; + if let Some(scope) = project_config.scope.clone() { + return self.sync_scope(provider_name, project_key, &scope).await; + } + let provider = self.provider(provider_name, project_key)?; // Fetch issues from the provider let issues = provider @@ -204,14 +205,115 @@ impl KanbanSyncService { project_key ); - // Get existing external IDs in the queue - let existing_ids = self.get_existing_external_ids()?; + Ok(self.apply_issues(provider_name, project_key, &project_config, issues)) + } + + /// Env-configured provider first, then config-backed (openspec is config-only). + fn provider(&self, provider_name: &str, project_key: &str) -> Result> { + match get_provider(provider_name) { + Some(p) => Ok(p), + None => get_provider_from_config(&self.config.kanban, provider_name, project_key) + .map_err(|e| anyhow::anyhow!("Provider '{provider_name}' not configured: {e}")), + } + } + + /// Sync one upstream container into a campaign (Premium). + pub async fn sync_scope( + &self, + provider_name: &str, + project_key: &str, + scope: &SyncScope, + ) -> Result { + crate::licensing::require_premium(&self.config, PremiumFeature::Campaigns)?; + let project_config = self + .get_project_config(provider_name, project_key) + .unwrap_or_default(); + let contents = self + .provider(provider_name, project_key)? + .list_scope(scope, &project_config.pull_statuses()) + .await + .context("Failed to fetch scope from provider")?; + info!( + "Fetched {} issues in {provider_name}:{}", + contents.issues.len(), + contents.key + ); + self.apply_scope(provider_name, project_key, &project_config, contents) + } + + /// Find or create the campaign for a fetched scope and stamp its issues into it. + pub fn apply_scope( + &self, + provider_name: &str, + project_key: &str, + project_config: &ProjectSyncConfig, + contents: ScopeContents, + ) -> Result { + crate::licensing::require_premium(&self.config, PremiumFeature::Campaigns)?; + self.stamp_scope(provider_name, project_key, project_config, contents) + } + + fn stamp_scope( + &self, + provider_name: &str, + project_key: &str, + project_config: &ProjectSyncConfig, + contents: ScopeContents, + ) -> Result { + let store = CampaignStore::new(&self.config); + let external_ref = format!("{provider_name}:{}", contents.key); + let campaign = match store.find_by_external_ref(&external_ref) { + Some(campaign) => campaign, + None => store.create(NewCampaign { + title: contents.title, + goal: contents.description, + external_ref: Some(external_ref), + ..Default::default() + })?, + }; + let mut result = self.apply_issues_in( + provider_name, + project_key, + project_config, + contents.issues, + Some(&campaign.id), + ); + result.campaign = Some(campaign.id); + Ok(result) + } + + /// Write fetched issues as tickets: create new ones, refresh relationships on existing ones. + pub fn apply_issues( + &self, + provider_name: &str, + project_key: &str, + project_config: &ProjectSyncConfig, + issues: Vec, + ) -> SyncResult { + self.apply_issues_in(provider_name, project_key, project_config, issues, None) + } + + fn apply_issues_in( + &self, + provider_name: &str, + project_key: &str, + project_config: &ProjectSyncConfig, + issues: Vec, + campaign: Option<&str>, + ) -> SyncResult { + let mut result = SyncResult::default(); + let mut existing = self.existing_tickets_by_external_id(provider_name); - // Create tickets for new issues for issue in issues { - if existing_ids.contains(&issue.key) { - debug!("Skipping existing issue: {}", issue.key); - result.skipped.push(issue.key.clone()); + if let Some(ticket) = existing.get_mut(&issue.key) { + match refresh_relationships(ticket, provider_name, &issue, campaign) { + Ok(true) => result.updated.push(issue.key.clone()), + Ok(false) => { + debug!("Skipping unchanged issue: {}", issue.key); + result.skipped.push(issue.key.clone()); + } + Err(e) => result.errors.push(format!("{}: {e}", issue.key)), + } continue; } @@ -223,9 +325,10 @@ impl KanbanSyncService { match self.create_ticket_from_issue( &issue, provider_name, - ticket_project(&project_config, project_key).as_str(), + ticket_project(project_config, project_key).as_str(), type_mappings, project_config.collection_name.as_deref(), + campaign, ) { Ok(filename) => { info!("Created ticket: {}", filename); @@ -238,8 +341,11 @@ impl KanbanSyncService { } } - result.total_processed = result.created.len() + result.skipped.len() + result.errors.len(); - Ok(result) + result.total_processed = result.created.len() + + result.updated.len() + + result.skipped.len() + + result.errors.len(); + result } /// Sync all configured collections @@ -338,29 +444,19 @@ impl KanbanSyncService { } } - /// Get set of external IDs that already exist in the queue - fn get_existing_external_ids(&self) -> Result> { - let queue_path = Path::new(&self.config.paths.tickets).join("queue"); - let mut ids = HashSet::new(); - - if !queue_path.exists() { - return Ok(ids); - } - - for entry in fs::read_dir(&queue_path)? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|e| e == "md") { - // Try to read external_id from frontmatter - if let Ok(content) = fs::read_to_string(&path) { - if let Some(external_id) = extract_external_id(&content) { - ids.insert(external_id); - } - } - } - } - - Ok(ids) + /// This provider's synced tickets in queue, in-progress and completed, keyed by external id. + fn existing_tickets_by_external_id(&self, provider: &str) -> HashMap { + let tickets = Path::new(&self.config.paths.tickets); + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|dir| fs::read_dir(tickets.join(dir)).ok()) + .flatten() + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| path.extension().is_some_and(|e| e == "md")) + .filter_map(|path| Ticket::from_file(&path).ok()) + .filter(|t| t.external_provider.as_deref().is_none_or(|p| p == provider)) + .filter_map(|t| Some((t.external_id.clone()?, t))) + .collect() } /// Create a ticket file from an external issue @@ -371,6 +467,7 @@ impl KanbanSyncService { project_key: &str, type_mappings: Option<&std::collections::HashMap>, collection: Option<&str>, + campaign: Option<&str>, ) -> Result { let queue_path = Path::new(&self.config.paths.tickets).join("queue"); fs::create_dir_all(&queue_path)?; @@ -382,11 +479,22 @@ impl KanbanSyncService { // falling back to TASK with needs_issuetype_mapping flag let (ticket_type, needs_mapping) = resolve_ticket_type(&issue.kanban_issue_types, type_mappings); - let slug = slugify(&issue.summary, 50); - let filename = format!("{timestamp}-{ticket_type}-{project_key}-{slug}.md"); + let slug = crate::queue::ids::slugify(&issue.summary, 50); + let file_path = crate::queue::ids::unique_path( + &queue_path, + &format!("{timestamp}-{ticket_type}-{project_key}-{slug}"), + ); + let filename = file_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); - let frontmatter = + let mut frontmatter = ticket_frontmatter(ticket_type, issue, provider, needs_mapping, collection); + let (depends_on, external_parent) = relationship_refs(provider, issue); + frontmatter = frontmatter.trim_end_matches("---").to_string() + + &relationship_yaml(&depends_on, external_parent.as_deref(), campaign) + + "---"; // Build content let description = issue @@ -408,7 +516,6 @@ impl KanbanSyncService { frontmatter, ticket_type, issue.summary, description, provider, issue.key, issue.url, ); - let file_path = queue_path.join(&filename); fs::write(&file_path, content)?; Ok(filename) @@ -456,6 +563,85 @@ external_provider: {}{} ) } +/// Provider-qualified relationship refs for an issue: `(depends_on, external_parent)`. +/// +/// Blockers and sub-tasks both gate the issue, so both become dependencies. +pub fn relationship_refs(provider: &str, issue: &ExternalIssue) -> (Vec, Option) { + let depends_on: BTreeSet = issue + .blocked_by + .iter() + .chain(&issue.subtasks) + .filter(|key| **key != issue.key) + .map(|key| format!("{provider}:{key}")) + .collect(); + let parent = issue + .parent + .as_ref() + .map(|p| format!("{provider}:{}", p.key)); + (depends_on.into_iter().collect(), parent) +} + +/// Replace this provider's synced refs, keeping local ids and other providers' refs. +pub fn merge_depends_on(existing: &[String], provider: &str, synced: &[String]) -> Vec { + let prefix = format!("{provider}:"); + let kept = existing.iter().filter(|r| !r.starts_with(&prefix)); + let mut merged: Vec = Vec::new(); + for r in kept.chain(synced) { + if !merged.contains(r) { + merged.push(r.clone()); + } + } + merged +} + +/// Refresh synced relationships on an existing ticket; returns whether anything changed. +fn refresh_relationships( + ticket: &mut Ticket, + provider: &str, + issue: &ExternalIssue, + campaign: Option<&str>, +) -> Result { + let (synced, parent) = relationship_refs(provider, issue); + let merged = merge_depends_on(&ticket.depends_on, provider, &synced); + let mut changed = false; + if merged != ticket.depends_on { + ticket.set_depends_on(&merged)?; + changed = true; + } + if let Some(parent) = parent.filter(|p| ticket.external_parent.as_ref() != Some(p)) { + ticket.update_field("external_parent", &parent)?; + changed = true; + } + if let Some(campaign) = campaign.filter(|_| ticket.campaign.is_none()) { + ticket.set_campaign(Some(campaign))?; + changed = true; + } + Ok(changed) +} + +/// YAML lines for relationship fields; values are JSON-quoted, which is valid YAML. +fn relationship_yaml( + depends_on: &[String], + external_parent: Option<&str>, + campaign: Option<&str>, +) -> String { + let quote = |v: &str| serde_json::to_string(v).unwrap_or_default(); + let mut yaml = String::new(); + if let Some(campaign) = campaign { + yaml.push_str(&format!("campaign: {}\n", quote(campaign))); + } + if let Some(parent) = external_parent { + yaml.push_str(&format!("external_parent: {}\n", quote(parent))); + } + if !depends_on.is_empty() { + yaml.push_str("depends_on:\n"); + for r in depends_on { + yaml.push_str(&format!(" - {}\n", quote(r))); + } + } + yaml +} + /// Extract `external_id` from ticket content frontmatter fn extract_external_id(content: &str) -> Option { // Simple extraction - look for "external_id: " in frontmatter @@ -546,37 +732,15 @@ fn sanitize_external_key(key: &str) -> String { key.chars().filter(char::is_ascii_alphanumeric).collect() } -/// Convert a string to a URL-safe slug -fn slugify(s: &str, max_len: usize) -> String { - let slug: String = s - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - - if slug.len() <= max_len { - slug - } else { - // Truncate at word boundary if possible - let truncated = &slug[..max_len]; - if let Some(last_dash) = truncated.rfind('-') { - truncated[..last_dash].to_string() - } else { - truncated.to_string() - } - } -} - #[cfg(test)] mod tests { use super::*; fn sample_issue() -> ExternalIssue { ExternalIssue { + blocked_by: Vec::new(), + parent: None, + subtasks: Vec::new(), id: "10042".to_string(), key: "PROJ-42".to_string(), summary: "Fix the widget".to_string(), @@ -709,21 +873,6 @@ mod tests { assert_eq!(map_priority(&None), TicketPriority::P2Medium); } - #[test] - fn test_slugify() { - assert_eq!(slugify("Hello World", 50), "hello-world"); - assert_eq!(slugify("Fix: Login Bug!", 50), "fix-login-bug"); - assert_eq!(slugify(" Multiple Spaces ", 50), "multiple-spaces"); - } - - #[test] - fn test_slugify_truncation() { - let long_string = "this is a very long string that should be truncated at a word boundary"; - let slug = slugify(long_string, 30); - assert!(slug.len() <= 30); - assert!(!slug.ends_with('-')); - } - #[test] fn test_extract_external_id() { let content = r"--- @@ -764,7 +913,10 @@ status: queued result.skipped.push("PROJ-3".to_string()); assert!(result.is_success()); - assert_eq!(result.summary(), "Created: 2, Skipped: 1, Errors: 0"); + assert_eq!( + result.summary(), + "Created: 2, Updated: 0, Skipped: 1, Errors: 0" + ); } #[test] @@ -849,6 +1001,13 @@ status: queued assert!(content.contains("external_id: add-dark-mode#1")); assert!(content.contains("- [ ] 1.1 Create ThemeContext")); assert!(content.contains("Users asked.")); + let ui = Ticket::from_file(&queue.join(files.iter().find(|f| f.contains("ui")).unwrap())) + .unwrap(); + assert_eq!(ui.depends_on, vec!["openspec:add-dark-mode#1"]); + assert_eq!( + ui.external_parent.as_deref(), + Some("openspec:add-dark-mode") + ); // Re-import is idempotent: everything skips, nothing new is created let rerun = service @@ -872,4 +1031,273 @@ status: queued .await .is_err()); } + + // ── Relationship sync ─────────────────────────────────────────────────── + + fn sync_service(root: &std::path::Path) -> KanbanSyncService { + let mut config = Config::default(); + config.paths.tickets = root.join(".tickets").to_string_lossy().into_owned(); + KanbanSyncService::new(&config) + } + + fn synced_ticket(root: &std::path::Path, key: &str) -> Ticket { + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|d| fs::read_dir(root.join(".tickets").join(d)).ok()) + .flatten() + .filter_map(|e| Ticket::from_file(&e.unwrap().path()).ok()) + .find(|t| t.external_id.as_deref() == Some(key)) + .unwrap_or_else(|| panic!("no ticket for {key}")) + } + + fn ticket_count(root: &std::path::Path) -> usize { + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|d| fs::read_dir(root.join(".tickets").join(d)).ok()) + .map(Iterator::count) + .sum() + } + + #[test] + fn test_relationship_refs_unions_blockers_and_subtasks() { + let issue = ExternalIssue { + blocked_by: vec!["PROJ-12".into(), "PROJ-42".into()], + subtasks: vec!["PROJ-43".into(), "PROJ-12".into()], + parent: Some(crate::api::providers::kanban::ExternalParent { + key: "PROJ-1".into(), + kind: crate::api::providers::kanban::ParentKind::Epic, + }), + ..sample_issue() + }; + let (deps, parent) = relationship_refs("jira", &issue); + assert_eq!(deps, vec!["jira:PROJ-12", "jira:PROJ-43"]); + assert_eq!(parent.as_deref(), Some("jira:PROJ-1")); + } + + #[test] + fn test_merge_depends_on_keeps_local_and_foreign_refs() { + let existing = vec![ + "FEAT-0003".to_string(), + "jira:PROJ-9".to_string(), + "linear:ENG-1".to_string(), + ]; + let merged = merge_depends_on(&existing, "jira", &["jira:PROJ-12".to_string()]); + assert_eq!(merged, vec!["FEAT-0003", "linear:ENG-1", "jira:PROJ-12"]); + } + + #[test] + fn test_jira_fixture_sync_writes_relationships_and_keeps_dangling_blockers() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let result = service.apply_issues( + "jira", + "PROJ", + &ProjectSyncConfig::default(), + crate::api::providers::kanban::jira_fixture_issues(), + ); + assert_eq!(result.created.len(), 2, "{:?}", result.errors); + let story = synced_ticket(temp.path(), "PROJ-42"); + assert_eq!( + story.depends_on, + vec!["jira:PROJ-12", "jira:PROJ-43", "jira:PROJ-44"], + "PROJ-12 is not synced but stays as a blocker" + ); + assert_eq!(story.external_parent.as_deref(), Some("jira:PROJ-1")); + let sub = synced_ticket(temp.path(), "PROJ-60"); + assert!(sub.depends_on.is_empty()); + assert_eq!(sub.external_parent.as_deref(), Some("jira:PROJ-42")); + } + + #[test] + fn test_linear_fixture_sync_maps_project_parent() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + service.apply_issues( + "linear", + "ENG", + &ProjectSyncConfig::default(), + crate::api::providers::kanban::linear_fixture_issues(), + ); + let issue = synced_ticket(temp.path(), "ENG-7"); + assert_eq!( + issue.depends_on, + vec!["linear:ENG-3", "linear:ENG-8", "linear:ENG-9"] + ); + assert_eq!( + issue.external_parent.as_deref(), + Some("linear:proj-billing") + ); + } + + #[test] + fn test_github_keys_survive_yaml_round_trip() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let issue = ExternalIssue { + key: "octo/hello#12".into(), + blocked_by: vec!["octo/hello#9".into()], + subtasks: vec!["octo/infra#2".into()], + parent: Some(crate::api::providers::kanban::ExternalParent { + key: "octo/hello#3".into(), + kind: crate::api::providers::kanban::ParentKind::Issue, + }), + ..sample_issue() + }; + service.apply_issues( + "github", + "PVT_x", + &ProjectSyncConfig::default(), + vec![issue], + ); + let ticket = synced_ticket(temp.path(), "octo/hello#12"); + assert_eq!( + ticket.depends_on, + vec!["github:octo/hello#9", "github:octo/infra#2"] + ); + assert_eq!( + ticket.external_parent.as_deref(), + Some("github:octo/hello#3") + ); + } + + #[test] + fn test_resync_refreshes_provider_edges_across_folders_without_duplicating() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let config = ProjectSyncConfig::default(); + let issue = ExternalIssue { + blocked_by: vec!["PROJ-12".into()], + ..sample_issue() + }; + service.apply_issues("jira", "PROJ", &config, vec![issue]); + + let mut ticket = synced_ticket(temp.path(), "PROJ-42"); + ticket + .set_depends_on(&["jira:PROJ-12".to_string(), "FEAT-0007".to_string()]) + .unwrap(); + let in_progress = temp.path().join(".tickets/in-progress"); + fs::create_dir_all(&in_progress).unwrap(); + fs::rename(&ticket.filepath, in_progress.join(&ticket.filename)).unwrap(); + + let rerun = service.apply_issues("jira", "PROJ", &config, vec![sample_issue()]); + assert_eq!(rerun.updated, vec!["PROJ-42"]); + assert!(rerun.created.is_empty()); + assert_eq!(ticket_count(temp.path()), 1); + let refreshed = synced_ticket(temp.path(), "PROJ-42"); + assert_eq!(refreshed.depends_on, vec!["FEAT-0007"]); + assert_eq!(refreshed.status, "queued"); + + let again = service.apply_issues("jira", "PROJ", &config, vec![sample_issue()]); + assert_eq!(again.skipped, vec!["PROJ-42"]); + } + + #[test] + fn test_same_key_from_another_provider_is_a_separate_ticket() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let config = ProjectSyncConfig::default(); + service.apply_issues("jira", "PROJ", &config, vec![sample_issue()]); + let result = service.apply_issues("linear", "PROJ", &config, vec![sample_issue()]); + assert_eq!(result.created, vec!["PROJ-42"]); + assert_eq!(ticket_count(temp.path()), 2); + } + + // ── Scoped sync into a campaign ───────────────────────────────────────── + + fn all_tickets(root: &std::path::Path) -> Vec { + ["queue", "in-progress", "completed"] + .iter() + .filter_map(|d| fs::read_dir(root.join(".tickets").join(d)).ok()) + .flatten() + .map(|e| Ticket::from_file(&e.unwrap().path()).unwrap()) + .collect() + } + + #[test] + fn test_epic_scope_becomes_a_campaign_planned_into_waves() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let result = service + .stamp_scope( + "jira", + "PROJ-1", + &ProjectSyncConfig::default(), + crate::api::providers::kanban::jira_epic_fixture(), + ) + .unwrap(); + assert_eq!(result.campaign.as_deref(), Some("CAMP-0001")); + assert_eq!(result.created.len(), 6, "{:?}", result.errors); + + let campaign = CampaignStore::new(&service.config) + .get("CAMP-0001") + .unwrap(); + assert_eq!(campaign.title, "Pricing overhaul"); + assert_eq!(campaign.external_ref.as_deref(), Some("jira:PROJ-1")); + assert!(campaign.body.contains("Ship v2 pricing.")); + + let all = all_tickets(temp.path()); + let members = crate::campaigns::members("CAMP-0001", &all); + assert_eq!(members.len(), 6); + assert!(members + .iter() + .all(|t| t.external_parent.as_deref() == Some("jira:PROJ-1"))); + + let plan = crate::campaigns::plan::plan(&service.config, &members, &all).unwrap(); + assert_eq!( + plan.waves, + vec![ + vec!["TASK-PROJ11", "TASK-PROJ12", "TASK-PROJ15"], + vec!["TASK-PROJ13", "TASK-PROJ16"], + vec!["TASK-PROJ14"], + ] + ); + assert_eq!( + crate::campaigns::plan::ready(&plan, &all), + vec!["TASK-PROJ11", "TASK-PROJ12", "TASK-PROJ15"] + ); + } + + #[test] + fn test_resync_reuses_campaign_and_keeps_reassigned_tickets() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let config = ProjectSyncConfig::default(); + let epic = crate::api::providers::kanban::jira_epic_fixture; + service + .stamp_scope("jira", "PROJ-1", &config, epic()) + .unwrap(); + + let mut moved = synced_ticket(temp.path(), "PROJ-16"); + moved.set_campaign(Some("CAMP-0099")).unwrap(); + + let rerun = service + .stamp_scope("jira", "PROJ-1", &config, epic()) + .unwrap(); + assert_eq!(rerun.campaign.as_deref(), Some("CAMP-0001")); + assert!(rerun.created.is_empty()); + assert_eq!(CampaignStore::new(&service.config).list().len(), 1); + assert_eq!( + synced_ticket(temp.path(), "PROJ-16").campaign.as_deref(), + Some("CAMP-0099") + ); + } + + #[test] + fn test_scoped_sync_without_premium_writes_nothing() { + let temp = tempfile::tempdir().unwrap(); + let service = sync_service(temp.path()); + let err = service + .apply_scope( + "jira", + "PROJ-1", + &ProjectSyncConfig::default(), + crate::api::providers::kanban::jira_epic_fixture(), + ) + .unwrap_err(); + assert!(err + .downcast_ref::() + .is_some_and(|e| e.feature == PremiumFeature::Campaigns)); + assert!(!temp.path().join(".tickets/campaigns").exists()); + assert_eq!(ticket_count(temp.path()), 0); + } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 0d190c44..67b76282 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -5,6 +5,7 @@ #![allow(unused_imports)] // Re-exports for future integration +pub mod campaign_runner; pub mod git_onboarding; pub mod kanban_issuetype_service; pub mod kanban_onboarding; diff --git a/src/services/ticket_transitions.rs b/src/services/ticket_transitions.rs index b9a524db..99c1f40a 100644 --- a/src/services/ticket_transitions.rs +++ b/src/services/ticket_transitions.rs @@ -44,6 +44,7 @@ pub async fn move_ticket( .await .map_err(|e| e.to_string())??; + crate::services::campaign_runner::wake().notify_one(); push_kanban_transition(state, ticket, column); Ok(()) } diff --git a/src/state.rs b/src/state.rs index ecea7ebe..c247085d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::PathBuf; use ts_rs::TS; @@ -63,11 +63,49 @@ pub struct State { #[serde(default)] pub multi_agent_groups: Vec, + /// Campaign id → runner bookkeeping + #[serde(default)] + pub campaign_runs: HashMap, + #[serde(skip)] #[ts(skip)] state_path: PathBuf, } +/// Runner bookkeeping for one campaign. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[ts(export)] +pub struct CampaignRun { + /// Ticket id → launches made by the runner + #[serde(default)] + pub launches: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, + /// Why the run paused, if it did + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pause_reason: Option, +} + +impl CampaignRun { + pub fn record_launch(&mut self, ticket_id: &str) { + *self.launches.entry(ticket_id.to_string()).or_default() += 1; + } + + /// Launches across the whole run. + pub fn launched(&self) -> u32 { + self.launches.values().sum() + } + + /// Relaunches of one ticket. + pub fn retries(&self, ticket_id: &str) -> u32 { + self.launches + .get(ticket_id) + .map_or(0, |n| n.saturating_sub(1)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct AgentState { @@ -307,6 +345,7 @@ impl State { project_llm_stats: HashMap::new(), project_collection_prefs: HashMap::new(), multi_agent_groups: Vec::new(), + campaign_runs: HashMap::new(), state_path, }) } @@ -1412,6 +1451,31 @@ mod tests { assert!(state.project_llm_stats.is_empty()); } + #[test] + fn test_campaign_runs_default_when_absent_and_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let config = test_config(&temp_dir); + fs::write( + temp_dir.path().join("state.json"), + r#"{"paused":false,"agents":[],"completed":[]}"#, + ) + .unwrap(); + assert!(State::load(&config).unwrap().campaign_runs.is_empty()); + + State::mutate(&config, |s| { + let run = s.campaign_runs.entry("CAMP-0001".into()).or_default(); + run.record_launch("FEAT-0001"); + run.record_launch("FEAT-0001"); + run.record_launch("FIX-0002"); + }) + .unwrap(); + let run = &State::load(&config).unwrap().campaign_runs["CAMP-0001"]; + assert_eq!(run.launched(), 3); + assert_eq!(run.retries("FEAT-0001"), 1); + assert_eq!(run.retries("FIX-0002"), 0); + assert_eq!(run.retries("TASK-0003"), 0); + } + #[test] fn test_state_load_corrupted_json() { let temp_dir = TempDir::new().unwrap(); @@ -1486,7 +1550,7 @@ mod tests { // UUID v4 format: 8-4-4-4-12 hex chars assert_eq!(id.len(), 36); - assert!(id.chars().filter(|c| *c == '-').count() == 4); + assert_eq!(id.chars().filter(|c| *c == '-').count(), 4); } #[test] diff --git a/src/steps/manager.rs b/src/steps/manager.rs index a7863939..257ddfaf 100644 --- a/src/steps/manager.rs +++ b/src/steps/manager.rs @@ -456,6 +456,9 @@ mod tests { fn make_test_ticket(ticket_type: &str, step: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241221-1430-FEAT-gamesvc-test.md".to_string(), filepath: "/test/path".to_string(), timestamp: "20241221-1430".to_string(), diff --git a/src/steps/session.rs b/src/steps/session.rs index 4c058437..42a406d3 100644 --- a/src/steps/session.rs +++ b/src/steps/session.rs @@ -259,6 +259,9 @@ mod tests { fn make_test_ticket() -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241221-1430-FEAT-gamesvc-test.md".to_string(), filepath: "/test/path".to_string(), timestamp: "20241221-1430".to_string(), diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index 4342b777..7f75a6d9 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -635,6 +635,9 @@ mod tests { fn make_test_ticket(project: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: format!("20241225-1200-TASK-{project}-test.md"), filepath: format!("/tmp/tickets/queue/20241225-1200-TASK-{project}-test.md"), timestamp: "20241225-1200".to_string(), diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index 51b894dc..d8cb16ad 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -887,6 +887,12 @@ fn test_wizard_walk_visits_every_catalog_step() { .execution_target_state .select(Some(super::CODER_TARGET_OPTION_INDEX)); } + // Entering onboarding re-detects tmux on the host; pin it available. + if screen.step == SetupStep::TmuxOnboarding { + screen.tmux_status = TmuxDetectionStatus::Available { + version: "3.4".to_string(), + }; + } // `confirm` commits the highlighted wrapper, so steer the list. if screen.step == SetupStep::SessionWrapperChoice { let i = SessionWrapperOption::all() diff --git a/src/workflow_gen/agnt.rs b/src/workflow_gen/agnt.rs index e9b256c6..eec49c03 100644 --- a/src/workflow_gen/agnt.rs +++ b/src/workflow_gen/agnt.rs @@ -89,7 +89,7 @@ struct AgntNode { parameters: Value, } -/// A directed edge. AGNT's engine traverses `start.id` → `end.id` and tracks the +/// A directed edge. AGNT's engine traverses `start.id` > `end.id` and tracks the /// edge by `id`, so all three are required (a bare `{source,target}` won't run). #[derive(Debug, Serialize)] struct AgntEdge { @@ -400,6 +400,9 @@ mod tests { fn ticket(ticket_type: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241221-1430-FEAT-gamesvc-test.md".to_string(), filepath: "/test/path".to_string(), timestamp: "20241221-1430".to_string(), @@ -657,6 +660,7 @@ mod tests { fn config_with_remote_delegator(name: &str, platform: &str, id: &str) -> Config { let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + governance: None, git: None, name: name.to_string(), llm_tool: "anthropic".to_string(), @@ -735,6 +739,7 @@ mod tests { let it = issuetype_with_step_agent("local-claude"); let mut config = Config::default(); config.delegators.push(crate::config::Delegator { + governance: None, git: None, name: "local-claude".to_string(), llm_tool: "claude".to_string(), diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs index 6370a98e..91206d7f 100644 --- a/src/workflow_gen/command.rs +++ b/src/workflow_gen/command.rs @@ -154,6 +154,9 @@ fn pipeline_env_for(config: &Config, ticket: &Ticket, issuetype: &IssueType) -> fn preview_ticket(issuetype: &IssueType) -> Ticket { let key = &issuetype.key; Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: format!("{key}-PREVIEW.md"), filepath: format!("preview/{key}-PREVIEW.md"), timestamp: "preview".to_string(), @@ -189,6 +192,9 @@ mod tests { fn ticket(ticket_type: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241221-1430-FEAT-gamesvc-test.md".to_string(), filepath: "/test/path".to_string(), timestamp: "20241221-1430".to_string(), diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index 79904dc3..dc8a1341 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -637,6 +637,9 @@ mod tests { fn ticket(id: &str, project: &str, summary: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241221-1430-FEAT-proj-test.md".to_string(), filepath: "/test/path".to_string(), timestamp: "20241221-1430".to_string(), diff --git a/src/workflow_gen/mod.rs b/src/workflow_gen/mod.rs index 144479de..be346e08 100644 --- a/src/workflow_gen/mod.rs +++ b/src/workflow_gen/mod.rs @@ -40,6 +40,9 @@ mod tests { /// Build a ticket with the given id/project/summary; other fields fixed. fn ticket(id: &str, project: &str, summary: &str) -> Ticket { Ticket { + depends_on: Vec::new(), + campaign: None, + external_parent: None, filename: "20241221-1430-FEAT-proj-test.md".to_string(), filepath: "/test/path".to_string(), timestamp: "20241221-1430".to_string(), diff --git a/tests/campaign_common/mod.rs b/tests/campaign_common/mod.rs new file mode 100644 index 00000000..32829af3 --- /dev/null +++ b/tests/campaign_common/mod.rs @@ -0,0 +1,519 @@ +//! Campaign scenarios: fixture directories replayed tick by tick against the runner. +//! +//! A fixture under `tests/fixtures/campaigns//` holds `campaign.md`, +//! member tickets under `queue/` (and optionally `completed/`), and +//! `scenario.toml` with config overrides, scripted agent and operator actions, +//! and the expected trace. `base = ""` inherits another fixture's files. + +#![allow(dead_code)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use anyhow::{anyhow, Result}; +use serde::Deserialize; + +use operator::campaigns::model::{Campaign, CampaignStatus}; +use operator::campaigns::runner::{BlockReason, Slots}; +use operator::campaigns::{plan, CampaignStore}; +use operator::config::{Config, SessionWrapperType}; +use operator::queue::{Queue, Ticket}; +use operator::services::campaign_runner::{self, CampaignPort, TickReport}; + +pub const CAMPAIGN: &str = "CAMP-0001"; + +pub fn fixtures_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/campaigns") +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Overrides { + pub max_parallel: Option, + pub max_agents_per_repo: Option, + pub wrapper: Option, + /// Premium is revoked from this tick on. + pub revoke_license_at: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Expect { + #[serde(default)] + pub waves: Option>>, + pub plan_error: Option, + pub start_error: Option, + pub final_status: String, + pub pause_reason: Option, + #[serde(default)] + pub blocked: BTreeMap, + #[serde(default)] + pub stalled: Vec, + #[serde(default)] + pub completed: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + Complete, + Requeue, + Error, + Orphan, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentScript { + pub ticket: String, + #[serde(default)] + pub outcomes: Vec, + #[serde(default = "one")] + pub ticks: u32, +} + +fn one() -> u32 { + 1 +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OperatorAction { + pub tick: usize, + pub complete: String, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedTick { + #[serde(default)] + pub launch: Vec, + #[serde(default)] + pub awaiting_operator: Vec, + pub status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Spec { + pub base: Option, + #[serde(default)] + pub config: Overrides, + pub expect: Expect, + #[serde(default)] + pub agent: Vec, + #[serde(default)] + pub operator: Vec, + #[serde(default)] + pub tick: Vec, +} + +/// A fixture copied into an isolated workspace. +pub struct Scenario { + pub name: String, + pub spec: Spec, + pub config: Config, + _dir: tempfile::TempDir, +} + +fn copy_tree(src: &Path, dst: &Path) { + for entry in fs::read_dir(src).into_iter().flatten().flatten() { + let path = entry.path(); + let target = dst.join(entry.file_name()); + if path.is_dir() { + fs::create_dir_all(&target).unwrap(); + copy_tree(&path, &target); + } else { + fs::copy(&path, &target).unwrap(); + } + } +} + +fn install(fixture: &Path, tickets: &Path) { + for folder in ["queue", "completed"] { + let src = fixture.join(folder); + if src.is_dir() { + copy_tree(&src, &tickets.join(folder)); + } + } + let campaign = fixture.join("campaign.md"); + if campaign.is_file() { + fs::copy(campaign, tickets.join("campaigns/CAMP-0001-scenario.md")).unwrap(); + } +} + +/// Copy a fixture (and its `base`) into a tickets directory. +pub fn install_fixture(name: &str, spec: &Spec, tickets: &Path) { + for sub in ["queue", "in-progress", "completed", "campaigns"] { + fs::create_dir_all(tickets.join(sub)).unwrap(); + } + if let Some(base) = &spec.base { + install(&fixtures_root().join(base), tickets); + } + install(&fixtures_root().join(name), tickets); +} + +pub fn spec(name: &str) -> Spec { + let raw = fs::read_to_string(fixtures_root().join(name).join("scenario.toml")) + .unwrap_or_else(|e| panic!("{name}/scenario.toml: {e}")); + toml::from_str(&raw).unwrap_or_else(|e| panic!("{name}/scenario.toml: {e}")) +} + +impl Scenario { + pub fn load(name: &str) -> Self { + let spec = spec(name); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let tickets = root.join("tickets"); + for sub in ["queue", "in-progress", "completed", "campaigns", "operator"] { + fs::create_dir_all(tickets.join(sub)).unwrap(); + } + install_fixture(name, &spec, &tickets); + + let mut config = Config::default(); + config.paths.tickets = tickets.to_string_lossy().into_owned(); + config.paths.state = tickets.join("operator").to_string_lossy().into_owned(); + config.paths.projects = root.to_string_lossy().into_owned(); + config.profile.id = uuid::Uuid::new_v4(); + if let Some(n) = spec.config.max_parallel { + config.agents.max_parallel = n; + } + if let Some(n) = spec.config.max_agents_per_repo { + config.agents.max_agents_per_repo = n; + } + if let Some(wrapper) = spec.config.wrapper { + config.sessions.wrapper = wrapper; + } + Self { + name: name.to_string(), + spec, + config, + _dir: dir, + } + } + + pub fn tickets(&self) -> Vec { + let queue = Queue::new(&self.config).unwrap(); + let mut all = queue.list_queue().unwrap(); + all.extend(queue.list_in_progress().unwrap()); + all.extend(queue.list_completed().unwrap()); + all + } + + pub fn campaign(&self) -> Campaign { + CampaignStore::new(&self.config) + .get(CAMPAIGN) + .expect("fixture campaign") + } + + fn folder_of(&self, id: &str) -> Option { + self.tickets() + .into_iter() + .find(|t| t.id == id) + .and_then(|t| { + Path::new(&t.filepath) + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + }) + } +} + +struct Running { + project: String, + remaining: u32, + outcome: Outcome, +} + +/// Simulated agents: launching claims the ticket; outcomes land as the harness advances. +pub struct SimPort { + config: Config, + scripts: BTreeMap, + attempts: Mutex>, + running: Mutex>, + entitled: AtomicBool, +} + +impl SimPort { + pub fn new(config: &Config, scripts: &[AgentScript]) -> Self { + Self { + config: config.clone(), + scripts: scripts + .iter() + .map(|s| (s.ticket.clone(), s.clone())) + .collect(), + attempts: Mutex::default(), + running: Mutex::default(), + entitled: AtomicBool::new(true), + } + } + + pub fn set_entitled(&self, premium: bool) { + self.entitled.store(premium, Ordering::SeqCst); + } + + pub fn running(&self) -> usize { + self.running.lock().unwrap().len() + } + + /// One unit of agent time: finished agents complete, requeue or vanish. + pub fn advance(&self) { + let queue = Queue::new(&self.config).unwrap(); + let mut running = self.running.lock().unwrap(); + let mut done = Vec::new(); + for (id, agent) in running.iter_mut() { + agent.remaining = agent.remaining.saturating_sub(1); + if agent.remaining == 0 { + done.push((id.clone(), agent.outcome)); + } + } + for (id, outcome) in done { + running.remove(&id); + let ticket = queue.find_ticket(&id).unwrap().expect("running ticket"); + match outcome { + Outcome::Complete => queue.complete_ticket(&ticket).unwrap(), + Outcome::Requeue => queue.return_to_queue(&ticket).unwrap(), + Outcome::Orphan | Outcome::Error => {} + } + } + } +} + +impl SimPort { + fn start(&self, ticket: &Ticket) -> Result { + let attempt = { + let mut attempts = self.attempts.lock().unwrap(); + let n = attempts.entry(ticket.id.clone()).or_default(); + *n += 1; + *n - 1 + }; + let script = self.scripts.get(&ticket.id); + let outcome = script + .and_then(|s| s.outcomes.get(attempt).copied()) + .unwrap_or(Outcome::Complete); + if outcome == Outcome::Error { + return Err(anyhow!("simulated provisioning failure")); + } + Queue::new(&self.config)?.claim_ticket(ticket)?; + self.running.lock().unwrap().insert( + ticket.id.clone(), + Running { + project: ticket.project.clone(), + remaining: script.map_or(1, |s| s.ticks.max(1)), + outcome, + }, + ); + Ok(format!("sim-{}-{attempt}", ticket.id)) + } +} + +impl CampaignPort for SimPort { + fn launch( + &self, + _campaign: &Campaign, + ticket: &Ticket, + ) -> impl std::future::Future> + Send { + std::future::ready(self.start(ticket)) + } + + fn entitled(&self) -> bool { + self.entitled.load(Ordering::SeqCst) + } + + fn live_agents(&self) -> BTreeSet { + self.running.lock().unwrap().keys().cloned().collect() + } + + fn slots(&self) -> Slots { + let running = self.running.lock().unwrap(); + let mut per_repo_busy = BTreeMap::new(); + for agent in running.values() { + *per_repo_busy.entry(agent.project.clone()).or_default() += 1; + } + Slots { + global: self + .config + .agents + .max_parallel + .saturating_sub(running.len()), + per_repo_limit: self.config.agents.max_agents_per_repo.max(1), + per_repo_busy, + } + } +} + +fn status_name(status: CampaignStatus) -> String { + serde_json::to_value(status) + .unwrap() + .as_str() + .unwrap() + .to_string() +} + +fn reason_name(reason: &BlockReason) -> String { + serde_json::to_value(reason).unwrap()["kind"] + .as_str() + .unwrap() + .to_string() +} + +fn observed(report: &TickReport) -> ExpectedTick { + ExpectedTick { + launch: report.launched.clone(), + awaiting_operator: report.awaiting_operator.clone(), + status: status_name(report.status), + } +} + +/// Invariants that hold on every tick of every scenario. +fn check_invariants( + s: &Scenario, + before: &[Ticket], + report: &TickReport, + port: &SimPort, + entitled: bool, +) { + let name = &s.name; + let done = |id: &str| before.iter().any(|t| t.id == id && plan::is_completed(t)); + for id in &report.launched { + assert!(entitled, "{name}: launched {id} without entitlement"); + let ticket = before.iter().find(|t| &t.id == id).unwrap(); + assert!(!ticket.is_paired(), "{name}: launched paired ticket {id}"); + for dep in &ticket.depends_on { + assert!(done(dep), "{name}: launched {id} before {dep} completed"); + } + } + let slots = port.slots(); + assert!( + port.running() <= s.config.agents.max_parallel, + "{name}: global slots exceeded" + ); + for (repo, busy) in &slots.per_repo_busy { + assert!( + *busy <= slots.per_repo_limit, + "{name}: repo {repo} over its limit" + ); + } + let campaign = s.campaign(); + let policy = campaign.governance_overrides.unwrap_or_default(); + if let Some(cap) = policy.max_parallel { + let active = s + .tickets() + .iter() + .filter(|t| t.campaign.as_deref() == Some(CAMPAIGN)) + .filter(|t| s.folder_of(&t.id).as_deref() == Some("in-progress")) + .count(); + assert!( + active <= cap as usize, + "{name}: max_parallel {cap} exceeded" + ); + } + let state = operator::state::State::load(&s.config).unwrap(); + if let (Some(run), Some(cap)) = (state.campaign_runs.get(CAMPAIGN), policy.max_retries) { + for (id, n) in &run.launches { + assert!(*n <= cap + 1, "{name}: {id} launched {n} times"); + } + } +} + +/// Replay one fixture and assert its structure, trace, invariants and outcome. +pub async fn run(name: &str) { + let s = Scenario::load(name); + let expect = &s.spec.expect; + + let all = s.tickets(); + let members = operator::campaigns::members(CAMPAIGN, &all); + assert!(!members.is_empty(), "{name}: no members"); + let planned = plan::plan(&s.config, &members, &all); + match (&expect.plan_error, &planned) { + (Some(want), Err(e)) => assert!(e.to_string().contains(want), "{name}: {e}"), + (Some(want), Ok(p)) => panic!("{name}: expected plan error '{want}', got {p:?}"), + (None, Err(e)) => panic!("{name}: plan failed: {e}"), + (None, Ok(p)) => { + if let Some(waves) = &expect.waves { + assert_eq!(&p.waves, waves, "{name}: waves"); + } + } + } + + let port = SimPort::new(&s.config, &s.spec.agent); + let started = campaign_runner::start_campaign(&s.config, &port, CAMPAIGN); + if let Some(want) = &expect.start_error { + let e = started.expect_err("start must be refused"); + assert!(e.to_string().contains(want), "{name}: {e}"); + assert_eq!(status_name(s.campaign().status), expect.final_status); + assert_eq!(port.running(), 0, "{name}: refused start launched"); + return; + } + started.unwrap_or_else(|e| panic!("{name}: start failed: {e}")); + + let bound = members.len() * 4; + let mut trace = Vec::new(); + let mut last = None; + for n in 0..bound { + if s.spec.config.revoke_license_at.is_some_and(|at| n >= at) { + port.set_entitled(false); + } + let queue = Queue::new(&s.config).unwrap(); + for action in s.spec.operator.iter().filter(|a| a.tick == n) { + let ticket = queue.find_ticket(&action.complete).unwrap().unwrap(); + queue.complete_ticket(&ticket).unwrap(); + } + let before = s.tickets(); + let report = campaign_runner::tick(&s.config, &port, CAMPAIGN) + .await + .unwrap_or_else(|e| panic!("{name}: tick {n}: {e}")); + check_invariants(&s, &before, &report, &port, port.entitled()); + trace.push(observed(&report)); + let terminal = matches!( + report.status, + CampaignStatus::Completed | CampaignStatus::Paused + ); + let pending_operator = s.spec.operator.iter().any(|a| a.tick > n); + let idle = report.launched.is_empty() && port.running() == 0 && !pending_operator; + last = Some(report); + if terminal || idle { + break; + } + port.advance(); + } + let last = last.expect("at least one tick"); + assert!( + trace.len() < bound, + "{name}: did not settle within {bound} ticks" + ); + + assert_eq!(trace, s.spec.tick, "{name}: trace"); + assert_eq!( + status_name(last.status), + expect.final_status, + "{name}: final status" + ); + assert_eq!( + status_name(s.campaign().status), + expect.final_status, + "{name}: stored status" + ); + if let Some(want) = &expect.pause_reason { + let got = last.pause_reason.clone().unwrap_or_default(); + assert!(got.contains(want), "{name}: pause reason '{got}'"); + } + let blocked: BTreeMap = last + .blocked + .iter() + .filter(|(id, _)| expect.blocked.contains_key(*id)) + .map(|(id, r)| (id.clone(), reason_name(r))) + .collect(); + assert_eq!(blocked, expect.blocked, "{name}: blocked"); + assert_eq!(last.stalled, expect.stalled, "{name}: stalled"); + for id in &expect.completed { + assert_eq!( + s.folder_of(id).as_deref(), + Some("completed"), + "{name}: {id} not completed" + ); + } +} diff --git a/tests/campaign_launch_integration.rs b/tests/campaign_launch_integration.rs new file mode 100644 index 00000000..0bdab197 --- /dev/null +++ b/tests/campaign_launch_integration.rs @@ -0,0 +1,226 @@ +//! Campaign scenarios through the real launcher: tmux sessions running the mock LLM. +//! +//! Replays fixtures from `tests/fixtures/campaigns/` with `LauncherPort`. The +//! test plays the agents: once a ticket's session appears it kills the session +//! and completes the ticket, then ticks again. +//! +//! ```bash +//! OPERATOR_LAUNCH_TEST_ENABLED=true cargo test --test campaign_launch_integration -- --test-threads=1 --nocapture +//! ``` + +mod campaign_common; +mod launch_common; + +use std::collections::BTreeSet; +use std::fs; +use std::process::Command; +use std::time::{Duration, Instant}; + +use anyhow::Result; + +use campaign_common::{ExpectedTick, CAMPAIGN}; +use launch_common::{LaunchTestContext, WrapperTestMode}; +use operator::campaigns::model::{Campaign, CampaignStatus}; +use operator::campaigns::runner::Slots; +use operator::config::Config; +use operator::queue::{Queue, Ticket}; +use operator::services::campaign_runner::{self, CampaignPort, LauncherPort}; +use operator::state::State; + +fn tmux_available() -> bool { + Command::new("tmux") + .arg("-V") + .output() + .is_ok_and(|o| o.status.success()) +} + +fn sessions() -> Vec { + Command::new("tmux") + .args(["list-sessions", "-F", "#{session_name}"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|s| s.starts_with("op-")) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn wait_for_session(ticket_id: &str) -> Option { + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(15) { + if let Some(s) = sessions().into_iter().find(|s| s.contains(ticket_id)) { + return Some(s); + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +fn kill(session: &str) { + let _ = Command::new("tmux") + .args(["kill-session", "-t", session]) + .output(); +} + +/// The real port, licensed for the test. +struct Licensed(LauncherPort); + +impl CampaignPort for Licensed { + fn launch( + &self, + campaign: &Campaign, + ticket: &Ticket, + ) -> impl std::future::Future> + Send { + self.0.launch(campaign, ticket) + } + + fn entitled(&self) -> bool { + true + } + + fn live_agents(&self) -> BTreeSet { + self.0.live_agents() + } + + fn slots(&self) -> Slots { + self.0.slots() + } +} + +struct Run { + ctx: LaunchTestContext, + config: Config, + launched: Vec, +} + +impl Drop for Run { + fn drop(&mut self) { + for id in &self.launched { + for s in sessions().into_iter().filter(|s| s.contains(id.as_str())) { + kill(&s); + } + } + } +} + +impl Run { + fn new(name: &str) -> Self { + let ctx = LaunchTestContext::new(&format!("campaign_{name}"), WrapperTestMode::Tmux); + for project in ["api", "web"] { + let dir = ctx.projects_path.join(project); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("CLAUDE.md"), format!("# {project}\n")).unwrap(); + } + let spec = campaign_common::spec(name); + campaign_common::install_fixture(name, &spec, &ctx.tickets_path); + let mut config = Config::load(Some(ctx.config_path.to_str().unwrap())).unwrap(); + if let Some(n) = spec.config.max_parallel { + config.agents.max_parallel = n; + } + if let Some(n) = spec.config.max_agents_per_repo { + config.agents.max_agents_per_repo = n; + } + std::env::set_var("MOCK_LLM_OUTPUT_DIR", ctx.output_dir.path()); + Self { + ctx, + config, + launched: Vec::new(), + } + } + + fn wait_for_invocations(&self, n: usize) { + let start = Instant::now(); + while self.ctx.get_invocations().len() < n && start.elapsed() < Duration::from_secs(15) { + std::thread::sleep(Duration::from_millis(100)); + } + } + + /// Play the agent: its session started, so end it and complete the ticket. + fn finish(&self, ticket_id: &str) { + let session = wait_for_session(ticket_id) + .unwrap_or_else(|| panic!("no tmux session for {ticket_id}: {:?}", sessions())); + kill(&session); + let queue = Queue::new(&self.config).unwrap(); + let ticket = queue.find_ticket(ticket_id).unwrap().unwrap(); + queue.complete_ticket(&ticket).unwrap(); + State::mutate(&self.config, |s| { + s.agents.retain(|a| a.ticket_id != ticket_id); + }) + .unwrap(); + } +} + +async fn replay(name: &str) { + let spec = campaign_common::spec(name); + let mut run = Run::new(name); + let port = Licensed(LauncherPort::new(run.config.clone())); + campaign_runner::start_campaign(&run.config, &port, CAMPAIGN).expect("start"); + + let mut trace = Vec::new(); + for n in 0..spec.tick.len() + 2 { + let queue = Queue::new(&run.config).unwrap(); + for action in spec.operator.iter().filter(|a| a.tick == n) { + let ticket = queue.find_ticket(&action.complete).unwrap().unwrap(); + queue.complete_ticket(&ticket).unwrap(); + } + let report = campaign_runner::tick(&run.config, &port, CAMPAIGN) + .await + .unwrap_or_else(|e| panic!("{name}: tick {n}: {e}")); + let live = sessions() + .iter() + .filter(|s| run.launched.iter().any(|id| s.contains(id.as_str()))) + .count(); + assert!( + live + report.launched.len() <= run.config.agents.max_parallel, + "{name}: more sessions than slots" + ); + trace.push(ExpectedTick { + launch: report.launched.clone(), + awaiting_operator: report.awaiting_operator.clone(), + status: serde_json::to_value(report.status) + .unwrap() + .as_str() + .unwrap() + .to_string(), + }); + run.launched.extend(report.launched.iter().cloned()); + run.wait_for_invocations(run.launched.len()); + for id in &report.launched { + run.finish(id); + } + if report.status == CampaignStatus::Completed { + break; + } + } + assert_eq!(trace, spec.tick, "{name}: trace"); + let invocations = run.ctx.get_invocations().len(); + assert_eq!( + invocations, + run.launched.len(), + "{name}: one mock LLM invocation per launch" + ); +} + +macro_rules! skip_unless_enabled { + () => { + if !launch_common::launch_tests_enabled() || !tmux_available() { + eprintln!("Skipping: needs OPERATOR_LAUNCH_TEST_ENABLED=true and tmux"); + return; + } + }; +} + +#[tokio::test] +async fn diamond_launches_waves_through_tmux() { + skip_unless_enabled!(); + replay("diamond").await; +} + +#[tokio::test] +async fn gated_chain_launches_through_tmux() { + skip_unless_enabled!(); + replay("gated-chain").await; +} diff --git a/tests/campaign_mcp.rs b/tests/campaign_mcp.rs new file mode 100644 index 00000000..2a52545f --- /dev/null +++ b/tests/campaign_mcp.rs @@ -0,0 +1,210 @@ +//! The MCP planning flow an outside agent drives: preview, bulk create, then campaign tools. + +use std::fs; +use std::path::Path; + +use serde_json::{json, Value}; + +use operator::campaigns::{CampaignStore, NewCampaign}; +use operator::config::Config; +use operator::mcp::tools::execute_tool; +use operator::rest::dto::auth::Scope; +use operator::rest::state::ApiState; + +struct Workspace { + state: ApiState, + config: Config, + _dir: tempfile::TempDir, +} + +impl Workspace { + fn new(write_tools: bool) -> Self { + let dir = tempfile::tempdir().unwrap(); + let tickets = dir.path().join("tickets"); + for sub in ["queue", "in-progress", "completed"] { + fs::create_dir_all(tickets.join(sub)).unwrap(); + } + let mut config = Config::default(); + config.paths.tickets = tickets.to_string_lossy().into_owned(); + config.paths.state = dir.path().join("state").to_string_lossy().into_owned(); + config.paths.projects = dir.path().to_string_lossy().into_owned(); + config.profile.id = uuid::Uuid::new_v4(); + config.mcp.expose_ticket_write_tools = write_tools; + Self { + state: ApiState::new(config.clone(), tickets), + config, + _dir: dir, + } + } + + async fn call(&self, tool: &str, args: Value) -> Result { + execute_tool(tool, args, &self.state, &Scope::ALL).await + } + + fn queued(&self) -> Vec { + let mut names: Vec = + fs::read_dir(Path::new(&self.config.paths.tickets).join("queue")) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } +} + +fn diamond(campaign: Option<&str>) -> Value { + json!({ + "campaign": campaign, + "tickets": [ + {"ref": "schema", "template": "FEAT", "project": "api", "summary": "Pricing schema"}, + {"ref": "api", "template": "FEAT", "project": "api", "summary": "Pricing API", "depends_on": ["schema"]}, + {"ref": "ui", "template": "FIX", "project": "web", "summary": "Pricing page", "depends_on": ["schema"]}, + {"ref": "docs", "template": "TASK", "project": "web", "summary": "Pricing docs", "depends_on": ["api", "ui"]}, + {"ref": "rollout", "template": "TASK", "project": "api", "summary": "Roll out", "depends_on": ["docs", "jira:PRICE-1"]} + ] + }) +} + +#[tokio::test] +async fn preview_allocates_ids_and_waves_without_writing() { + let ws = Workspace::new(false); + let preview = ws + .call("operator_preview_ticket_batch", diamond(None)) + .await + .unwrap(); + assert_eq!(preview["valid"], true, "{preview}"); + let ids: Vec<&str> = preview["tickets"] + .as_array() + .unwrap() + .iter() + .map(|t| t["id"].as_str().unwrap()) + .collect(); + assert_eq!( + ids, + [ + "FEAT-0001", + "FEAT-0002", + "FIX-0001", + "TASK-0001", + "TASK-0002" + ] + ); + assert_eq!( + preview["waves"], + json!([ + ["FEAT-0001"], + ["FIX-0001", "FEAT-0002"], + ["TASK-0001"], + ["TASK-0002"] + ]) + ); + assert_eq!( + preview["tickets"][4]["depends_on"], + json!(["TASK-0001", "jira:PRICE-1"]) + ); + assert!(ws.queued().is_empty()); +} + +#[tokio::test] +async fn bulk_create_needs_write_tools() { + let ws = Workspace::new(false); + let err = ws + .call("operator_create_tickets_bulk", diamond(None)) + .await + .unwrap_err(); + assert!(err.contains("expose_ticket_write_tools"), "{err}"); + assert!(ws.queued().is_empty()); +} + +#[tokio::test] +async fn bulk_create_is_all_or_nothing() { + let ws = Workspace::new(true); + let mut batch = diamond(None); + batch["tickets"] + .as_array_mut() + .unwrap() + .push(json!({"ref": "bad", "template": "NOPE"})); + let err = ws + .call("operator_create_tickets_bulk", batch) + .await + .unwrap_err(); + assert!(err.starts_with("validation_error"), "{err}"); + assert!(err.contains("unknown template"), "{err}"); + assert!(ws.queued().is_empty(), "nothing is written"); +} + +#[tokio::test] +async fn bulk_create_inside_a_campaign_wires_dependencies() { + let ws = Workspace::new(true); + CampaignStore::new(&ws.config) + .create(NewCampaign { + title: "Pricing".into(), + ..NewCampaign::default() + }) + .unwrap(); + let created = ws + .call("operator_create_tickets_bulk", diamond(Some("CAMP-0001"))) + .await + .unwrap(); + assert_eq!(created["tickets"].as_array().unwrap().len(), 5); + assert_eq!(ws.queued().len(), 5); + + let queue = operator::queue::Queue::new(&ws.config).unwrap(); + let docs = queue.find_ticket("TASK-0001").unwrap().unwrap(); + assert_eq!(docs.campaign.as_deref(), Some("CAMP-0001")); + assert_eq!(docs.depends_on, ["FEAT-0002", "FIX-0001"]); + assert_eq!(docs.project, "web"); + + let again = ws + .call("operator_preview_ticket_batch", diamond(None)) + .await + .unwrap(); + assert_eq!( + again["tickets"][0]["id"], "FEAT-0003", + "ids continue past the batch" + ); +} + +#[tokio::test] +async fn campaign_tools_are_premium_and_launch_touches_nothing() { + let ws = Workspace::new(true); + CampaignStore::new(&ws.config) + .create(NewCampaign { + title: "Pricing".into(), + ..NewCampaign::default() + }) + .unwrap(); + ws.call("operator_create_tickets_bulk", diamond(Some("CAMP-0001"))) + .await + .unwrap(); + + for (tool, args) in [ + ("operator_create_campaign", json!({"title": "Another"})), + ( + "operator_assign_ticket_campaign", + json!({"id": "FEAT-0001", "campaign": null}), + ), + ("operator_launch_campaign", json!({"id": "CAMP-0001"})), + ("operator_campaign_status", json!({"id": "CAMP-0001"})), + ] { + let err = ws.call(tool, args).await.unwrap_err(); + assert!(err.starts_with("not_entitled"), "{tool}: {err}"); + } + assert_eq!(ws.queued().len(), 5, "no ticket was claimed"); + let state = operator::state::State::load(&ws.config).unwrap(); + assert!(state.agents.is_empty()); + assert!(state.campaign_runs.is_empty()); +} + +#[test] +fn campaign_tool_schemas_are_objects() { + for tool in operator::mcp::tools::all_tool_definitions() + .into_iter() + .filter(|t| { + t.name.contains("campaign") || t.name.contains("batch") || t.name.contains("bulk") + }) + { + assert_eq!(tool.input_schema["type"], "object", "{}", tool.name); + } +} diff --git a/tests/campaign_scenarios.rs b/tests/campaign_scenarios.rs new file mode 100644 index 00000000..e4031d64 --- /dev/null +++ b/tests/campaign_scenarios.rs @@ -0,0 +1,109 @@ +//! Campaign runner scenarios, replayed in-process against simulated agents. +//! +//! Each fixture under `tests/fixtures/campaigns/` is one campaign. Add a +//! directory and a line to `SCENARIOS` to test a new one. + +mod campaign_common; + +const SCENARIOS: &[&str] = &[ + "diamond", + "gated-chain", + "governed-fan", + "cycle", + "blocked-unknown-dep", + "launch-error", + "orphan", + "license-lapse", + "client-wrapper", + "completed-campaign", +]; + +macro_rules! scenario { + ($test:ident, $name:literal) => { + #[tokio::test] + async fn $test() { + campaign_common::run($name).await; + } + }; +} + +scenario!(diamond_pair_launches_together, "diamond"); +scenario!(gated_chain_waits_for_operator, "gated-chain"); +scenario!(governed_fan_throttles_and_pauses_on_retries, "governed-fan"); +scenario!(cycle_is_refused_at_start, "cycle"); +scenario!( + unknown_dependency_blocks_only_its_ticket, + "blocked-unknown-dep" +); +scenario!(launch_error_pauses_run, "launch-error"); +scenario!(orphan_reported_stalled_not_relaunched, "orphan"); +scenario!(license_lapse_stops_new_launches, "license-lapse"); +scenario!(client_wrapper_refused_before_running, "client-wrapper"); +scenario!(completed_campaign_cannot_restart, "completed-campaign"); + +use campaign_common::{Scenario, SimPort, CAMPAIGN}; +use operator::campaigns::model::CampaignStatus; +use operator::services::campaign_runner; + +#[tokio::test] +async fn assess_previews_first_tick_without_side_effects() { + let s = Scenario::load("diamond"); + let port = SimPort::new(&s.config, &[]); + campaign_runner::start_campaign(&s.config, &port, CAMPAIGN).unwrap(); + let preview = campaign_runner::assess(&s.config, &port, CAMPAIGN).unwrap(); + assert_eq!(preview.launched, vec!["FEAT-0001"]); + assert_eq!(port.running(), 0); + let state = operator::state::State::load(&s.config).unwrap(); + assert!(state.campaign_runs[CAMPAIGN].launches.is_empty()); + let report = campaign_runner::tick(&s.config, &port, CAMPAIGN) + .await + .unwrap(); + assert_eq!(report.launched, preview.launched); +} + +#[tokio::test] +async fn pause_then_resume_clears_reason_and_running_cannot_restart() { + let s = Scenario::load("diamond"); + let port = SimPort::new(&s.config, &[]); + campaign_runner::start_campaign(&s.config, &port, CAMPAIGN).unwrap(); + let err = campaign_runner::start_campaign(&s.config, &port, CAMPAIGN).unwrap_err(); + assert!(err.to_string().contains("already running"), "{err}"); + + campaign_runner::pause_campaign(&s.config, CAMPAIGN, "paused by operator").unwrap(); + assert_eq!(s.campaign().status, CampaignStatus::Paused); + let state = operator::state::State::load(&s.config).unwrap(); + assert_eq!( + state.campaign_runs[CAMPAIGN].pause_reason.as_deref(), + Some("paused by operator") + ); + let report = campaign_runner::tick(&s.config, &port, CAMPAIGN) + .await + .unwrap(); + assert!(report.launched.is_empty(), "a paused run launches nothing"); + + campaign_runner::start_campaign(&s.config, &port, CAMPAIGN).unwrap(); + assert_eq!(s.campaign().status, CampaignStatus::Running); + let state = operator::state::State::load(&s.config).unwrap(); + assert!(state.campaign_runs[CAMPAIGN].pause_reason.is_none()); +} + +#[test] +fn every_fixture_is_a_listed_scenario() { + let mut found: Vec = std::fs::read_dir(campaign_common::fixtures_root()) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().join("scenario.toml").is_file()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + found.sort(); + let mut listed: Vec = SCENARIOS.iter().map(ToString::to_string).collect(); + listed.sort(); + assert_eq!(found, listed); +} + +#[test] +fn every_scenario_parses() { + for name in SCENARIOS { + campaign_common::spec(name); + } +} diff --git a/tests/campaign_service.rs b/tests/campaign_service.rs new file mode 100644 index 00000000..0e10f5fe --- /dev/null +++ b/tests/campaign_service.rs @@ -0,0 +1,159 @@ +//! The campaign service behind REST and MCP, driven licensed through simulated agents. + +mod campaign_common; + +use std::fs; + +use campaign_common::{Scenario, SimPort, CAMPAIGN}; +use operator::campaigns::model::CampaignStatus; +use operator::campaigns::service::{self, NewCampaignInput}; +use operator::config::{GovernancePolicy, SessionWrapperType}; + +fn stray(s: &Scenario, id: &str) { + let path = std::path::Path::new(&s.config.paths.tickets) + .join("queue") + .join(format!("20260101-1300-FEAT-api-{}.md", id.to_lowercase())); + fs::write( + path, + format!("---\nid: {id}\nstatus: queued\n---\n\n# {id}\n"), + ) + .unwrap(); +} + +#[test] +fn create_campaign_validates_named_policy_and_keeps_overrides() { + let mut s = Scenario::load("diamond"); + let err = service::create_campaign( + &s.config, + NewCampaignInput { + title: "Bad".into(), + governance: Some("missing".into()), + ..NewCampaignInput::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("missing"), "{err}"); + + s.config.governance.push(GovernancePolicy { + name: "team".into(), + ..GovernancePolicy::default() + }); + let created = service::create_campaign( + &s.config, + NewCampaignInput { + title: "Epic".into(), + goal: Some("Ship it.".into()), + governance: Some("team".into()), + governance_overrides: Some(GovernancePolicy { + name: "epic".into(), + max_parallel: Some(2), + ..GovernancePolicy::default() + }), + ..NewCampaignInput::default() + }, + ) + .unwrap(); + assert_eq!(created.id, "CAMP-0002"); + let detail = service::detail(&s.config, &created.id).unwrap(); + assert_eq!( + detail.campaign.governance_overrides.unwrap().max_parallel, + Some(2) + ); + assert!(detail.members.is_empty()); +} + +#[test] +fn assign_ticket_joins_campaign_and_rejects_cycles() { + let s = Scenario::load("diamond"); + stray(&s, "FEAT-0010"); + let ticket = service::assign_ticket( + &s.config, + "FEAT-0010", + Some(CAMPAIGN), + Some(vec!["TASK-0004".into()]), + ) + .unwrap(); + assert_eq!(ticket.campaign.as_deref(), Some(CAMPAIGN)); + let detail = service::detail(&s.config, CAMPAIGN).unwrap(); + assert_eq!(detail.waves.last().unwrap(), &vec!["FEAT-0010".to_string()]); + + let err = service::assign_ticket( + &s.config, + "FEAT-0001", + Some(CAMPAIGN), + Some(vec!["FEAT-0010".into()]), + ) + .unwrap_err(); + assert!(err.to_string().contains("cycle"), "{err}"); + let root = s + .tickets() + .into_iter() + .find(|t| t.id == "FEAT-0001") + .unwrap(); + assert!(root.depends_on.is_empty(), "a rejected edit is reverted"); + + let err = service::assign_ticket(&s.config, "FEAT-0010", Some("CAMP-0404"), None).unwrap_err(); + assert!(err.to_string().contains("CAMP-0404"), "{err}"); + + let ticket = service::assign_ticket(&s.config, "FEAT-0010", None, None).unwrap(); + assert!(ticket.campaign.is_none()); +} + +#[test] +fn detail_lists_members_with_columns_and_waves() { + let s = Scenario::load("diamond"); + let detail = service::detail(&s.config, CAMPAIGN).unwrap(); + assert_eq!(detail.members.len(), 4); + assert!(detail.members.iter().all(|m| m.column == "queue")); + assert_eq!(detail.waves.len(), 3); + assert!(detail.plan_error.is_none()); +} + +#[tokio::test] +async fn dry_run_reports_violations_and_changes_nothing() { + let mut s = Scenario::load("diamond"); + let port = SimPort::new(&s.config, &[]); + let outcome = service::launch(&s.config, &port, CAMPAIGN, true) + .await + .unwrap(); + assert!(outcome.violations.is_empty(), "{:?}", outcome.violations); + assert_eq!(outcome.report.launched, vec!["FEAT-0001"]); + assert_eq!(port.running(), 0); + assert_eq!(s.campaign().status, CampaignStatus::Ready); + + port.set_entitled(false); + s.config.sessions.wrapper = SessionWrapperType::Vscode; + let outcome = service::launch(&s.config, &port, CAMPAIGN, true) + .await + .unwrap(); + let text = outcome.violations.join(" | "); + assert!(text.contains("Premium"), "{text}"); + assert!(text.contains("client"), "{text}"); +} + +#[tokio::test] +async fn launch_starts_and_runs_the_first_wave_then_status_counts_it() { + let s = Scenario::load("governed-fan"); + let port = SimPort::new(&s.config, &[]); + let outcome = service::launch(&s.config, &port, CAMPAIGN, false) + .await + .unwrap(); + assert_eq!(outcome.status, CampaignStatus::Running); + assert_eq!(outcome.report.launched, vec!["FEAT-0001"]); + assert_eq!(port.running(), 1); + + let view = service::status(&s.config, &port, CAMPAIGN).unwrap(); + assert_eq!(view.status, CampaignStatus::Running); + assert_eq!(view.counters.launched, 1); + assert_eq!(view.counters.active, 1); + assert_eq!(view.counters.max_parallel, Some(2)); + assert_eq!(view.counters.max_retries, Some(1)); + let root = view.tickets.iter().find(|t| t.id == "FEAT-0001").unwrap(); + assert_eq!((root.column.as_str(), root.launches), ("in-progress", 1)); + assert!(view.report.launched.is_empty(), "root still running"); + + let err = service::launch(&s.config, &port, CAMPAIGN, false) + .await + .unwrap_err(); + assert!(err.to_string().contains("already running"), "{err}"); +} diff --git a/tests/docs_structure.rs b/tests/docs_structure.rs index 32b77abf..843cf2f3 100644 --- a/tests/docs_structure.rs +++ b/tests/docs_structure.rs @@ -23,7 +23,11 @@ use operator::integrations::{all_integrations, SupportStatus, Vertical}; const NAV_DEFERRED: &[(&str, &str)] = &[("workflows", "claude"), ("workflows", "agnt")]; /// Leaf URLs under a vertical section that are supporting pages rather than catalog integrations. -const NAV_EXTRA_PAGES: &[&str] = &["/getting-started/git/provider-support/"]; +const NAV_EXTRA_PAGES: &[&str] = &[ + "/getting-started/git/provider-support/", + "/getting-started/tickets/", + "/getting-started/concepts/kanban/", +]; /// Published pages intentionally not linked from the sidebar. const NAV_ORPHAN_ALLOWLIST: &[&str] = &[ @@ -302,7 +306,10 @@ fn test_catalog_icons_exist() { } if e.status >= SupportStatus::Alpha && e.docs_path.is_some() - && !matches!(e.vertical, Vertical::Transport | Vertical::RemoteTargets) + && !matches!( + e.vertical, + Vertical::Transport | Vertical::RemoteTargets | Vertical::Feature + ) { assert!( e.icon.is_some(), diff --git a/tests/fixtures/campaigns/blocked-unknown-dep/campaign.md b/tests/fixtures/campaigns/blocked-unknown-dep/campaign.md new file mode 100644 index 00000000..7b4ef55b --- /dev/null +++ b/tests/fixtures/campaigns/blocked-unknown-dep/campaign.md @@ -0,0 +1,7 @@ +--- +id: CAMP-0001 +title: Blocked on unknown dependency +status: ready +--- + +# Blocked on unknown dependency diff --git a/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1200-FEAT-api-orphaned.md b/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1200-FEAT-api-orphaned.md new file mode 100644 index 00000000..c1a06bf4 --- /dev/null +++ b/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1200-FEAT-api-orphaned.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0001 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0099" +--- + +# FEAT-0001 diff --git a/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1201-FEAT-api-free.md b/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1201-FEAT-api-free.md new file mode 100644 index 00000000..e089bcd9 --- /dev/null +++ b/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1201-FEAT-api-free.md @@ -0,0 +1,8 @@ +--- +id: FEAT-0002 +status: queued +priority: P2-medium +campaign: CAMP-0001 +--- + +# FEAT-0002 diff --git a/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1202-FIX-web-free.md b/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1202-FIX-web-free.md new file mode 100644 index 00000000..5a1117ba --- /dev/null +++ b/tests/fixtures/campaigns/blocked-unknown-dep/queue/20260101-1202-FIX-web-free.md @@ -0,0 +1,8 @@ +--- +id: FIX-0003 +status: queued +priority: P2-medium +campaign: CAMP-0001 +--- + +# FIX-0003 diff --git a/tests/fixtures/campaigns/blocked-unknown-dep/scenario.toml b/tests/fixtures/campaigns/blocked-unknown-dep/scenario.toml new file mode 100644 index 00000000..a8c6d4d8 --- /dev/null +++ b/tests/fixtures/campaigns/blocked-unknown-dep/scenario.toml @@ -0,0 +1,16 @@ +# An unknown dependency blocks only its ticket; the rest complete. +[config] +max_parallel = 4 +max_agents_per_repo = 2 + +[expect] +waves = [["FIX-0003", "FEAT-0001", "FEAT-0002"]] +final_status = "running" +blocked = { "FEAT-0001" = "unsynced" } + +[[tick]] +launch = ["FIX-0003", "FEAT-0002"] +status = "running" + +[[tick]] +status = "running" diff --git a/tests/fixtures/campaigns/client-wrapper/scenario.toml b/tests/fixtures/campaigns/client-wrapper/scenario.toml new file mode 100644 index 00000000..c9888e61 --- /dev/null +++ b/tests/fixtures/campaigns/client-wrapper/scenario.toml @@ -0,0 +1,9 @@ +# A wrapper that needs a client (VS Code) is refused before the run starts. +base = "diamond" + +[config] +wrapper = "vscode" + +[expect] +start_error = "client" +final_status = "ready" diff --git a/tests/fixtures/campaigns/completed-campaign/campaign.md b/tests/fixtures/campaigns/completed-campaign/campaign.md new file mode 100644 index 00000000..0a05948f --- /dev/null +++ b/tests/fixtures/campaigns/completed-campaign/campaign.md @@ -0,0 +1,7 @@ +--- +id: CAMP-0001 +title: Done +status: completed +--- + +# Done diff --git a/tests/fixtures/campaigns/completed-campaign/scenario.toml b/tests/fixtures/campaigns/completed-campaign/scenario.toml new file mode 100644 index 00000000..49b2caba --- /dev/null +++ b/tests/fixtures/campaigns/completed-campaign/scenario.toml @@ -0,0 +1,6 @@ +# A completed campaign cannot be started again. +base = "diamond" + +[expect] +start_error = "completed" +final_status = "completed" diff --git a/tests/fixtures/campaigns/cycle/campaign.md b/tests/fixtures/campaigns/cycle/campaign.md new file mode 100644 index 00000000..90ed2cfb --- /dev/null +++ b/tests/fixtures/campaigns/cycle/campaign.md @@ -0,0 +1,7 @@ +--- +id: CAMP-0001 +title: Cycle +status: ready +--- + +# Cycle diff --git a/tests/fixtures/campaigns/cycle/queue/20260101-1200-FEAT-api-a.md b/tests/fixtures/campaigns/cycle/queue/20260101-1200-FEAT-api-a.md new file mode 100644 index 00000000..6bf5ad5d --- /dev/null +++ b/tests/fixtures/campaigns/cycle/queue/20260101-1200-FEAT-api-a.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0001 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0002" +--- + +# FEAT-0001 diff --git a/tests/fixtures/campaigns/cycle/queue/20260101-1201-FEAT-api-b.md b/tests/fixtures/campaigns/cycle/queue/20260101-1201-FEAT-api-b.md new file mode 100644 index 00000000..ffa41775 --- /dev/null +++ b/tests/fixtures/campaigns/cycle/queue/20260101-1201-FEAT-api-b.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0002 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FEAT-0002 diff --git a/tests/fixtures/campaigns/cycle/scenario.toml b/tests/fixtures/campaigns/cycle/scenario.toml new file mode 100644 index 00000000..3b27e710 --- /dev/null +++ b/tests/fixtures/campaigns/cycle/scenario.toml @@ -0,0 +1,5 @@ +# A cycle is refused at start; the campaign stays ready and nothing launches. +[expect] +plan_error = "dependency cycle" +start_error = "dependency cycle" +final_status = "ready" diff --git a/tests/fixtures/campaigns/diamond/campaign.md b/tests/fixtures/campaigns/diamond/campaign.md new file mode 100644 index 00000000..57452717 --- /dev/null +++ b/tests/fixtures/campaigns/diamond/campaign.md @@ -0,0 +1,7 @@ +--- +id: CAMP-0001 +title: Diamond +status: ready +--- + +# Diamond diff --git a/tests/fixtures/campaigns/diamond/queue/20260101-1200-FEAT-api-root.md b/tests/fixtures/campaigns/diamond/queue/20260101-1200-FEAT-api-root.md new file mode 100644 index 00000000..54bc728a --- /dev/null +++ b/tests/fixtures/campaigns/diamond/queue/20260101-1200-FEAT-api-root.md @@ -0,0 +1,8 @@ +--- +id: FEAT-0001 +status: queued +priority: P2-medium +campaign: CAMP-0001 +--- + +# FEAT-0001 diff --git a/tests/fixtures/campaigns/diamond/queue/20260101-1201-FEAT-api-left.md b/tests/fixtures/campaigns/diamond/queue/20260101-1201-FEAT-api-left.md new file mode 100644 index 00000000..ffa41775 --- /dev/null +++ b/tests/fixtures/campaigns/diamond/queue/20260101-1201-FEAT-api-left.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0002 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FEAT-0002 diff --git a/tests/fixtures/campaigns/diamond/queue/20260101-1202-FIX-web-right.md b/tests/fixtures/campaigns/diamond/queue/20260101-1202-FIX-web-right.md new file mode 100644 index 00000000..d8e5e79c --- /dev/null +++ b/tests/fixtures/campaigns/diamond/queue/20260101-1202-FIX-web-right.md @@ -0,0 +1,10 @@ +--- +id: FIX-0003 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FIX-0003 diff --git a/tests/fixtures/campaigns/diamond/queue/20260101-1203-TASK-web-join.md b/tests/fixtures/campaigns/diamond/queue/20260101-1203-TASK-web-join.md new file mode 100644 index 00000000..7bf736dc --- /dev/null +++ b/tests/fixtures/campaigns/diamond/queue/20260101-1203-TASK-web-join.md @@ -0,0 +1,11 @@ +--- +id: TASK-0004 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0002" + - "FIX-0003" +--- + +# TASK-0004 diff --git a/tests/fixtures/campaigns/diamond/scenario.toml b/tests/fixtures/campaigns/diamond/scenario.toml new file mode 100644 index 00000000..aa73adc0 --- /dev/null +++ b/tests/fixtures/campaigns/diamond/scenario.toml @@ -0,0 +1,23 @@ +# A -> (B || C across repos) -> D; the pair launches in the same tick. +[config] +max_parallel = 4 +max_agents_per_repo = 2 + +[expect] +waves = [["FEAT-0001"], ["FIX-0003", "FEAT-0002"], ["TASK-0004"]] +final_status = "completed" + +[[tick]] +launch = ["FEAT-0001"] +status = "running" + +[[tick]] +launch = ["FIX-0003", "FEAT-0002"] +status = "running" + +[[tick]] +launch = ["TASK-0004"] +status = "running" + +[[tick]] +status = "completed" diff --git a/tests/fixtures/campaigns/gated-chain/campaign.md b/tests/fixtures/campaigns/gated-chain/campaign.md new file mode 100644 index 00000000..6889236c --- /dev/null +++ b/tests/fixtures/campaigns/gated-chain/campaign.md @@ -0,0 +1,7 @@ +--- +id: CAMP-0001 +title: Gated chain +status: ready +--- + +# Gated chain diff --git a/tests/fixtures/campaigns/gated-chain/queue/20260101-1200-FEAT-api-build.md b/tests/fixtures/campaigns/gated-chain/queue/20260101-1200-FEAT-api-build.md new file mode 100644 index 00000000..54bc728a --- /dev/null +++ b/tests/fixtures/campaigns/gated-chain/queue/20260101-1200-FEAT-api-build.md @@ -0,0 +1,8 @@ +--- +id: FEAT-0001 +status: queued +priority: P2-medium +campaign: CAMP-0001 +--- + +# FEAT-0001 diff --git a/tests/fixtures/campaigns/gated-chain/queue/20260101-1201-SPIKE-api-review.md b/tests/fixtures/campaigns/gated-chain/queue/20260101-1201-SPIKE-api-review.md new file mode 100644 index 00000000..6c0f7b64 --- /dev/null +++ b/tests/fixtures/campaigns/gated-chain/queue/20260101-1201-SPIKE-api-review.md @@ -0,0 +1,10 @@ +--- +id: SPIKE-0002 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# SPIKE-0002 diff --git a/tests/fixtures/campaigns/gated-chain/queue/20260101-1202-FIX-api-followup.md b/tests/fixtures/campaigns/gated-chain/queue/20260101-1202-FIX-api-followup.md new file mode 100644 index 00000000..bff19254 --- /dev/null +++ b/tests/fixtures/campaigns/gated-chain/queue/20260101-1202-FIX-api-followup.md @@ -0,0 +1,10 @@ +--- +id: FIX-0003 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "SPIKE-0002" +--- + +# FIX-0003 diff --git a/tests/fixtures/campaigns/gated-chain/scenario.toml b/tests/fixtures/campaigns/gated-chain/scenario.toml new file mode 100644 index 00000000..8c6d9424 --- /dev/null +++ b/tests/fixtures/campaigns/gated-chain/scenario.toml @@ -0,0 +1,27 @@ +# The paired SPIKE is never launched; the operator completes it to unblock FIX. +[config] +max_parallel = 4 +max_agents_per_repo = 1 + +[expect] +waves = [["FEAT-0001"], ["SPIKE-0002"], ["FIX-0003"]] +final_status = "completed" + +[[operator]] +tick = 2 +complete = "SPIKE-0002" + +[[tick]] +launch = ["FEAT-0001"] +status = "running" + +[[tick]] +awaiting_operator = ["SPIKE-0002"] +status = "running" + +[[tick]] +launch = ["FIX-0003"] +status = "running" + +[[tick]] +status = "completed" diff --git a/tests/fixtures/campaigns/governed-fan/campaign.md b/tests/fixtures/campaigns/governed-fan/campaign.md new file mode 100644 index 00000000..56eac25a --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/campaign.md @@ -0,0 +1,11 @@ +--- +id: CAMP-0001 +title: Governed fan +status: ready +governance_overrides: + name: fan + max_parallel: 2 + max_retries: 1 +--- + +# Governed fan diff --git a/tests/fixtures/campaigns/governed-fan/queue/20260101-1200-FEAT-api-root.md b/tests/fixtures/campaigns/governed-fan/queue/20260101-1200-FEAT-api-root.md new file mode 100644 index 00000000..54bc728a --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/queue/20260101-1200-FEAT-api-root.md @@ -0,0 +1,8 @@ +--- +id: FEAT-0001 +status: queued +priority: P2-medium +campaign: CAMP-0001 +--- + +# FEAT-0001 diff --git a/tests/fixtures/campaigns/governed-fan/queue/20260101-1201-FEAT-api-a.md b/tests/fixtures/campaigns/governed-fan/queue/20260101-1201-FEAT-api-a.md new file mode 100644 index 00000000..ffa41775 --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/queue/20260101-1201-FEAT-api-a.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0002 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FEAT-0002 diff --git a/tests/fixtures/campaigns/governed-fan/queue/20260101-1202-FEAT-api-b.md b/tests/fixtures/campaigns/governed-fan/queue/20260101-1202-FEAT-api-b.md new file mode 100644 index 00000000..baa3c820 --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/queue/20260101-1202-FEAT-api-b.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0003 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FEAT-0003 diff --git a/tests/fixtures/campaigns/governed-fan/queue/20260101-1203-FEAT-web-c.md b/tests/fixtures/campaigns/governed-fan/queue/20260101-1203-FEAT-web-c.md new file mode 100644 index 00000000..d532f73d --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/queue/20260101-1203-FEAT-web-c.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0004 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FEAT-0004 diff --git a/tests/fixtures/campaigns/governed-fan/queue/20260101-1204-FEAT-web-d.md b/tests/fixtures/campaigns/governed-fan/queue/20260101-1204-FEAT-web-d.md new file mode 100644 index 00000000..88f1ce2f --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/queue/20260101-1204-FEAT-web-d.md @@ -0,0 +1,10 @@ +--- +id: FEAT-0005 +status: queued +priority: P2-medium +campaign: CAMP-0001 +depends_on: + - "FEAT-0001" +--- + +# FEAT-0005 diff --git a/tests/fixtures/campaigns/governed-fan/scenario.toml b/tests/fixtures/campaigns/governed-fan/scenario.toml new file mode 100644 index 00000000..f186ec53 --- /dev/null +++ b/tests/fixtures/campaigns/governed-fan/scenario.toml @@ -0,0 +1,29 @@ +# max_parallel=2 and one agent per repo throttle the fan; FEAT-0004 requeues +# twice, so its second retry exceeds max_retries=1 and the run pauses. +[config] +max_parallel = 4 +max_agents_per_repo = 1 + +[expect] +waves = [["FEAT-0001"], ["FEAT-0002", "FEAT-0003", "FEAT-0004", "FEAT-0005"]] +final_status = "paused" +pause_reason = "max_retries reached by FEAT-0004" + +[[agent]] +ticket = "FEAT-0004" +outcomes = ["requeue", "requeue"] + +[[tick]] +launch = ["FEAT-0001"] +status = "running" + +[[tick]] +launch = ["FEAT-0002", "FEAT-0004"] +status = "running" + +[[tick]] +launch = ["FEAT-0003", "FEAT-0004"] +status = "running" + +[[tick]] +status = "paused" diff --git a/tests/fixtures/campaigns/launch-error/scenario.toml b/tests/fixtures/campaigns/launch-error/scenario.toml new file mode 100644 index 00000000..d33d0fdf --- /dev/null +++ b/tests/fixtures/campaigns/launch-error/scenario.toml @@ -0,0 +1,21 @@ +# A failed launch call pauses the run before the rest of the wave. +base = "diamond" + +[config] +max_parallel = 4 +max_agents_per_repo = 2 + +[expect] +final_status = "paused" +pause_reason = "launch of FIX-0003 failed" + +[[agent]] +ticket = "FIX-0003" +outcomes = ["error"] + +[[tick]] +launch = ["FEAT-0001"] +status = "running" + +[[tick]] +status = "paused" diff --git a/tests/fixtures/campaigns/license-lapse/scenario.toml b/tests/fixtures/campaigns/license-lapse/scenario.toml new file mode 100644 index 00000000..8e4d28dc --- /dev/null +++ b/tests/fixtures/campaigns/license-lapse/scenario.toml @@ -0,0 +1,26 @@ +# Premium lapses after the first launch: the in-flight agent finishes, nothing new starts. +base = "diamond" + +[config] +max_parallel = 4 +max_agents_per_repo = 2 +revoke_license_at = 1 + +[expect] +final_status = "running" +completed = ["FEAT-0001"] +blocked = { "FIX-0003" = "license", "FEAT-0002" = "license" } + +[[agent]] +ticket = "FEAT-0001" +ticks = 2 + +[[tick]] +launch = ["FEAT-0001"] +status = "running" + +[[tick]] +status = "running" + +[[tick]] +status = "running" diff --git a/tests/fixtures/campaigns/orphan/scenario.toml b/tests/fixtures/campaigns/orphan/scenario.toml new file mode 100644 index 00000000..7b9128b7 --- /dev/null +++ b/tests/fixtures/campaigns/orphan/scenario.toml @@ -0,0 +1,21 @@ +# An agent that dies leaves its ticket in progress: reported stalled, never relaunched. +base = "diamond" + +[config] +max_parallel = 4 +max_agents_per_repo = 2 + +[expect] +final_status = "running" +stalled = ["FEAT-0001"] + +[[agent]] +ticket = "FEAT-0001" +outcomes = ["orphan"] + +[[tick]] +launch = ["FEAT-0001"] +status = "running" + +[[tick]] +status = "running" diff --git a/tests/fixtures/kanban/github/items_with_relationships.json b/tests/fixtures/kanban/github/items_with_relationships.json new file mode 100644 index 00000000..e943e221 --- /dev/null +++ b/tests/fixtures/kanban/github/items_with_relationships.json @@ -0,0 +1,52 @@ +{ + "node": { + "items": { + "pageInfo": { "hasNextPage": false, "endCursor": null }, + "nodes": [ + { + "id": "PVTI_item_12", + "type": "ISSUE", + "content": { + "__typename": "Issue", + "id": "I_kwDO_12", + "number": 12, + "title": "Add retry to the sync worker", + "body": null, + "url": "https://github.com/octo/hello/issues/12", + "repository": { "nameWithOwner": "octo/hello" }, + "assignees": { "nodes": [] }, + "labels": { "nodes": [] }, + "issueType": null, + "parent": { "number": 3, "repository": { "nameWithOwner": "octo/hello" } }, + "subIssues": { + "nodes": [ + { "number": 13, "repository": { "nameWithOwner": "octo/hello" } }, + { "number": 2, "repository": { "nameWithOwner": "octo/infra" } } + ] + }, + "blockedBy": { + "nodes": [{ "number": 9, "repository": { "nameWithOwner": "octo/hello" } }] + } + }, + "fieldValues": { "nodes": [] } + }, + { + "id": "PVTI_item_pr", + "type": "PULL_REQUEST", + "content": { + "__typename": "PullRequest", + "id": "PR_kwDO_14", + "number": 14, + "title": "A pull request has no relationships", + "body": null, + "url": "https://github.com/octo/hello/pull/14", + "repository": { "nameWithOwner": "octo/hello" }, + "assignees": { "nodes": [] }, + "labels": { "nodes": [] } + }, + "fieldValues": { "nodes": [] } + } + ] + } + } +} diff --git a/tests/fixtures/kanban/github/parent_with_sub_issues.json b/tests/fixtures/kanban/github/parent_with_sub_issues.json new file mode 100644 index 00000000..95726ad0 --- /dev/null +++ b/tests/fixtures/kanban/github/parent_with_sub_issues.json @@ -0,0 +1,58 @@ +{ + "repository": { + "issue": { + "title": "Sync worker hardening", + "body": "Make the sync worker survive outages.", + "url": "https://github.com/octo/hello/issues/3", + "subIssues": { + "nodes": [ + { + "__typename": "Issue", + "id": "I_12", + "number": 12, + "title": "Add retry", + "body": null, + "url": "https://github.com/octo/hello/issues/12", + "state": "OPEN", + "repository": { "nameWithOwner": "octo/hello" }, + "assignees": { "nodes": [] }, + "labels": { "nodes": [] }, + "issueType": null, + "parent": { "number": 3, "repository": { "nameWithOwner": "octo/hello" } }, + "subIssues": { "nodes": [] }, + "blockedBy": { "nodes": [] } + }, + { + "__typename": "Issue", + "id": "I_13", + "number": 13, + "title": "Alert on repeated failure", + "body": null, + "url": "https://github.com/octo/hello/issues/13", + "state": "OPEN", + "repository": { "nameWithOwner": "octo/hello" }, + "assignees": { "nodes": [] }, + "labels": { "nodes": [] }, + "issueType": null, + "parent": { "number": 3, "repository": { "nameWithOwner": "octo/hello" } }, + "subIssues": { "nodes": [] }, + "blockedBy": { "nodes": [{ "number": 12, "repository": { "nameWithOwner": "octo/hello" } }] } + }, + { + "__typename": "Issue", + "id": "I_14", + "number": 14, + "title": "Already closed", + "body": null, + "url": "https://github.com/octo/hello/issues/14", + "state": "CLOSED", + "repository": { "nameWithOwner": "octo/hello" }, + "assignees": { "nodes": [] }, + "labels": { "nodes": [] }, + "issueType": null + } + ] + } + } + } +} diff --git a/tests/fixtures/kanban/jira/epic_children.json b/tests/fixtures/kanban/jira/epic_children.json new file mode 100644 index 00000000..bf906e5f --- /dev/null +++ b/tests/fixtures/kanban/jira/epic_children.json @@ -0,0 +1,221 @@ +{ + "issues": [ + { + "id": "10011", + "key": "PROJ-11", + "fields": { + "summary": "Pricing step 11", + "description": null, + "issuetype": { + "id": "10001", + "name": "Story" + }, + "status": { + "name": "To Do" + }, + "assignee": null, + "priority": { + "name": "Medium" + }, + "parent": { + "key": "PROJ-1", + "fields": { + "issuetype": { + "name": "Epic" + } + } + }, + "issuelinks": [], + "subtasks": [] + } + }, + { + "id": "10012", + "key": "PROJ-12", + "fields": { + "summary": "Pricing step 12", + "description": null, + "issuetype": { + "id": "10001", + "name": "Story" + }, + "status": { + "name": "To Do" + }, + "assignee": null, + "priority": { + "name": "Medium" + }, + "parent": { + "key": "PROJ-1", + "fields": { + "issuetype": { + "name": "Epic" + } + } + }, + "issuelinks": [], + "subtasks": [] + } + }, + { + "id": "10013", + "key": "PROJ-13", + "fields": { + "summary": "Pricing step 13", + "description": null, + "issuetype": { + "id": "10001", + "name": "Story" + }, + "status": { + "name": "To Do" + }, + "assignee": null, + "priority": { + "name": "Medium" + }, + "parent": { + "key": "PROJ-1", + "fields": { + "issuetype": { + "name": "Epic" + } + } + }, + "issuelinks": [ + { + "type": { + "name": "Blocks", + "inward": "is blocked by", + "outward": "blocks" + }, + "inwardIssue": { + "key": "PROJ-11" + } + } + ], + "subtasks": [] + } + }, + { + "id": "10014", + "key": "PROJ-14", + "fields": { + "summary": "Pricing step 14", + "description": null, + "issuetype": { + "id": "10001", + "name": "Story" + }, + "status": { + "name": "To Do" + }, + "assignee": null, + "priority": { + "name": "Medium" + }, + "parent": { + "key": "PROJ-1", + "fields": { + "issuetype": { + "name": "Epic" + } + } + }, + "issuelinks": [ + { + "type": { + "name": "Blocks", + "inward": "is blocked by", + "outward": "blocks" + }, + "inwardIssue": { + "key": "PROJ-12" + } + }, + { + "type": { + "name": "Blocks", + "inward": "is blocked by", + "outward": "blocks" + }, + "inwardIssue": { + "key": "PROJ-13" + } + } + ], + "subtasks": [] + } + }, + { + "id": "10015", + "key": "PROJ-15", + "fields": { + "summary": "Pricing step 15", + "description": null, + "issuetype": { + "id": "10001", + "name": "Story" + }, + "status": { + "name": "To Do" + }, + "assignee": null, + "priority": { + "name": "Medium" + }, + "parent": { + "key": "PROJ-1", + "fields": { + "issuetype": { + "name": "Epic" + } + } + }, + "issuelinks": [], + "subtasks": [] + } + }, + { + "id": "10016", + "key": "PROJ-16", + "fields": { + "summary": "Pricing step 16", + "description": null, + "issuetype": { + "id": "10001", + "name": "Story" + }, + "status": { + "name": "To Do" + }, + "assignee": null, + "priority": { + "name": "Medium" + }, + "parent": { + "key": "PROJ-1", + "fields": { + "issuetype": { + "name": "Epic" + } + } + }, + "issuelinks": [ + { + "type": { + "name": "Blocks", + "inward": "is blocked by", + "outward": "blocks" + }, + "inwardIssue": { + "key": "PROJ-15" + } + } + ], + "subtasks": [] + } + } + ] +} diff --git a/tests/fixtures/kanban/jira/search_with_relationships.json b/tests/fixtures/kanban/jira/search_with_relationships.json new file mode 100644 index 00000000..aa1c0aa8 --- /dev/null +++ b/tests/fixtures/kanban/jira/search_with_relationships.json @@ -0,0 +1,58 @@ +{ + "issues": [ + { + "id": "10042", + "key": "PROJ-42", + "fields": { + "summary": "Wire checkout to the new pricing API", + "description": null, + "issuetype": { "id": "10001", "name": "Story" }, + "status": { "name": "To Do" }, + "assignee": null, + "priority": { "name": "High" }, + "parent": { + "id": "10001", + "key": "PROJ-1", + "fields": { + "summary": "Pricing overhaul", + "issuetype": { "id": "10000", "name": "Epic", "hierarchyLevel": 1 } + } + }, + "issuelinks": [ + { + "id": "20001", + "type": { "id": "10000", "name": "Blocks", "inward": "is blocked by", "outward": "blocks" }, + "inwardIssue": { "id": "10012", "key": "PROJ-12", "fields": { "summary": "Pricing API" } } + }, + { + "id": "20002", + "type": { "id": "10000", "name": "Blocks", "inward": "is blocked by", "outward": "blocks" }, + "outwardIssue": { "id": "10050", "key": "PROJ-50", "fields": { "summary": "Downstream" } } + }, + { + "id": "20003", + "type": { "id": "10003", "name": "Relates", "inward": "relates to", "outward": "relates to" }, + "inwardIssue": { "id": "10013", "key": "PROJ-13", "fields": { "summary": "Related" } } + } + ], + "subtasks": [ + { "id": "10043", "key": "PROJ-43", "fields": { "summary": "Sub one" } }, + { "id": "10044", "key": "PROJ-44", "fields": { "summary": "Sub two" } } + ] + } + }, + { + "id": "10060", + "key": "PROJ-60", + "fields": { + "summary": "Story under a non-epic parent, no links", + "description": null, + "issuetype": { "name": "Sub-task" }, + "status": { "name": "In Progress" }, + "assignee": null, + "priority": null, + "parent": { "id": "10042", "key": "PROJ-42", "fields": { "issuetype": { "name": "Story" } } } + } + } + ] +} diff --git a/tests/fixtures/kanban/linear/issues_with_relationships.json b/tests/fixtures/kanban/linear/issues_with_relationships.json new file mode 100644 index 00000000..c5a4fbf3 --- /dev/null +++ b/tests/fixtures/kanban/linear/issues_with_relationships.json @@ -0,0 +1,39 @@ +{ + "issues": { + "nodes": [ + { + "id": "issue-7", + "identifier": "ENG-7", + "title": "Ship the billing webhook", + "description": null, + "state": { "name": "Todo" }, + "assignee": null, + "priority": 2, + "url": "https://linear.app/acme/issue/ENG-7", + "labels": { "nodes": [{ "id": "label-feat", "name": "Feature" }] }, + "inverseRelations": { + "nodes": [ + { "type": "blocks", "issue": { "identifier": "ENG-3" } }, + { "type": "related", "issue": { "identifier": "ENG-4" } }, + { "type": "duplicate", "issue": { "identifier": "ENG-5" } } + ] + }, + "parent": { "identifier": "ENG-1" }, + "children": { "nodes": [{ "identifier": "ENG-8" }, { "identifier": "ENG-9" }] }, + "project": { "id": "proj-billing", "name": "Billing v2" } + }, + { + "id": "issue-8", + "identifier": "ENG-8", + "title": "Child with only a parent issue", + "description": null, + "state": { "name": "Todo" }, + "assignee": null, + "priority": 0, + "url": "https://linear.app/acme/issue/ENG-8", + "parent": { "identifier": "ENG-7" }, + "project": null + } + ] + } +} diff --git a/tests/helm_chart.rs b/tests/helm_chart.rs index 4453370b..f91295fb 100644 --- a/tests/helm_chart.rs +++ b/tests/helm_chart.rs @@ -210,6 +210,28 @@ fn test_rendered_image_tag_matches_chart_app_version() { ); } +/// volumeClaimTemplates are immutable, so a label that changes per chart version +/// makes every `helm upgrade` across versions fail. +#[test] +fn test_volume_claim_template_labels_are_version_independent() { + if !helm_available() { + eprintln!("skipping: helm not on PATH"); + return; + } + let rendered = helm(&["template", "op", chart_dir().to_str().unwrap()]); + let claims = rendered + .split_once("volumeClaimTemplates:") + .map(|(_, rest)| rest) + .expect("the StatefulSet must render volumeClaimTemplates"); + + for label in ["helm.sh/chart", "app.kubernetes.io/version"] { + assert!( + !claims.contains(label), + "volumeClaimTemplates must not carry {label}; it changes on every chart release" + ); + } +} + /// A chart path that does not exist must not silently pass as "helm unavailable". #[test] fn test_chart_directory_exists() { diff --git a/tests/no_handrolled_encoding.rs b/tests/no_handrolled_encoding.rs new file mode 100644 index 00000000..679f762e --- /dev/null +++ b/tests/no_handrolled_encoding.rs @@ -0,0 +1,123 @@ +//! No hand-rolled encodings, hashes, or bit-level parsing. +//! +//! Bit twiddling in application code is almost always a reimplementation of +//! something a vetted crate or std already does correctly. Use these instead: +//! +//! | Need | Use | +//! |------|-----| +//! | Base64 | `base64` crate, or reqwest `RequestBuilder::basic_auth` for Basic auth | +//! | Hex | `hex::encode` | +//! | UUIDs | `uuid::Uuid::new_v4` | +//! | URL / query encoding | `url::form_urlencoded`, reqwest `RequestBuilder::query` | +//! | Digests | `sha2` | +//! | Small non-crypto hashes | `fnv::FnvHasher` | +//! | IP range checks | `std::net::Ipv{4,6}Addr::is_*` | +//! +//! Run `cargo test --test no_handrolled_encoding` before committing. + +use std::path::{Path, PathBuf}; + +use regex::Regex; + +/// Source roots scanned, one per Rust crate. +const SOURCE_ROOTS: &[&str] = &["src", "crates/relay/src", "opr8r/src", "zed-extension/src"]; + +/// Files allowed to use bitwise operators, with the reason. +const EXEMPT: &[(&str, &str)] = &[( + "src/auth/store.rs", + "exponential login backoff `1 << n` is arithmetic, not an encoding", +)]; + +/// Forbidden operator patterns. rustfmt spaces binary operators, which keeps +/// generics like `Vec>` from matching. +const FORBIDDEN: &[(&str, &str)] = &[ + (r"\s(<<|>>)=?\s", "bit shift"), + (r"\s\^=?\s", "xor"), + (r"[&|]=?\s*0x[0-9a-fA-F_]+", "hex-literal mask"), +]; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn rust_files() -> Vec { + fn walk(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + let root = repo_root(); + let mut files = Vec::new(); + for dir in SOURCE_ROOTS { + walk(&root.join(dir), &mut files); + } + files.sort(); + assert!( + !files.is_empty(), + "found no sources under {SOURCE_ROOTS:?} - has the layout moved?" + ); + files +} + +fn rel(path: &Path) -> String { + path.strip_prefix(repo_root()) + .unwrap_or(path) + .display() + .to_string() +} + +fn code_of(line: &str) -> &str { + line.split("//").next().unwrap_or_default() +} + +#[test] +fn test_no_handrolled_bitwise_encoding() { + let patterns: Vec<(Regex, &str)> = FORBIDDEN + .iter() + .map(|(re, name)| (Regex::new(re).unwrap(), *name)) + .collect(); + + let mut hits = Vec::new(); + for file in rust_files() { + let name = rel(&file); + if EXEMPT.iter().any(|(path, _)| *path == name) { + continue; + } + let source = + std::fs::read_to_string(&file).unwrap_or_else(|e| panic!("{name}: cannot read: {e}")); + for (idx, line) in source.lines().enumerate() { + let code = code_of(line); + for (re, what) in &patterns { + if re.is_match(code) { + hits.push(format!("{name}:{}: {what}: {}", idx + 1, line.trim())); + } + } + } + } + + assert!( + hits.is_empty(), + "hand-rolled bitwise code found; use a crate or std API instead \ + (see tests/no_handrolled_encoding.rs):\n{}", + hits.join("\n") + ); +} + +#[test] +fn test_exemptions_still_exist() { + for (path, reason) in EXEMPT { + assert!( + repo_root().join(path).is_file(), + "stale exemption {path} ({reason}): file no longer exists" + ); + } +} diff --git a/tests/premium_http_contract.rs b/tests/premium_http_contract.rs index eb11a494..15fa578a 100644 --- a/tests/premium_http_contract.rs +++ b/tests/premium_http_contract.rs @@ -280,3 +280,43 @@ async fn an_unknown_configuration_is_not_served_by_the_default() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + +const BATCH: &str = r#"{"tickets":[{"ref":"a","template":"FEAT","summary":"A"},{"ref":"b","template":"FIX","summary":"B","depends_on":["a"]}]}"#; + +#[tokio::test] +async fn campaign_routes_are_402_without_a_licence_and_batches_are_free() { + let server = Server::start(); + let token = server.admin_token(); + + for response in [ + server.get("/api/v1/campaigns", Some(&token)).await, + server + .post("/api/v1/campaigns", Some(&token), r#"{"title":"Epic"}"#) + .await, + server + .get("/api/v1/campaigns/CAMP-0001/status", Some(&token)) + .await, + server + .post("/api/v1/campaigns/CAMP-0001/launch", Some(&token), "{}") + .await, + server + .post("/api/v1/campaigns/CAMP-0001/pause", Some(&token), "{}") + .await, + ] { + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let body = body_json(response).await; + assert_eq!(body["error"], "not_entitled"); + assert_eq!(body["feature"], "campaigns"); + } + + let response = server + .post("/api/v1/tickets/batch/preview", Some(&token), BATCH) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + assert_eq!(body["valid"], true); + assert_eq!( + body["waves"], + serde_json::json!([["FEAT-0001"], ["FIX-0001"]]) + ); +} diff --git a/ui/public/icons/cmux.svg b/ui/public/icons/cmux.svg new file mode 100644 index 00000000..9e005499 --- /dev/null +++ b/ui/public/icons/cmux.svg @@ -0,0 +1 @@ +cmux diff --git a/ui/public/icons/cursor.svg b/ui/public/icons/cursor.svg new file mode 100644 index 00000000..b9672494 --- /dev/null +++ b/ui/public/icons/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/ui/public/icons/github.svg b/ui/public/icons/github.svg new file mode 100644 index 00000000..23349765 --- /dev/null +++ b/ui/public/icons/github.svg @@ -0,0 +1 @@ +GitHub diff --git a/ui/public/icons/gitlab.svg b/ui/public/icons/gitlab.svg new file mode 100644 index 00000000..ab1286a2 --- /dev/null +++ b/ui/public/icons/gitlab.svg @@ -0,0 +1 @@ +GitLab diff --git a/ui/public/icons/jira.svg b/ui/public/icons/jira.svg new file mode 100644 index 00000000..8cc91ccf --- /dev/null +++ b/ui/public/icons/jira.svg @@ -0,0 +1 @@ +Jira diff --git a/ui/public/icons/linear.svg b/ui/public/icons/linear.svg new file mode 100644 index 00000000..ada2e9da --- /dev/null +++ b/ui/public/icons/linear.svg @@ -0,0 +1 @@ +Linear diff --git a/ui/public/icons/openspec.svg b/ui/public/icons/openspec.svg new file mode 100644 index 00000000..a1d39712 --- /dev/null +++ b/ui/public/icons/openspec.svg @@ -0,0 +1 @@ +OpenSpec diff --git a/ui/public/icons/tmux.svg b/ui/public/icons/tmux.svg new file mode 100644 index 00000000..921c63bb --- /dev/null +++ b/ui/public/icons/tmux.svg @@ -0,0 +1 @@ +tmux diff --git a/ui/public/icons/vscode.svg b/ui/public/icons/vscode.svg new file mode 100644 index 00000000..92f3ae83 --- /dev/null +++ b/ui/public/icons/vscode.svg @@ -0,0 +1 @@ +Visual Studio Code diff --git a/ui/public/icons/zed.svg b/ui/public/icons/zed.svg new file mode 100644 index 00000000..5c3fe595 --- /dev/null +++ b/ui/public/icons/zed.svg @@ -0,0 +1 @@ +Zed diff --git a/ui/public/icons/zellij.svg b/ui/public/icons/zellij.svg new file mode 100644 index 00000000..160b3c07 --- /dev/null +++ b/ui/public/icons/zellij.svg @@ -0,0 +1 @@ +Zellij diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index 1d5ad06e..fbcd616e 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -18,6 +18,7 @@ import type { SectionDto } from "./api-client"; import { useApiMutation, useApiQuery, useResetSession } from "./api"; import { currentSessionQuery, logoutMutation } from "./api/definitions"; import { useProfiles } from "./profiles-context"; +import { useAttentionTitle } from "./attention-title"; // 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 @@ -84,6 +85,7 @@ function RightPanelController() { export function Layout() { const { theme, toggleTheme } = useTheme(); const { selected } = useProfiles(); + useAttentionTitle(); const navigate = useNavigate(); const session = useApiQuery(currentSessionQuery()); const logout = useApiMutation(logoutMutation); diff --git a/ui/src/api-client.ts b/ui/src/api-client.ts index 3f62f442..0899ca33 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api-client.ts @@ -4,6 +4,15 @@ import type { ProfileSummary } from "@operator/bindings/ProfileSummary"; import type { LicenseResponse } from "@operator/bindings/LicenseResponse"; import type { TargetResponse } from "@operator/bindings/TargetResponse"; import type { TargetsResponse } from "@operator/bindings/TargetsResponse"; +import type { CampaignListResponse } from "@operator/bindings/CampaignListResponse"; +import type { CampaignDetail } from "@operator/bindings/CampaignDetail"; +import type { CampaignStatusView } from "@operator/bindings/CampaignStatusView"; +import type { CampaignLaunchOutcome } from "@operator/bindings/CampaignLaunchOutcome"; +import type { LaunchCampaignRequest } from "@operator/bindings/LaunchCampaignRequest"; +import type { TickReport } from "@operator/bindings/TickReport"; +import type { TicketDetailResponse } from "@operator/bindings/TicketDetailResponse"; +import type { UpdateTicketStatusRequest } from "@operator/bindings/UpdateTicketStatusRequest"; +import type { UpdateTicketStatusResponse } from "@operator/bindings/UpdateTicketStatusResponse"; import type { TargetProbeResponse } from "@operator/bindings/TargetProbeResponse"; import type { HealthResponse } from "@operator/bindings/HealthResponse"; import type { StatusResponse } from "@operator/bindings/StatusResponse"; @@ -675,6 +684,35 @@ export class OperatorApi { return requestVoid(this.base, "/api/v1/queue/sync", { method: "POST" }); } + // --- Campaigns --- + + campaigns(): Promise { + return request(this.base, "/api/v1/campaigns"); + } + + campaign(id: string): Promise { + return request(this.base, `/api/v1/campaigns/${encodeURIComponent(id)}`); + } + + campaignStatus(id: string): Promise { + return request(this.base, `/api/v1/campaigns/${encodeURIComponent(id)}/status`); + } + + launchCampaign(id: string, dryRun: boolean): Promise { + const body: LaunchCampaignRequest = { dry_run: dryRun }; + return request(this.base, `/api/v1/campaigns/${encodeURIComponent(id)}/launch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: toJson(body), + }); + } + + pauseCampaign(id: string): Promise { + return request(this.base, `/api/v1/campaigns/${encodeURIComponent(id)}/pause`, { + method: "POST", + }); + } + // --- Agents --- activeAgents(): Promise { @@ -712,6 +750,19 @@ export class OperatorApi { // --- Tickets --- + ticket(ticketId: string): Promise { + return request(this.base, `/api/v1/tickets/${encodeURIComponent(ticketId)}`); + } + + moveTicket(ticketId: string, status: string): Promise { + const body: UpdateTicketStatusRequest = { status }; + return request(this.base, `/api/v1/tickets/${encodeURIComponent(ticketId)}/status`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: toJson(body), + }); + } + launchTicket(ticketId: string, options: LaunchTicketRequest): Promise { return request(this.base, `/api/v1/tickets/${encodeURIComponent(ticketId)}/launch`, { method: "POST", diff --git a/ui/src/api/definitions.ts b/ui/src/api/definitions.ts index 5fc6d70a..b63c7bd5 100644 --- a/ui/src/api/definitions.ts +++ b/ui/src/api/definitions.ts @@ -21,6 +21,7 @@ import type { BootstrapSubmitRequest } from "@operator/bindings/BootstrapSubmitR import type { OperatorApi } from "../api-client"; import type { MutationDefinition, QueryDefinition } from "./adapter"; import { + CAMPAIGN_QUERY_KEYS, ISSUE_TYPE_QUERY_KEYS, LICENSE_QUERY_KEYS, MODEL_QUERY_KEYS, @@ -101,6 +102,10 @@ export const kanbanQuery = staticRead(QUERY_KEYS.kanban, (api) => api.kanban()); export const activeAgentsQuery = staticRead(QUERY_KEYS.activeAgents, (api) => api.activeAgents()); +export const ticketQuery = memoizeDefinition((ticketId: string) => + readWith(QUERY_KEYS.ticket, { ticketId }, (api, params) => api.ticket(params.ticketId)), +); + export const agentQuery = memoizeDefinition((agentId: string) => readWith(QUERY_KEYS.agent, { agentId }, (api, params) => api.getAgent(params.agentId)), ); @@ -135,6 +140,16 @@ export const licenseQuery = staticRead(QUERY_KEYS.license, (api) => api.license( export const targetsQuery = staticRead(QUERY_KEYS.targets, (api) => api.targets()); +export const campaignsQuery = staticRead(QUERY_KEYS.campaigns, (api) => api.campaigns()); + +export const campaignQuery = memoizeDefinition((id: string) => + readWith(QUERY_KEYS.campaign, { id }, (api, params) => api.campaign(params.id)), +); + +export const campaignStatusQuery = memoizeDefinition((id: string) => + readWith(QUERY_KEYS.campaignStatus, { id }, (api, params) => api.campaignStatus(params.id)), +); + export const sessionsQuery = staticRead(QUERY_KEYS.sessions, (api) => api.listSessions()); export const accessKeysQuery = staticRead(QUERY_KEYS.accessKeys, (api) => api.listAccessKeys()); @@ -327,6 +342,33 @@ export const probeTargetMutation: MutationDefinition, { run: (api, variables) => api.probeTarget(variables.name), }; +export const moveTicketMutation: MutationDefinition< + ApiResult<"moveTicket">, + { ticketId: string; status: string } +> = { + key: "move-ticket", + affected: [...TICKET_QUERY_KEYS, ...CAMPAIGN_QUERY_KEYS], + run: (api, variables) => api.moveTicket(variables.ticketId, variables.status), +}; + +export const launchCampaignMutation: MutationDefinition< + ApiResult<"launchCampaign">, + { id: string; dryRun: boolean } +> = { + key: "launch-campaign", + affected: [...CAMPAIGN_QUERY_KEYS], + run: (api, variables) => api.launchCampaign(variables.id, variables.dryRun), +}; + +export const pauseCampaignMutation: MutationDefinition< + ApiResult<"pauseCampaign">, + { id: string } +> = { + key: "pause-campaign", + affected: [...CAMPAIGN_QUERY_KEYS], + run: (api, variables) => api.pauseCampaign(variables.id), +}; + export const pauseQueueMutation: MutationDefinition< ApiResult<"pauseQueue">, Record diff --git a/ui/src/api/queries/index.ts b/ui/src/api/queries/index.ts index ae8f8fa8..a0d678df 100644 --- a/ui/src/api/queries/index.ts +++ b/ui/src/api/queries/index.ts @@ -13,6 +13,7 @@ export const QUERY_KEYS = { kanban: "kanban", activeAgents: "active-agents", agent: "agent", + ticket: "ticket", configuration: "configuration", executionTargets: "execution-targets", llmTools: "llm-tools", @@ -23,6 +24,9 @@ export const QUERY_KEYS = { projects: "projects", license: "license", targets: "targets", + campaigns: "campaigns", + campaign: "campaign", + campaignStatus: "campaign-status", sessions: "sessions", accessKeys: "access-keys", providerKinds: "provider-kinds", @@ -39,6 +43,7 @@ export const STATUS_POLL_MS = 3000; export const AGENT_POLL_MS = 5000; export const TICKET_QUERY_KEYS = [ + QUERY_KEYS.ticket, QUERY_KEYS.kanban, QUERY_KEYS.queueStatus, QUERY_KEYS.activeAgents, @@ -58,6 +63,16 @@ export const LICENSE_QUERY_KEYS = [ QUERY_KEYS.license, QUERY_KEYS.targets, QUERY_KEYS.executionTargets, + QUERY_KEYS.campaigns, + QUERY_KEYS.campaign, + QUERY_KEYS.campaignStatus, +] as const; + +export const CAMPAIGN_QUERY_KEYS = [ + QUERY_KEYS.campaigns, + QUERY_KEYS.campaign, + QUERY_KEYS.campaignStatus, + ...TICKET_QUERY_KEYS, ] as const; export const ISSUE_TYPE_QUERY_KEYS = [ diff --git a/ui/src/attention-title.test.ts b/ui/src/attention-title.test.ts new file mode 100644 index 00000000..5f263e34 --- /dev/null +++ b/ui/src/attention-title.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { titleWithCount } from "./attention-title"; + +describe("titleWithCount", () => { + test("prefixes a count", () => { + expect(titleWithCount("Operator", 2)).toBe("(2) Operator"); + }); + + test("replaces an existing count", () => { + expect(titleWithCount("(2) Operator", 3)).toBe("(3) Operator"); + }); + + test("clears the count at zero", () => { + expect(titleWithCount("(2) Operator", 0)).toBe("Operator"); + }); +}); diff --git a/ui/src/attention-title.ts b/ui/src/attention-title.ts new file mode 100644 index 00000000..3b5daef3 --- /dev/null +++ b/ui/src/attention-title.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import type { ActiveAgentsResponse } from "@operator/bindings/ActiveAgentsResponse"; +import { AGENT_POLL_MS, useApiQuery } from "./api"; +import { activeAgentsQuery } from "./api/definitions"; + +export function attentionCount(res: ActiveAgentsResponse | null | undefined): number { + return res?.agents.filter((a) => a.attention).length ?? 0; +} + +export function titleWithCount(title: string, count: number): string { + const base = title.replace(/^\(\d+\) /, ""); + return count > 0 ? `(${count}) ${base}` : base; +} + +/** Prefixes the document title with the number of agents needing attention. */ +export function useAttentionTitle(): void { + const agents = useApiQuery(activeAgentsQuery(), { pollIntervalMs: AGENT_POLL_MS }); + const count = attentionCount(agents.data); + useEffect(() => { + document.title = titleWithCount(document.title, count); + }, [count]); +} diff --git a/ui/src/components/ConnectPanel.module.css b/ui/src/components/ConnectPanel.module.css new file mode 100644 index 00000000..f960add3 --- /dev/null +++ b/ui/src/components/ConnectPanel.module.css @@ -0,0 +1,138 @@ +.panel { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 24px; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 10px 12px; + border-radius: var(--radius); + background: var(--warning-bg); + color: var(--warning); + font-weight: 600; +} + +.banner[data-reason="orphaned"] { + background: var(--danger-bg); + color: var(--danger); +} + +.header { + display: flex; + align-items: center; + gap: 8px; +} + +.header h2 { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.phase { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: var(--radius-sm); + text-transform: uppercase; + background: var(--surface-alt); + color: var(--text-muted); +} + +.phase[data-phase="live"] { + background: var(--success-bg); + color: var(--success); +} + +.phase[data-phase="resumable"] { + background: var(--warning-bg); + color: var(--warning); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.primary, +.secondary { + font: inherit; + font-size: 0.85rem; + padding: 6px 12px; + border-radius: var(--radius); + cursor: pointer; +} + +.primary { + background: var(--action-bg); + color: var(--action-text); + border: 1px solid var(--action-bg); +} + +.primary:hover { + background: var(--action-bg-hover); +} + +.secondary { + background: var(--surface-alt); + color: var(--text); + border: 1px solid var(--border); +} + +.secondary:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.command { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + font-size: 0.8rem; +} + +.command code { + font-family: var(--font-mono); + background: var(--surface-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 6px; + overflow-x: auto; + white-space: nowrap; +} + +.commandLabel { + color: var(--text-muted); +} + +.muted { + color: var(--text-muted); + font-size: 0.85rem; + margin: 0; +} + +.error { + color: var(--danger); + font-size: 0.8rem; +} + +@media (max-width: 600px) { + .command { + grid-template-columns: 1fr auto; + } + + .commandLabel { + grid-column: 1 / -1; + } +} diff --git a/ui/src/components/ConnectPanel.tsx b/ui/src/components/ConnectPanel.tsx new file mode 100644 index 00000000..dbc3c529 --- /dev/null +++ b/ui/src/components/ConnectPanel.tsx @@ -0,0 +1,126 @@ +import { useCallback, useState } from "react"; +import type { Attention } from "@operator/bindings/Attention"; +import type { ConnectOption } from "@operator/bindings/ConnectOption"; +import type { ConnectPlan } from "@operator/bindings/ConnectPlan"; +import { useApiMutation } from "../api"; +import { focusSessionMutation } from "../api/definitions"; +import { useHost } from "../host"; +import { connectAction } from "../session-links"; +import styles from "./ConnectPanel.module.css"; + +const ATTENTION_TEXT: Record = { + awaiting_input: "Agent is waiting for you", + review: "Review pending", + orphaned: "Session lost", + idle: "No output for a while", +}; + +export function attentionText(attention: Attention): string { + const head = ATTENTION_TEXT[attention.reason]; + return attention.detail ? `${head} · ${attention.detail}` : head; +} + +/** Connect controls for an in-progress agent: preferred link, alternatives, attach commands. */ +export function ConnectPanel({ + agentId, + plan, + attention, +}: { + agentId: string; + plan: ConnectPlan; + attention?: Attention | null; +}) { + const host = useHost(); + const focus = useApiMutation(focusSessionMutation); + const [copied, setCopied] = useState(null); + + const run = useCallback( + (option: ConnectOption) => { + const action = connectAction(option); + switch (action.kind) { + case "open-url": + host.openExternal(action.url); + break; + case "focus-api": + focus.mutate({ agentId }); + break; + case "copy": + void navigator.clipboard + ?.writeText(action.text) + .then(() => setCopied(action.text)) + .catch(() => setCopied(null)); + break; + case "none": + break; + } + }, + [agentId, focus, host], + ); + + const preferred = plan.preferred; + const others = plan.options.filter( + (o) => o.kind !== "command" && !(preferred && sameOption(o, preferred)), + ); + const commands = plan.options.filter((o) => o.kind === "command" && o.command); + + return ( +
+ {attention && ( + + {attentionText(attention)} + {preferred && ( + + )} + + )} + +
+

Connect

+ + {plan.phase} + +
+ + {plan.options.length === 0 && ( +

No session to attach to or resume.

+ )} + +
+ {preferred && !attention && ( + + )} + {others.map((o) => ( + + ))} +
+ {focus.error &&
{focus.error.message}
} + + {commands.map((o) => ( +
+ {o.label} + {o.command} + +
+ ))} +
+ ); +} + +function sameOption(a: ConnectOption, b: ConnectOption): boolean { + return a.interface === b.interface && a.kind === b.kind && a.label === b.label; +} diff --git a/ui/src/components/TicketDetailPanel.tsx b/ui/src/components/TicketDetailPanel.tsx index 37c75dc5..f4fc1fdd 100644 --- a/ui/src/components/TicketDetailPanel.tsx +++ b/ui/src/components/TicketDetailPanel.tsx @@ -1,21 +1,20 @@ import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { KanbanTicketCard } from "@operator/bindings/KanbanTicketCard"; -import { LaunchForm, TicketDetailView } from "@operator/webcomponents"; -import type { LaunchFormValue } from "@operator/webcomponents"; -import { useApiMutation, useApiQuery } from "../api"; +import { TicketDetailView, type AsyncValue } from "@operator/webcomponents"; +import type { IssueType } from "@operator/bindings/IssueType"; +import { AGENT_POLL_MS, useApiMutation, useApiQuery } from "../api"; import { - configurationQuery, - delegatorsQuery, - executionTargetsQuery, + activeAgentsQuery, focusSessionMutation, issueTypeDocumentQuery, - launchTicketMutation, } from "../api/definitions"; +import { ConnectPanel } from "./ConnectPanel"; import { useHost } from "../host"; import { useRightPanel } from "../right-panel"; import { wrapperSessionLink } from "../session-links"; import styles from "./TicketDetailPanel.module.css"; +import { useTicketLaunch } from "./use-ticket-launch"; /** * Right-panel contents for a kanban ticket: detail, the issue-type workflow @@ -29,45 +28,16 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { const navigate = useNavigate(); const { close } = useRightPanel(); - const [delegator, setDelegator] = useState(""); - const [wrapper, setWrapper] = useState(""); - const [target, setTarget] = useState(""); - const [yolo, setYolo] = useState(false); const [focused, setFocused] = useState(false); - - const config = useApiQuery(configurationQuery()); - const delegators = useApiQuery(delegatorsQuery()); - const targets = useApiQuery(executionTargetsQuery()); + const { controls: launchControls, result } = useTicketLaunch(ticket.id); const workflow = useApiQuery(issueTypeDocumentQuery(ticket.ticket_type)); - const launch = useApiMutation(launchTicketMutation); const focus = useApiMutation(focusSessionMutation); - - const defaultWrapperLabel = config.data?.launch.session_wrapper ?? "configured"; - const result = launch.data; - - const onLaunch = useCallback(() => { - launch.mutate({ - ticketId: ticket.id, - options: { - delegator: delegator || null, - provider: null, - model: null, - model_server: null, - yolo_mode: yolo, - wrapper: wrapper || null, - retry_reason: null, - resume_session_id: null, - target: target || null, - }, - }); - }, [delegator, launch, target, ticket.id, wrapper, yolo]); - - const handleFormChange = useCallback((value: LaunchFormValue) => { - setDelegator(value.delegator); - setWrapper(value.wrapper); - setTarget(value.target); - setYolo(value.yolo); - }, []); + const isActive = ticket.status !== "queued" && ticket.status !== "completed"; + const agents = useApiQuery(activeAgentsQuery(), { + enabled: isActive || Boolean(result), + pollIntervalMs: AGENT_POLL_MS, + }); + const agent = agents.data?.agents.find((a) => a.ticket_id === ticket.id); const link = useMemo(() => (result ? wrapperSessionLink(result) : null), [result]); const openAgentDetail = useCallback(() => { @@ -77,6 +47,10 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { void navigate(`/agent/${encodeURIComponent(result.agent_id)}`); close(); }, [close, navigate, result]); + const openTicketPage = useCallback(() => { + void navigate(`/tickets/${encodeURIComponent(ticket.id)}`); + close(); + }, [close, navigate, ticket.id]); const openSession = useCallback(() => { if (link?.kind === "open-url") { host.openExternal(link.url); @@ -96,28 +70,14 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { ); }, [focus, result]); const isFinished = ticket.status === "completed"; - const formValue: LaunchFormValue = { delegator, wrapper, target, yolo }; - const launchLoading = config.isLoading || delegators.isLoading || targets.isLoading; - const launchError = - config.error?.message ?? delegators.error?.message ?? targets.error?.message ?? null; - - const launchControls = launchLoading ? ( -
Loading launch configuration…
- ) : launchError ? ( -
Launch options unavailable: {launchError}
- ) : ( - item.available) - .map((item) => item.name)} - defaultWrapperLabel={defaultWrapperLabel} - busy={launch.isPending} - error={launch.error?.message ?? null} - onChange={handleFormChange} - onSubmit={onLaunch} - /> + const workflowValue = useMemo>( + () => + workflow.error + ? { status: "error", message: workflow.error.message } + : workflow.data + ? { status: "ready", data: workflow.data } + : { status: "loading", message: "Loading workflow…" }, + [workflow.data, workflow.error], ); const launchActions = result ? ( @@ -153,18 +113,20 @@ export function TicketDetailPanel({ ticket }: { ticket: KanbanTicketCard }) { ) : undefined; return ( - + <> + + {agent && ( + + )} + + ); } diff --git a/ui/src/components/use-ticket-launch.tsx b/ui/src/components/use-ticket-launch.tsx new file mode 100644 index 00000000..767ebf76 --- /dev/null +++ b/ui/src/components/use-ticket-launch.tsx @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { LaunchForm } from "@operator/webcomponents"; +import type { LaunchFormValue } from "@operator/webcomponents"; +import type { LaunchTicketResponse } from "../api-client"; +import { useApiMutation, useApiQuery } from "../api"; +import { + configurationQuery, + delegatorsQuery, + executionTargetsQuery, + launchTicketMutation, +} from "../api/definitions"; +import styles from "./TicketDetailPanel.module.css"; + +const EMPTY_FORM: LaunchFormValue = { delegator: "", wrapper: "", target: "", yolo: false }; + +/** The launch form for one ticket, and the launch it produced. */ +export function useTicketLaunch(ticketId: string): { + controls: ReactNode; + result: LaunchTicketResponse | undefined; +} { + const [form, setForm] = useState(EMPTY_FORM); + const config = useApiQuery(configurationQuery()); + const delegators = useApiQuery(delegatorsQuery()); + const targets = useApiQuery(executionTargetsQuery()); + const launch = useApiMutation(launchTicketMutation); + + const onLaunch = useCallback(() => { + launch.mutate({ + ticketId, + options: { + delegator: form.delegator || null, + provider: null, + model: null, + model_server: null, + yolo_mode: form.yolo, + wrapper: form.wrapper || null, + retry_reason: null, + resume_session_id: null, + target: form.target || null, + }, + }); + }, [form, launch, ticketId]); + + const delegatorList = useMemo(() => delegators.data?.delegators ?? [], [delegators.data]); + const targetNames = useMemo( + () => (targets.data?.targets ?? []).filter((item) => item.available).map((item) => item.name), + [targets.data], + ); + + const loading = config.isLoading || delegators.isLoading || targets.isLoading; + const error = config.error?.message ?? delegators.error?.message ?? targets.error?.message; + + const controls = loading ? ( +
Loading launch configuration…
+ ) : error ? ( +
Launch options unavailable: {error}
+ ) : ( + + ); + + return { controls, result: launch.data ?? undefined }; +} diff --git a/ui/src/concepts.ts b/ui/src/concepts.ts index eef7e8f4..a22664c7 100644 --- a/ui/src/concepts.ts +++ b/ui/src/concepts.ts @@ -134,6 +134,15 @@ export const CONCEPTS: Record = { docsUrl: `${DOCS_BASE}/getting-started/remote-targets/`, summary: "SSH hosts and Coder workspaces agents can run on. Requires Premium.", }, + campaigns: { + key: "campaigns", + icon: "milestone", + label: "Campaigns", + route: "/campaigns", + docsUrl: `${DOCS_BASE}/getting-started/concepts/campaigns/`, + summary: + "Epics of tickets planned into dependency waves and launched together. Requires Premium.", + }, license: { key: "license", icon: "key", @@ -161,4 +170,4 @@ export const STATUS_KEYS = [ ] as const; /** Sidebar order for the web-only pages. */ -export const PAGE_KEYS = ["dashboard", "queue"] as const; +export const PAGE_KEYS = ["dashboard", "queue", "campaigns"] as const; diff --git a/ui/src/host.ts b/ui/src/host.ts index 8a6bfbde..a2bd1e8a 100644 --- a/ui/src/host.ts +++ b/ui/src/host.ts @@ -3,6 +3,8 @@ import { createContext, useContext } from "react"; export interface Host { readonly profileId?: string; readonly signal?: AbortSignal; + /** The host opens local files itself (the VS Code webview). */ + readonly opensFiles?: boolean; baseUrl(): string; openExternal(url: string): void; browseFolder(): Promise; @@ -32,6 +34,7 @@ export type VscodeApi = { }; class VscodeHost implements Host { + readonly opensFiles = true; private vscode: VscodeApi; private apiUrl: string; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index ed9abedb..4ec3e265 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -25,6 +25,9 @@ import { WorkspaceGate } from "./WorkspaceGate"; import { ProfilesProvider } from "./profiles-context"; import { LicensePage } from "./routes/LicensePage"; import { RemoteTargetsPage } from "./routes/RemoteTargetsPage"; +import { CampaignsPage } from "./routes/CampaignsPage"; +import { CampaignDetailPage } from "./routes/CampaignDetailPage"; +import { TicketPage } from "./routes/TicketPage"; const host = createBrowserHost(); @@ -48,6 +51,8 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -62,6 +67,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> diff --git a/ui/src/routes/AgentDetailPage.tsx b/ui/src/routes/AgentDetailPage.tsx index 02cde885..a82573fb 100644 --- a/ui/src/routes/AgentDetailPage.tsx +++ b/ui/src/routes/AgentDetailPage.tsx @@ -1,6 +1,7 @@ import { useParams, Link } from "react-router-dom"; import { AGENT_POLL_MS, useApiQuery } from "../api"; import { agentQuery } from "../api/definitions"; +import { ConnectPanel } from "../components/ConnectPanel"; import styles from "./AgentDetailPage.module.css"; export function AgentDetailPage() { @@ -27,12 +28,14 @@ export function AgentDetailPage() {

- {agent.ticket_id} + {agent.ticket_id} {agent.status}

+ +
@@ -44,6 +47,8 @@ export function AgentDetailPage() { + +
{agent.pr_url && ( diff --git a/ui/src/routes/CampaignDetailPage.tsx b/ui/src/routes/CampaignDetailPage.tsx new file mode 100644 index 00000000..61818202 --- /dev/null +++ b/ui/src/routes/CampaignDetailPage.tsx @@ -0,0 +1,258 @@ +import { useCallback, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { PremiumGate } from "@operator/webcomponents"; +import type { BlockReason } from "@operator/bindings/BlockReason"; +import type { CampaignLaunchOutcome } from "@operator/bindings/CampaignLaunchOutcome"; +import type { CampaignMember } from "@operator/bindings/CampaignMember"; +import type { CampaignStatusView } from "@operator/bindings/CampaignStatusView"; +import { STATUS_POLL_MS, useApiMutation, useApiQuery } from "../api"; +import { + activeAgentsQuery, + campaignQuery, + campaignStatusQuery, + launchCampaignMutation, + licenseQuery, + pauseCampaignMutation, +} from "../api/definitions"; +import styles from "./CampaignsPage.module.css"; + +const LAUNCHABLE = new Set(["draft", "ready", "paused"]); + +function blockedLabel(reason: BlockReason): string { + if (reason.kind === "gate") { + return `waiting on ${reason.refs.join(", ")}`; + } + if (reason.kind === "unsynced") { + return `waiting on unsynced ${reason.refs.join(", ")}`; + } + return BLOCKED_LABELS[reason.kind]; +} + +const BLOCKED_LABELS = { + dependencies: "waiting on dependencies", + license: "needs a Premium license", + capacity: "waiting for a free slot", +} as const; + +function cap(value: number, limit: number | null | undefined): string { + return limit == null ? String(value) : `${value} / ${limit}`; +} + +function Member({ + member, + status, + agentId, +}: { + member: CampaignMember; + status: CampaignStatusView | undefined; + agentId: string | undefined; +}) { + const blocked = status?.report.blocked[member.id]; + const awaiting = status?.report.awaiting_operator.includes(member.id); + const stalled = status?.report.stalled.includes(member.id); + return ( +
+
+ {member.id}{" "} + {member.column} + {agentId && ( + <> + {" · "} + agent + + )} +
+
{member.summary}
+ {member.depends_on.length > 0 && ( +
depends on {member.depends_on.join(", ")}
+ )} + {blocked &&
{blockedLabel(blocked)}
} + {awaiting &&
awaiting operator (paired)
} + {stalled &&
stalled: in progress with no live agent
} +
+ ); +} + +function LaunchPreview({ + outcome, + busy, + onConfirm, + onCancel, +}: { + outcome: CampaignLaunchOutcome; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const blocked = outcome.violations.length > 0; + return ( +
+

Launch preview

+

+ {outcome.waves.length} {outcome.waves.length === 1 ? "wave" : "waves"}; the first launches{" "} + {outcome.report.launched.length > 0 ? outcome.report.launched.join(", ") : "nothing yet"}. +

+ {blocked && ( +
    + {outcome.violations.map((v) => ( +
  • + {v} +
  • + ))} +
+ )} +
+ + +
+
+ ); +} + +function CampaignDetail({ id }: { id: string }) { + const detail = useApiQuery(campaignQuery(id), { pollIntervalMs: STATUS_POLL_MS }); + const status = useApiQuery(campaignStatusQuery(id), { pollIntervalMs: STATUS_POLL_MS }); + const agents = useApiQuery(activeAgentsQuery()); + const launch = useApiMutation(launchCampaignMutation); + const pause = useApiMutation(pauseCampaignMutation); + const [preview, setPreview] = useState(null); + const [message, setMessage] = useState(null); + const busy = launch.isPending || pause.isPending; + + const onPreview = useCallback(() => { + setMessage(null); + launch.mutate( + { id, dryRun: true }, + { onSuccess: setPreview, onError: (cause) => setMessage(cause.message) }, + ); + }, [id, launch]); + + const onConfirm = useCallback(() => { + launch.mutate( + { id, dryRun: false }, + { + onSuccess: (outcome) => { + setPreview(null); + setMessage(`Campaign ${outcome.status}.`); + }, + onError: (cause) => setMessage(cause.message), + }, + ); + }, [id, launch]); + + const onCancel = useCallback(() => setPreview(null), []); + + const onPause = useCallback(() => { + pause.mutate( + { id }, + { + onSuccess: () => setMessage("Campaign paused."), + onError: (cause) => setMessage(cause.message), + }, + ); + }, [id, pause]); + + if (detail.error) { + return {detail.error.message}; + } + if (!detail.data) { + return

Loading campaign…

; + } + + const { campaign, members, plan_error } = detail.data; + const lifecycle = status.data?.status ?? campaign.status; + const pauseReason = status.data?.pause_reason; + const byId = new Map(members.map((m) => [m.id, m])); + const agentFor = new Map((agents.data?.agents ?? []).map((a) => [a.ticket_id, a.id])); + const counters = status.data?.counters; + + return ( + <> +

+ {campaign.title} + + {lifecycle} + +

+ {pauseReason &&

Paused: {pauseReason}

} + {plan_error &&

{plan_error}

} + {message && {message}} +
+ {LAUNCHABLE.has(lifecycle) && ( + + )} + {lifecycle === "running" && ( + + )} +
+ {preview && ( + + )} +
+ {detail.data.waves.map((wave, index) => ( +
+

Wave {index + 1}

+ {wave.map((ticketId) => { + const member = byId.get(ticketId); + return member ? ( + + ) : null; + })} +
+ ))} +
+ {counters && status.data && ( +
+

Governance

+
+
Launched
+
{cap(counters.launched, counters.max_launches)}
+
Active
+
{cap(counters.active, counters.max_parallel)}
+
Retries
+
{cap(counters.retries, counters.max_retries)}
+
Policy
+
{status.data.policy.name}
+
+
+ )} + + ); +} + +export function CampaignDetailPage() { + const { id = "" } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const license = useApiQuery(licenseQuery()); + const onAddLicense = useCallback(() => { + void navigate("/settings/license"); + }, [navigate]); + + return ( +
+
+ Campaigns / {id} +
+

+ {id} Premium +

+ {license.error && {license.error.message}} + + + +
+ ); +} diff --git a/ui/src/routes/CampaignsPage.module.css b/ui/src/routes/CampaignsPage.module.css new file mode 100644 index 00000000..3960b79e --- /dev/null +++ b/ui/src/routes/CampaignsPage.module.css @@ -0,0 +1,131 @@ +.page { + padding: 1.5rem; + max-width: 64rem; +} + +.heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; +} + +.badge { + font-size: 0.75rem; + border: 1px solid currentColor; + border-radius: 1rem; + padding: 0.1rem 0.5rem; + color: var(--text-muted); +} + +.status { + display: block; + margin-block: 0.75rem; +} + +.muted { + color: var(--text-muted); + font-size: 0.875rem; +} + +.list { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.card { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + background: var(--surface); +} + +.state { + font-size: 0.75rem; + padding: 0.1rem 0.5rem; + border-radius: 0.25rem; + text-transform: uppercase; + font-weight: 600; + background: var(--surface-alt); +} + +.state[data-status="running"] { + background: var(--success-bg); + color: var(--success); +} + +.state[data-status="paused"] { + background: var(--warning-bg); + color: var(--warning); +} + +.state[data-status="cancelled"] { + background: var(--danger-bg); + color: var(--danger); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-block: 1rem; +} + +.actions button { + min-height: 2rem; +} + +.confirm { + border: 1px solid var(--accent); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin-block: 1rem; +} + +.violation { + color: var(--danger); +} + +.waves { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 1rem; +} + +.wave { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.75rem; + background: var(--surface); +} + +.wave h3 { + margin-top: 0; +} + +.member { + border-top: 1px solid var(--border); + padding-block: 0.5rem; +} + +.member:first-of-type { + border-top: none; +} + +.note { + color: var(--warning); + font-size: 0.8125rem; +} + +.counters { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 1rem; +} + +.counters dd { + margin: 0; +} diff --git a/ui/src/routes/CampaignsPage.tsx b/ui/src/routes/CampaignsPage.tsx new file mode 100644 index 00000000..64a647d3 --- /dev/null +++ b/ui/src/routes/CampaignsPage.tsx @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { PremiumGate } from "@operator/webcomponents"; +import { useApiQuery } from "../api"; +import { campaignsQuery, licenseQuery } from "../api/definitions"; +import styles from "./CampaignsPage.module.css"; + +function CampaignList() { + const query = useApiQuery(campaignsQuery()); + if (query.error) { + return {query.error.message}; + } + if (!query.data) { + return

Loading campaigns…

; + } + if (query.data.campaigns.length === 0) { + return ( +

+ No campaigns yet. Create one over MCP or with POST /api/v1/campaigns. +

+ ); + } + return ( +
    + {query.data.campaigns.map(({ campaign, members }) => ( +
  • +
    + {campaign.id} + + {campaign.status} + +
    +
    {campaign.title}
    +
    + {members} {members === 1 ? "ticket" : "tickets"} · created {campaign.created} +
    +
  • + ))} +
+ ); +} + +export function CampaignsPage() { + const navigate = useNavigate(); + const license = useApiQuery(licenseQuery()); + const onAddLicense = useCallback(() => { + void navigate("/settings/license"); + }, [navigate]); + + return ( +
+

+ Campaigns Premium +

+

Epics of tickets, planned into dependency waves and launched together.

+ {license.error && {license.error.message}} + + + +
+ ); +} diff --git a/ui/src/routes/ModelProvidersPage.tsx b/ui/src/routes/ModelProvidersPage.tsx index 71422d70..5a66790d 100644 --- a/ui/src/routes/ModelProvidersPage.tsx +++ b/ui/src/routes/ModelProvidersPage.tsx @@ -291,6 +291,7 @@ function CreateDelegatorForm({ git: serializeGitDraft(git), launch_config: null, remote_agent: null, + governance: null, }, { onSuccess: () => { @@ -586,6 +587,7 @@ function DelegatorGitEditor({ delegator }: { delegator: DelegatorResponse }) { model_server: delegator.model_server ?? null, launch_config: delegator.launch_config ?? null, remote_agent: delegator.remote_agent ?? null, + governance: delegator.governance ?? null, git: serializeGitDraft(git), }, }, diff --git a/ui/src/routes/TicketPage.module.css b/ui/src/routes/TicketPage.module.css new file mode 100644 index 00000000..4f1e845a --- /dev/null +++ b/ui/src/routes/TicketPage.module.css @@ -0,0 +1,154 @@ +.page { + padding: 1.5rem; + max-width: 80rem; +} + +.header { + margin-bottom: 1rem; +} + +.title { + margin: 0 0 0.5rem; + font-size: 1.5rem; +} + +.summary { + font-weight: 400; +} + +.badges, +.meta, +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-block: 0.5rem; +} + +.badge { + font-size: 0.75rem; + padding: 0.1rem 0.5rem; + border-radius: 1rem; + border: 1px solid var(--border); + color: var(--text-muted); +} + +.badge[data-column="in-progress"] { + background: var(--success-bg); + color: var(--success); +} + +.badge[data-column="completed"] { + background: var(--surface-alt); +} + +.open { + display: flex; + flex-direction: column; + gap: 0.375rem; + margin-block: 1rem; +} + +fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} + +.openRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; +} + +.label { + min-width: 8rem; + font-size: 0.8125rem; + color: var(--text-muted); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2rem; +} + +.chip img { + width: 1rem; + height: 1rem; +} + +.path { + font-size: 0.75rem; + color: var(--text-muted); + overflow-wrap: anywhere; +} + +.launch { + flex-basis: 100%; + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.75rem; +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 20rem; + gap: 1.5rem; +} + +@media (max-width: 900px) { + .columns { + grid-template-columns: minmax(0, 1fr); + } +} + +.body { + min-width: 0; +} + +.side { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.card { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + background: var(--surface); +} + +.card h2 { + font-size: 0.875rem; + margin: 0 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.list { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; +} + +.muted { + color: var(--text-muted); + font-size: 0.875rem; +} + +.error { + color: var(--danger); + font-size: 0.875rem; +} + +.status { + display: block; + margin-block: 0.75rem; +} diff --git a/ui/src/routes/TicketPage.tsx b/ui/src/routes/TicketPage.tsx new file mode 100644 index 00000000..2747b18a --- /dev/null +++ b/ui/src/routes/TicketPage.tsx @@ -0,0 +1,408 @@ +import { useCallback, useMemo, useState, type ChangeEvent } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + MarkdownView, + StepProgress, + openTargets, + sessionTargets, + ticketFileTargets, + type OpenTarget, + type SessionTarget, +} from "@operator/webcomponents"; +import type { TicketAgent } from "@operator/bindings/TicketAgent"; +import type { TicketDetailResponse } from "@operator/bindings/TicketDetailResponse"; +import { ApiError } from "../api-client"; +import { STATUS_POLL_MS, useApiMutation, useApiQuery } from "../api"; +import { + approveReviewMutation, + focusSessionMutation, + issueTypeDocumentQuery, + moveTicketMutation, + rejectReviewMutation, + ticketQuery, +} from "../api/definitions"; +import { BrandIcon } from "../components/BrandIcon"; +import { useTicketLaunch } from "../components/use-ticket-launch"; +import { useHost } from "../host"; +import styles from "./TicketPage.module.css"; + +const MOVE_OPTIONS = [ + { value: "queued", label: "Queue" }, + { value: "running", label: "In progress" }, + { value: "awaiting", label: "Awaiting input" }, + { value: "completed", label: "Completed" }, +]; + +const NO_STEPS: string[] = []; + +const PROVIDER_LABEL: Record = { + jira: "Jira", + linear: "Linear", + github: "GitHub", + gitlab: "GitLab", + openspec: "OpenSpec", +}; + +function OpenGroup({ + label, + path, + targets, + remoteHost, +}: { + label: string; + path: string; + targets: OpenTarget[]; + remoteHost?: string | null; +}) { + const host = useHost(); + const open = useCallback( + (target: OpenTarget) => { + if (host.opensFiles && !remoteHost) { + host.openFile(path); + } else { + host.openExternal(target.url); + } + }, + [host, path, remoteHost], + ); + return ( +
+ {label} + {targets.map((target) => ( + + ))} + {remoteHost ? `${remoteHost}:${path}` : path} +
+ ); +} + +function OpenButton({ + target, + onOpen, +}: { + target: OpenTarget; + onOpen: (target: OpenTarget) => void; +}) { + const onClick = useCallback(() => onOpen(target), [onOpen, target]); + return ( + + ); +} + +function SessionAction({ agentId, target }: { agentId: string; target: SessionTarget }) { + const host = useHost(); + const focus = useApiMutation(focusSessionMutation); + const [copied, setCopied] = useState(false); + const onFocus = useCallback(() => focus.mutate({ agentId }), [agentId, focus]); + const onOpen = useCallback(() => { + if (target.kind === "open-url") { + host.openExternal(target.url); + } + }, [host, target]); + const onCopy = useCallback(() => { + if (target.kind === "command") { + void navigator.clipboard?.writeText(target.command).then(() => setCopied(true)); + } + }, [target]); + + if (target.kind === "open-url") { + return ( + + ); + } + if (target.kind === "focus-api") { + return ( + <> + + {focus.error && {focus.error.message}} + + ); + } + if (target.kind === "command") { + return ( + + ); + } + return ( + + {target.label} {target.detail} + + ); +} + +function Review({ agent }: { agent: TicketAgent }) { + const approve = useApiMutation(approveReviewMutation); + const reject = useApiMutation(rejectReviewMutation); + const [reason, setReason] = useState(""); + const onApprove = useCallback(() => approve.mutate({ agentId: agent.id }), [agent.id, approve]); + const onReject = useCallback( + () => reject.mutate({ agentId: agent.id, reason }), + [agent.id, reason, reject], + ); + const onReason = useCallback((e: ChangeEvent) => setReason(e.target.value), []); + const busy = approve.isPending || reject.isPending; + return ( +
+ Review + + + + {(approve.error ?? reject.error) && ( + {(approve.error ?? reject.error)?.message} + )} +
+ ); +} + +function Launch({ ticketId }: { ticketId: string }) { + const [open, setOpen] = useState(false); + const { controls } = useTicketLaunch(ticketId); + const toggle = useCallback(() => setOpen((v) => !v), []); + return ( + <> + + {open &&
{controls}
} + + ); +} + +function Move({ ticketId }: { ticketId: string }) { + const move = useApiMutation(moveTicketMutation); + const onChange = useCallback( + (e: ChangeEvent) => { + if (e.target.value) { + move.mutate({ ticketId, status: e.target.value }); + } + }, + [move, ticketId], + ); + return ( + <> + + {move.error && {move.error.message}} + + ); +} + +function ExternalIssue({ ticket }: { ticket: TicketDetailResponse }) { + const host = useHost(); + const url = ticket.external_url; + const onOpen = useCallback(() => { + if (url) { + host.openExternal(url); + } + }, [host, url]); + if (!url) { + return null; + } + const provider = ticket.external_provider ?? ""; + return ( + + ); +} + +function Dependencies({ ticket }: { ticket: TicketDetailResponse }) { + if (ticket.dependencies.length === 0 && ticket.dependents.length === 0) { + return null; + } + return ( +
+ {ticket.dependencies.length > 0 && ( + <> +

Depends on

+
    + {ticket.dependencies.map((dep) => ( +
  • + + {dep.ticket_id ? ( + {dep.ticket_id} + ) : ( + {dep.reference} + )} + {dep.column ?? "not synced"} +
  • + ))} +
+ + )} + {ticket.dependents.length > 0 && ( + <> +

Needed by

+
    + {ticket.dependents.map((id) => ( +
  • + {id} +
  • + ))} +
+ + )} +
+ ); +} + +function AgentCard({ agent }: { agent: TicketAgent }) { + const host = useHost(); + const pr = agent.pr_url; + const onPr = useCallback(() => { + if (pr) { + host.openExternal(pr); + } + }, [host, pr]); + return ( +
+

Agent

+

+ {agent.status} + {agent.llm_tool && ( + + {" "} + · {agent.llm_tool} + {agent.llm_model ? `/${agent.llm_model}` : ""} + + )} +

+ {pr && ( + + )} + {agent.last_message &&

{agent.last_message}

} +
+ ); +} + +function TicketView({ ticket }: { ticket: TicketDetailResponse }) { + const workflow = useApiQuery(issueTypeDocumentQuery(ticket.ticket_type)); + const agent = ticket.agent ?? null; + const worktree = agent?.worktree_path ?? ticket.worktree_path ?? null; + const sessions = useMemo(() => (agent ? sessionTargets(agent) : []), [agent]); + const fileTargets = useMemo( + () => ticketFileTargets(ticket.id, ticket.filepath), + [ticket.id, ticket.filepath], + ); + const remoteHost = agent?.remote_host; + const worktreeTargets = useMemo( + () => (worktree ? openTargets(worktree, remoteHost) : []), + [worktree, remoteHost], + ); + + return ( + <> +
+

+ {ticket.id} {ticket.summary} +

+
+ + {ticket.column} + + {ticket.priority} + {ticket.ticket_type} + {ticket.project} +
+
+ {ticket.campaign && ( + {ticket.campaign} + )} + + {ticket.branch && {ticket.branch}} +
+
+ +
+ {ticket.column === "queue" && !agent && } + + {agent?.status === "awaiting_input" && agent.review_state && } +
+ +
+ + {worktree && ( + + )} + {agent && sessions.length > 0 && ( +
+ Session + {sessions.map((target) => ( + + ))} +
+ )} +
+ +
+ + +
+ + ); +} + +export function TicketPage() { + const { id = "" } = useParams<{ id: string }>(); + const query = useApiQuery(ticketQuery(id), { + enabled: Boolean(id), + pollIntervalMs: STATUS_POLL_MS, + }); + + let content; + if (query.error) { + content = + query.error instanceof ApiError && query.error.status === 404 ? ( +

Ticket not found

+ ) : ( + {query.error.message} + ); + } else if (query.data) { + content = ; + } else { + content =

Loading ticket…

; + } + return
{content}
; +} diff --git a/ui/src/routes/campaign-pages.test.tsx b/ui/src/routes/campaign-pages.test.tsx new file mode 100644 index 00000000..75ed2764 --- /dev/null +++ b/ui/src/routes/campaign-pages.test.tsx @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { ApiProvider } from "../api"; +import { resetSessionState } from "../api/adapter"; +import { setCsrfToken } from "../api-client"; +import { HostContext, type Host } from "../host"; +import { mockFetch, requestBody, requestPath, restoreFetch } from "../test-fetch"; +import * as webcomponentMocks from "../test-webcomponents"; + +mock.module("@operator/webcomponents", () => webcomponentMocks); + +const { CampaignsPage } = await import("./CampaignsPage"); +const { CampaignDetailPage } = await import("./CampaignDetailPage"); + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: () => undefined, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function license(premium: boolean) { + return { status: "valid", profile_id: "default", premium, terms: null, purchase_url: null }; +} + +const CAMPAIGN = { + id: "CAMP-0001", + title: "Pricing overhaul", + status: "draft", + created: "2026-10-05", +}; + +const POLICY = { name: "campaign", require_confirmation: false }; + +function member(id: string, column: string, depends_on: string[] = []) { + return { id, summary: `${id} summary`, project: "api", column, depends_on, launches: 0 }; +} + +const MEMBERS = [member("FEAT-0001", "queue"), member("FEAT-0002", "queue", ["FEAT-0001"])]; + +const WAVES = [["FEAT-0001"], ["FEAT-0002"]]; + +function report(status: string) { + return { + status, + launched: [], + awaiting_operator: [], + stalled: [], + blocked: { "FEAT-0002": { kind: "dependencies" } }, + pause_reason: null, + }; +} + +function statusView(status: string) { + return { + id: CAMPAIGN.id, + status, + tickets: MEMBERS, + waves: WAVES, + report: report(status), + counters: { + launched: 0, + max_launches: 5, + active: 0, + max_parallel: 2, + retries: 0, + max_retries: null, + }, + policy: POLICY, + }; +} + +type Requested = { path: string; method: string; body?: string }; + +function serve(premium: boolean, status = "draft") { + const requests: Requested[] = []; + mockFetch((input, init) => { + const path = requestPath(input); + const method = init?.method ?? "GET"; + requests.push({ path, method, body: requestBody(init) }); + if (path.endsWith("/license")) { + return Promise.resolve(json(license(premium))); + } + if (path.endsWith("/agents/active")) { + return Promise.resolve(json({ agents: [], count: 0 })); + } + if (path.endsWith("/campaigns")) { + return Promise.resolve( + json({ campaigns: [{ campaign: { ...CAMPAIGN, status }, members: 2 }], total: 1 }), + ); + } + if (path.endsWith("/status")) { + return Promise.resolve(json(statusView(status))); + } + if (path.endsWith("/launch")) { + const dryRun = (JSON.parse(requestBody(init) ?? "{}") as { dry_run: boolean }).dry_run; + return Promise.resolve( + json({ + id: CAMPAIGN.id, + dry_run: dryRun, + status: dryRun ? status : "running", + waves: WAVES, + policy: POLICY, + violations: [], + report: report(dryRun ? status : "running"), + }), + ); + } + if (path.endsWith(`/campaigns/${CAMPAIGN.id}`)) { + return Promise.resolve( + json({ + campaign: { ...CAMPAIGN, status }, + members: MEMBERS, + waves: WAVES, + gates: {}, + unsynced: {}, + }), + ); + } + return Promise.resolve(json({})); + }); + return requests; +} + +const LIST_PAGE = ; +const DETAIL_PAGE = ; +const LIST_ENTRY = ["/campaigns"]; +const DETAIL_ENTRY = ["/campaigns/CAMP-0001"]; + +function mount(entry: string[]) { + setCsrfToken("csrf"); + return render( + + + + + + + + + + , + ); +} + +function campaignCalls(requests: Requested[]): Requested[] { + return requests.filter((r) => r.path.includes("/campaigns")); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + restoreFetch(); + jest.restoreAllMocks(); +}); + +describe("CampaignsPage", () => { + test("shows the paywall without Premium and never fetches campaigns", async () => { + const requests = serve(false); + mount(LIST_ENTRY); + await screen.findByText("Premium required"); + expect(campaignCalls(requests)).toEqual([]); + }); + + test("lists campaigns with Premium", async () => { + serve(true); + mount(LIST_ENTRY); + const link = await screen.findByRole("link", { name: /CAMP-0001/ }); + expect(link.getAttribute("href")).toBe("/campaigns/CAMP-0001"); + expect(screen.getByText("Pricing overhaul")).toBeTruthy(); + }); +}); + +describe("CampaignDetailPage", () => { + test("shows the paywall without Premium and never fetches the campaign", async () => { + const requests = serve(false); + mount(DETAIL_ENTRY); + await screen.findByText("Premium required"); + expect(campaignCalls(requests)).toEqual([]); + }); + + test("renders members wave by wave", async () => { + serve(true); + mount(DETAIL_ENTRY); + const waves = await screen.findAllByRole("region", { name: /^Wave / }); + expect(waves.map((w) => w.getAttribute("aria-label"))).toEqual(["Wave 1", "Wave 2"]); + expect(waves[0]?.textContent).toContain("FEAT-0001"); + expect(waves[1]?.textContent).toContain("FEAT-0002"); + expect(waves[1]?.textContent).toContain("waiting on dependencies"); + const link = screen.getByRole("link", { name: "FEAT-0001" }); + expect(link.getAttribute("href")).toBe("/tickets/FEAT-0001"); + }); + + test("previews with a dry run before launching", async () => { + const requests = serve(true); + mount(DETAIL_ENTRY); + fireEvent.click(await screen.findByRole("button", { name: "Launch…" })); + fireEvent.click(await screen.findByRole("button", { name: "Confirm launch" })); + await waitFor(() => { + expect(requests.filter((r) => r.path.endsWith("/launch")).length).toBe(2); + }); + const launches = requests.filter((r) => r.path.endsWith("/launch")).map((r) => r.body); + expect(launches).toEqual([ + JSON.stringify({ dry_run: true }), + JSON.stringify({ dry_run: false }), + ]); + }); + + test("offers pause only while running", async () => { + serve(true, "draft"); + mount(DETAIL_ENTRY); + await screen.findByRole("button", { name: "Launch…" }); + expect(screen.queryByRole("button", { name: "Pause" })).toBeNull(); + cleanup(); + resetSessionState(); + + const requests = serve(true, "running"); + mount(DETAIL_ENTRY); + fireEvent.click(await screen.findByRole("button", { name: "Pause" })); + await waitFor(() => { + expect(requests.some((r) => r.path.endsWith("/pause") && r.method === "POST")).toBe(true); + }); + }); +}); diff --git a/ui/src/routes/model-providers-page.test.tsx b/ui/src/routes/model-providers-page.test.tsx index 3f8b542a..52df1aa1 100644 --- a/ui/src/routes/model-providers-page.test.tsx +++ b/ui/src/routes/model-providers-page.test.tsx @@ -4,7 +4,7 @@ import { ApiProvider } from "../api"; import { resetSessionState } from "../api/adapter"; import { setCsrfToken } from "../api-client"; import { HostContext, type Host } from "../host"; -import { mockFetch, requestPath, restoreFetch } from "../test-fetch"; +import { mockFetch, requestBody, requestPath, restoreFetch } from "../test-fetch"; import * as webcomponentMocks from "../test-webcomponents"; mock.module("@operator/webcomponents", () => webcomponentMocks); @@ -36,6 +36,7 @@ afterEach(() => { describe("ModelProvidersPage", () => { test("creates and updates delegators", async () => { const requests: Array<{ path: string; method: string }> = []; + const bodies: Record = {}; const delegator = { name: "claude-gpt", llm_tool: "claude", @@ -45,6 +46,7 @@ describe("ModelProvidersPage", () => { model_server: "openai", launch_config: null, remote_agent: null, + governance: "careful", git: null, }; mockFetch((input, init) => { @@ -106,6 +108,7 @@ describe("ModelProvidersPage", () => { return Promise.resolve(json(delegator)); } if (path.endsWith("/delegators/claude-gpt") && method === "PUT") { + bodies.put = JSON.parse(requestBody(init) ?? "{}"); return Promise.resolve(json(delegator)); } return Promise.resolve(json({ message: `Unexpected request: ${path}` })); @@ -132,5 +135,6 @@ describe("ModelProvidersPage", () => { method: "PUT", }); }); + expect(bodies.put).toMatchObject({ governance: "careful" }); }); }); diff --git a/ui/src/routes/ticket-page.test.tsx b/ui/src/routes/ticket-page.test.tsx new file mode 100644 index 00000000..626df5ac --- /dev/null +++ b/ui/src/routes/ticket-page.test.tsx @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { ApiProvider } from "../api"; +import { resetSessionState } from "../api/adapter"; +import { setCsrfToken } from "../api-client"; +import { HostContext, type Host } from "../host"; +import { mockFetch, requestBody, requestPath, restoreFetch } from "../test-fetch"; +import * as webcomponentMocks from "../test-webcomponents"; + +mock.module("@operator/webcomponents", () => webcomponentMocks); + +const { TicketPage } = await import("./TicketPage"); + +const opened: string[] = []; + +const TEST_HOST: Host = { + baseUrl: () => "http://operator.test", + openExternal: (url) => { + opened.push(url); + }, + browseFolder: () => Promise.resolve(null), + openFile: () => undefined, +}; + +const PAGE = ; +const ENTRY = ["/tickets/FEAT-2"]; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +type Agent = Record; + +function ticket(column: string, over: Record = {}, agent: Agent | null = null) { + return { + id: "FEAT-2", + summary: "Serve price tiers", + ticket_type: "FEAT", + project: "api", + status: column === "queue" ? "queued" : "running", + step: "build", + priority: "P2-medium", + timestamp: "20260101-0001", + content: "---\nid: FEAT-2\n---\n# Body", + body: "# Serve price tiers\n\nDetails.", + filename: "20260101-0001-FEAT-api-b.md", + filepath: "/work/.tickets/in-progress/20260101-0001-FEAT-api-b.md", + sessions: {}, + step_delegators: {}, + column, + depends_on: ["FEAT-1", "jira:PROJ-9"], + dependencies: [ + { reference: "FEAT-1", ticket_id: "FEAT-1", column: "completed", satisfied: true }, + { reference: "jira:PROJ-9", satisfied: false }, + ], + dependents: ["FEAT-3"], + llm_task: {}, + agent, + ...over, + }; +} + +const ISSUE_TYPE = { + key: "FEAT", + name: "Feature", + steps: [{ name: "plan" }, { name: "build" }, { name: "review" }], +}; + +type Requested = { path: string; method: string; body?: string }; + +function serve(detail: unknown, status = 200) { + const requests: Requested[] = []; + mockFetch((input, init) => { + const path = requestPath(input); + const method = init?.method ?? "GET"; + requests.push({ path, method, body: requestBody(init) }); + if (path.endsWith("/tickets/FEAT-2")) { + return Promise.resolve(json(detail, status)); + } + if (path.includes("/issuetypes/")) { + return Promise.resolve(json(ISSUE_TYPE)); + } + if (path.endsWith("/status") || path.endsWith("/focus")) { + return Promise.resolve(json({ ok: true })); + } + return Promise.resolve(json({ delegators: [], targets: [], launch: {} })); + }); + return requests; +} + +function mount() { + setCsrfToken("csrf"); + return render( + + + + + + + + + , + ); +} + +afterEach(() => { + cleanup(); + resetSessionState(); + setCsrfToken(null); + restoreFetch(); + jest.restoreAllMocks(); + opened.length = 0; +}); + +describe("TicketPage", () => { + test("renders the body without its frontmatter", async () => { + serve(ticket("in-progress")); + mount(); + const body = await screen.findByTestId("markdown"); + expect(body.textContent).toBe("# Serve price tiers\n\nDetails."); + expect(screen.getByRole("heading", { level: 1 }).textContent).toContain("FEAT-2"); + }); + + test("links out to the kanban issue when it has one", async () => { + serve( + ticket("in-progress", { + external_provider: "jira", + external_id: "PROJ-12", + external_url: "https://acme.atlassian.net/browse/PROJ-12", + }), + ); + mount(); + fireEvent.click(await screen.findByRole("button", { name: /PROJ-12/ })); + expect(opened).toEqual(["https://acme.atlassian.net/browse/PROJ-12"]); + }); + + test("opens the ticket file and the worktree in an editor", async () => { + serve(ticket("in-progress", { worktree_path: "/work/trees/feat-2" })); + mount(); + const file = await screen.findByRole("group", { name: "Open ticket file" }); + expect(file.textContent).toContain("JetBrains"); + fireEvent.click(file.querySelector("button") as HTMLButtonElement); + expect(opened).toEqual(["vscode://open-ticket/FEAT-2"]); + opened.length = 0; + const tree = screen.getByRole("group", { name: "Open worktree" }); + fireEvent.click(tree.querySelector("button") as HTMLButtonElement); + expect(opened).toEqual(["vscode://file/work/trees/feat-2"]); + }); + + test("opens a remote worktree over SSH", async () => { + serve( + ticket( + "in-progress", + {}, + { + id: "agent-1", + status: "running", + paired: false, + started_at: "2026-01-01T00:00:00Z", + completed_steps: ["plan"], + worktree_path: "/srv/feat-2", + remote_host: "builder", + }, + ), + ); + mount(); + const tree = await screen.findByRole("group", { name: "Open worktree" }); + expect(tree.textContent).not.toContain("JetBrains"); + fireEvent.click(tree.querySelector("button") as HTMLButtonElement); + expect(opened).toEqual(["vscode://ssh/builder/srv/feat-2"]); + }); + + test("offers launch only in the queue", async () => { + serve(ticket("queue")); + mount(); + await screen.findByRole("button", { name: "Launch…" }); + cleanup(); + resetSessionState(); + + serve(ticket("in-progress")); + mount(); + await screen.findByTestId("markdown"); + expect(screen.queryByRole("button", { name: "Launch…" })).toBeNull(); + }); + + test("moves the ticket to another column", async () => { + const requests = serve(ticket("in-progress")); + mount(); + const select = await screen.findByRole("combobox", { name: "Move to" }); + fireEvent.change(select, { target: { value: "completed" } }); + await waitFor(() => { + expect(requests.find((r) => r.path.endsWith("/tickets/FEAT-2/status"))?.body).toBe( + JSON.stringify({ status: "completed" }), + ); + }); + }); + + test("offers review only while the agent awaits it", async () => { + const agent = { + id: "agent-1", + status: "running", + paired: false, + started_at: "2026-01-01T00:00:00Z", + completed_steps: [], + }; + serve(ticket("in-progress", {}, agent)); + mount(); + await screen.findByTestId("markdown"); + expect(screen.queryByRole("button", { name: "Approve" })).toBeNull(); + cleanup(); + resetSessionState(); + + serve( + ticket("in-progress", {}, { ...agent, status: "awaiting_input", review_state: "pending" }), + ); + mount(); + expect(await screen.findByRole("button", { name: "Approve" })).toBeTruthy(); + }); + + test("links dependencies and dependents to their ticket pages", async () => { + serve(ticket("in-progress")); + mount(); + const dep = await screen.findByRole("link", { name: "FEAT-1" }); + expect(dep.getAttribute("href")).toBe("/tickets/FEAT-1"); + expect(screen.getByRole("link", { name: "FEAT-3" }).getAttribute("href")).toBe( + "/tickets/FEAT-3", + ); + expect(screen.getByText("jira:PROJ-9")).toBeTruthy(); + }); + + test("focuses the agent's session", async () => { + const requests = serve( + ticket( + "in-progress", + {}, + { + id: "agent-1", + status: "running", + paired: false, + started_at: "2026-01-01T00:00:00Z", + completed_steps: [], + session_wrapper: "cmux", + }, + ), + ); + mount(); + fireEvent.click(await screen.findByRole("button", { name: "Focus cmux session" })); + await waitFor(() => { + expect(requests.some((r) => r.path.endsWith("/agents/agent-1/focus"))).toBe(true); + }); + }); + + test("says when the ticket does not exist", async () => { + serve({ error: "not_found", message: "Ticket 'FEAT-2' not found" }, 404); + mount(); + expect(await screen.findByText("Ticket not found")).toBeTruthy(); + }); +}); diff --git a/ui/src/session-links.test.ts b/ui/src/session-links.test.ts new file mode 100644 index 00000000..c5babd4e --- /dev/null +++ b/ui/src/session-links.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import type { ConnectOption } from "@operator/bindings/ConnectOption"; +import { connectAction } from "./session-links"; + +const base: ConnectOption = { + interface: "vscode", + kind: "ide", + label: "Open in VS Code", + available: true, +}; + +describe("connectAction", () => { + test("ide option opens its uri", () => { + expect(connectAction({ ...base, uri: "vscode://x/connect" })).toEqual({ + kind: "open-url", + url: "vscode://x/connect", + }); + }); + + test("command option copies its command", () => { + expect( + connectAction({ ...base, interface: "tmux", kind: "command", command: "tmux attach -t s" }), + ).toEqual({ kind: "copy", text: "tmux attach -t s" }); + }); + + test("focus option calls the api", () => { + expect(connectAction({ ...base, interface: "cmux", kind: "focus_api" })).toEqual({ + kind: "focus-api", + }); + }); + + test("unavailable option does nothing", () => { + expect(connectAction({ ...base, uri: "vscode://x", available: false })).toEqual({ + kind: "none", + }); + }); +}); diff --git a/ui/src/session-links.ts b/ui/src/session-links.ts index 8a1c8638..bb80f3cc 100644 --- a/ui/src/session-links.ts +++ b/ui/src/session-links.ts @@ -12,6 +12,7 @@ // - tmux / zellij have neither a URL scheme nor a control-plane focus path, so // their session is shown read-only. +import type { ConnectOption } from "@operator/bindings/ConnectOption"; import type { LaunchTicketResponse } from "./api-client"; /** Publisher.name of the operator VS Code extension - the URI authority. */ @@ -46,3 +47,26 @@ export function wrapperSessionLink(res: LaunchTicketResponse): WrapperSessionLin } } } + +/** What a click on a server-computed connect option does. */ +export type ConnectAction = + | { kind: "open-url"; url: string } + | { kind: "focus-api" } + | { kind: "copy"; text: string } + | { kind: "none" }; + +export function connectAction(option: ConnectOption): ConnectAction { + if (!option.available) { + return { kind: "none" }; + } + if (option.kind === "focus_api") { + return { kind: "focus-api" }; + } + if (option.kind === "ide" && option.uri) { + return { kind: "open-url", url: option.uri }; + } + if (option.kind === "command" && option.command) { + return { kind: "copy", text: option.command }; + } + return { kind: "none" }; +} diff --git a/ui/src/test-webcomponents.tsx b/ui/src/test-webcomponents.tsx index b8db0b0b..71106781 100644 --- a/ui/src/test-webcomponents.tsx +++ b/ui/src/test-webcomponents.tsx @@ -137,6 +137,19 @@ export function PremiumPaywall() { return
Premium required
; } +export function PremiumGate({ + license, + children, +}: { + license: { premium: boolean } | null | undefined; + children: ReactNode; +}) { + if (!license) { + return null; + } + return license.premium ? children : ; +} + export function AsyncState({ value, children, @@ -149,3 +162,53 @@ export function AsyncState({ } return
{value.message}
; } + +export function MarkdownView({ source }: { source: string }) { + return
{source}
; +} + +export function StepProgress({ + steps, + current, +}: { + steps: { name: string }[]; + current?: string | null; +}) { + return ( +
    + {steps.map((s) => ( +
  1. + {s.name} +
  2. + ))} +
+ ); +} + +export function openTargets(path: string, remoteHost?: string | null) { + return remoteHost + ? [{ editor: "vscode", label: "VS Code", url: `vscode://ssh/${remoteHost}${path}` }] + : [ + { editor: "vscode", label: "VS Code", url: `vscode://file${path}` }, + { editor: "jetbrains", label: "JetBrains", url: `idea://open?file=${path}` }, + ]; +} + +export function ticketFileTargets(ticketId: string, path: string) { + return [ + { editor: "vscode", label: "VS Code", url: `vscode://open-ticket/${ticketId}` }, + { editor: "jetbrains", label: "JetBrains", url: `idea://open?file=${path}` }, + ]; +} + +export function sessionTargets(agent: { + session_wrapper: string | null; + attach_command: string | null; +}) { + if (agent.session_wrapper === "cmux") { + return [{ kind: "focus-api", label: "Focus cmux session" }]; + } + return agent.attach_command + ? [{ kind: "command", label: "Attach", command: agent.attach_command }] + : []; +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 4d101c9f..87b0bcb9 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -35,6 +35,7 @@ "onView:operator-in-progress", "onView:operator-queue", "onView:operator-completed", + "onView:operator-campaigns", "onCommand:operator.showStatus", "onCommand:operator.startOperatorServer", "onCommand:operator.launchTicket", @@ -75,6 +76,12 @@ "name": "Completed", "type": "tree", "visibility": "collapsed" + }, + { + "id": "operator-campaigns", + "name": "Campaigns", + "type": "tree", + "visibility": "collapsed" } ] }, @@ -330,6 +337,26 @@ { "command": "operator.startWebhookServer", "title": "Operator: Start Webhook Server" + }, + { + "command": "operator.refreshCampaigns", + "title": "Refresh Campaigns", + "icon": "$(refresh)" + }, + { + "command": "operator.launchCampaign", + "title": "Operator: Launch Campaign", + "icon": "$(play)" + }, + { + "command": "operator.pauseCampaign", + "title": "Operator: Pause Campaign", + "icon": "$(debug-pause)" + }, + { + "command": "operator.openCampaign", + "title": "Operator: Open Campaign in Operator UI", + "icon": "$(link-external)" } ], "keybindings": [ @@ -397,6 +424,21 @@ "command": "operator.selectWorkingDirectory", "when": "view == operator-status && viewItem == workingDirConfigured", "group": "inline" + }, + { + "command": "operator.launchCampaign", + "when": "view == operator-campaigns && viewItem == campaign-launchable", + "group": "inline" + }, + { + "command": "operator.pauseCampaign", + "when": "view == operator-campaigns && viewItem == campaign-running", + "group": "inline" + }, + { + "command": "operator.openCampaign", + "when": "view == operator-campaigns && viewItem =~ /^campaign/", + "group": "inline" } ], "editor/context": [ @@ -415,6 +457,18 @@ { "command": "operator.startOperatorServer", "when": "operator.operatorAvailable && operator.ticketsParentFound" + }, + { + "command": "operator.launchCampaign", + "when": "false" + }, + { + "command": "operator.pauseCampaign", + "when": "false" + }, + { + "command": "operator.openCampaign", + "when": "false" } ] }, @@ -583,7 +637,13 @@ "fontCharacter": "\\F104" } } - } + }, + "viewsWelcome": [ + { + "view": "operator-campaigns", + "contents": "Campaigns need a running Operator server.\n[Start Operator Server](command:operator.startOperatorServer)" + } + ] }, "scripts": { "vscode:prepublish": "npm run generate:icons && npm run compile && npm run compile:webview", diff --git a/vscode-extension/src/agent-connect.ts b/vscode-extension/src/agent-connect.ts new file mode 100644 index 00000000..24ef689f --- /dev/null +++ b/vscode-extension/src/agent-connect.ts @@ -0,0 +1,121 @@ +/** + * `vscode://untra.operator-terminals/connect?agent=..&authority=..&path=..` + * + * Opens an agent's workspace (over Remote-SSH when `authority` is set) and + * starts a terminal attached to its session, or resuming its conversation. + * The window that receives the link asks the Operator API what to run, then + * hands it to the window that owns the workspace through `globalState`. + */ + +import * as vscode from "vscode"; +import type { ConnectTerminal } from "./generated"; + +export interface ConnectRequest { + agent: string; + authority?: string; + path: string; +} + +export interface PendingConnect extends ConnectRequest { + terminal: ConnectTerminal; + at: number; +} + +export interface WorkspaceRef { + authority?: string; + path: string; +} + +export const PENDING_CONNECT_KEY = "operator.pendingConnect"; +const PENDING_TTL_MS = 2 * 60 * 1000; + +export function parseConnect(params: URLSearchParams): ConnectRequest | undefined { + const agent = params.get("agent"); + const path = params.get("path"); + if (!agent || !path) { + return undefined; + } + return { agent, path, authority: params.get("authority") || undefined }; +} + +export function matchesWorkspace(req: ConnectRequest, folders: readonly WorkspaceRef[]): boolean { + return folders.some( + (f) => + (f.authority || undefined) === req.authority && trimSlash(f.path) === trimSlash(req.path), + ); +} + +export function isFresh(pending: PendingConnect, now: number): boolean { + return now - pending.at < PENDING_TTL_MS; +} + +function trimSlash(p: string): string { + return p.length > 1 ? p.replace(/\/+$/, "") : p; +} + +function currentWorkspaces(): WorkspaceRef[] { + return (vscode.workspace.workspaceFolders ?? []).map((f) => ({ + authority: f.uri.scheme === "vscode-remote" ? f.uri.authority : undefined, + path: f.uri.path, + })); +} + +export interface ConnectDeps { + state: vscode.Memento; + fetchTerminal: (agentId: string) => Promise; + openTerminal: (terminal: ConnectTerminal) => void; +} + +/** Handle a connect link in this window. */ +export async function handleConnect(req: ConnectRequest, deps: ConnectDeps): Promise { + const terminal = await deps.fetchTerminal(req.agent); + if (matchesWorkspace(req, currentWorkspaces())) { + deps.openTerminal(terminal); + return; + } + const pending: PendingConnect = { ...req, terminal, at: Date.now() }; + await deps.state.update(PENDING_CONNECT_KEY, pending); + const folder = req.authority + ? vscode.Uri.from({ scheme: "vscode-remote", authority: req.authority, path: req.path }) + : vscode.Uri.file(req.path); + await vscode.commands.executeCommand("vscode.openFolder", folder, { forceNewWindow: true }); +} + +/** On activation, finish a connect another window started for this workspace. */ +export async function resumePendingConnect(deps: ConnectDeps): Promise { + const pending = deps.state.get(PENDING_CONNECT_KEY); + if (!pending || !matchesWorkspace(pending, currentWorkspaces())) { + return; + } + await deps.state.update(PENDING_CONNECT_KEY, undefined); + if (isFresh(pending, Date.now())) { + deps.openTerminal(pending.terminal); + } +} + +export interface TerminalOps { + exists(name: string): boolean; + focus(name: string): void; + create(options: { name: string; workingDir?: string }): unknown; + send(name: string, command: string): void; +} + +/** Focus the agent's own terminal, or open one running the attach/resume command. */ +export function openAgentTerminal( + ops: TerminalOps, + terminal: ConnectTerminal, + warn: (message: string) => void, +): void { + if (!terminal.command) { + if (ops.exists(terminal.terminal_name)) { + ops.focus(terminal.terminal_name); + } else { + warn(`Operator: no terminal named '${terminal.terminal_name}' to focus`); + } + return; + } + const verb = terminal.phase === "resumable" ? "resume" : "attach"; + const name = `${terminal.ticket_id} · ${verb}`; + ops.create({ name, workingDir: terminal.cwd }); + ops.send(name, terminal.command); +} diff --git a/vscode-extension/src/api-client.ts b/vscode-extension/src/api-client.ts index ca48fed5..2affb7af 100644 --- a/vscode-extension/src/api-client.ts +++ b/vscode-extension/src/api-client.ts @@ -15,6 +15,7 @@ import { ApiError, AuthRequiredError } from "./auth/errors"; // Import generated types from Rust bindings (source of truth) import type { ActiveAgentsResponse, + ConnectTerminal, CurrentSessionResponse, KanbanBoardResponse, LaunchTicketRequest, @@ -54,6 +55,12 @@ import type { McpDescriptorResponse, LicenseResponse, TargetsResponse, + CampaignListResponse, + CampaignStatusView, + TicketDetailResponse, + CampaignLaunchOutcome, + LaunchCampaignRequest, + TickReport, } from "./generated"; // Re-export generated types for consumers @@ -381,6 +388,11 @@ export class OperatorApiClient { return this.request("/api/v1/agents/active"); } + /** What to run after opening an agent's workspace: attach while live, resume once gone. */ + async connectTerminal(agentId: string): Promise { + return this.request(`/api/v1/agents/${encodeURIComponent(agentId)}/connect/terminal`); + } + /** * Pause queue processing * @@ -684,4 +696,32 @@ export class OperatorApiClient { async listTargets(): Promise { return this.request("/api/v1/targets"); } + + /** Full ticket detail, including where its file lives. */ + async getTicket(id: string): Promise { + return this.request(`/api/v1/tickets/${encodeURIComponent(id)}`); + } + + async listCampaigns(): Promise { + return this.request("/api/v1/campaigns"); + } + + /** Members, waves, and what the next tick would do. */ + async campaignStatus(id: string): Promise { + return this.request(`/api/v1/campaigns/${encodeURIComponent(id)}/status`); + } + + /** Start a campaign, or preview the start with `dryRun`. */ + async launchCampaign(id: string, dryRun: boolean): Promise { + const body: LaunchCampaignRequest = { dry_run: dryRun }; + return this.request(`/api/v1/campaigns/${encodeURIComponent(id)}/launch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + async pauseCampaign(id: string): Promise { + return this.request(`/api/v1/campaigns/${encodeURIComponent(id)}/pause`, { method: "POST" }); + } } diff --git a/vscode-extension/src/campaign-commands.ts b/vscode-extension/src/campaign-commands.ts new file mode 100644 index 00000000..c5e703db --- /dev/null +++ b/vscode-extension/src/campaign-commands.ts @@ -0,0 +1,87 @@ +/** + * Campaign commands: preview-then-launch, pause, and open in the Operator UI. + */ + +import * as vscode from "vscode"; +import { discoverApiUrl, OperatorApiClient } from "./api-client"; +import type { CampaignNode } from "./campaign-provider"; +import type { CampaignLaunchOutcome } from "./generated"; +import { openOperatorUi } from "./open-operator-ui"; + +export interface CampaignCommandContext { + getCurrentTicketsDir: () => string | undefined; + refreshCampaigns: () => Promise; +} + +/** One-paragraph summary of a dry run, shown before launching. */ +export function launchSummary(outcome: CampaignLaunchOutcome): string { + const waves = `${outcome.waves.length} ${outcome.waves.length === 1 ? "wave" : "waves"}`; + const first = + outcome.report.launched.length > 0 ? outcome.report.launched.join(", ") : "nothing yet"; + const policy = `Governance: ${outcome.policy.name}.`; + return `${outcome.id}: ${waves}; the first launches ${first}. ${policy}`; +} + +async function client(ctx: CampaignCommandContext): Promise { + return new OperatorApiClient(await discoverApiUrl(ctx.getCurrentTicketsDir())); +} + +function failed(action: string, err: unknown): void { + const msg = err instanceof Error ? err.message : "Unknown error"; + void vscode.window.showErrorMessage(`Failed to ${action}: ${msg}`); +} + +export async function launchCampaignCommand( + ctx: CampaignCommandContext, + node: CampaignNode | undefined, +): Promise { + const id = node?.campaignId; + if (!id) { + return; + } + try { + const api = await client(ctx); + const preview = await api.launchCampaign(id, true); + if (preview.violations.length > 0) { + void vscode.window.showErrorMessage(`${id} cannot launch: ${preview.violations.join("; ")}`); + return; + } + const choice = await vscode.window.showInformationMessage( + launchSummary(preview), + { modal: true }, + "Launch", + ); + if (choice !== "Launch") { + return; + } + const outcome = await api.launchCampaign(id, false); + void vscode.window.showInformationMessage(`${id} is ${outcome.status}.`); + await ctx.refreshCampaigns(); + } catch (err) { + failed("launch campaign", err); + } +} + +export async function pauseCampaignCommand( + ctx: CampaignCommandContext, + node: CampaignNode | undefined, +): Promise { + const id = node?.campaignId; + if (!id) { + return; + } + try { + await (await client(ctx)).pauseCampaign(id); + void vscode.window.showInformationMessage(`${id} paused.`); + await ctx.refreshCampaigns(); + } catch (err) { + failed("pause campaign", err); + } +} + +export function openCampaignCommand( + ctx: CampaignCommandContext, + node: CampaignNode | undefined, +): Promise { + return openOperatorUi(ctx.getCurrentTicketsDir(), "campaigns", node?.campaignId); +} diff --git a/vscode-extension/src/campaign-provider.ts b/vscode-extension/src/campaign-provider.ts new file mode 100644 index 00000000..35685dbb --- /dev/null +++ b/vscode-extension/src/campaign-provider.ts @@ -0,0 +1,194 @@ +/** + * Campaigns view: campaign → wave → ticket, read from the Operator REST API. + * + * Campaigns are Premium. Without a license the view stays visible with a + * single row linking to the license page, so the feature reads as locked + * rather than missing. + */ + +import * as vscode from "vscode"; +import type { OperatorApiClient } from "./api-client"; +import type { + BlockReason, + CampaignMember, + CampaignStatus, + CampaignStatusView, + CampaignSummary, +} from "./generated"; + +const LAUNCHABLE: ReadonlySet = new Set(["draft", "ready", "paused"]); + +export type CampaignNodeKind = "paywall" | "message" | "campaign" | "wave" | "ticket"; + +export class CampaignNode extends vscode.TreeItem { + constructor( + readonly kind: CampaignNodeKind, + label: string, + collapsible: vscode.TreeItemCollapsibleState, + readonly campaignId?: string, + readonly wave?: number, + ) { + super(label, collapsible); + } +} + +/** Context value driving the launch and pause menu entries. */ +export function campaignContextValue(status: CampaignStatus): string { + if (status === "running") { + return "campaign-running"; + } + return LAUNCHABLE.has(status) ? "campaign-launchable" : "campaign"; +} + +export function blockedLabel(reason: BlockReason): string { + if (reason.kind === "gate") { + return `waiting on ${reason.refs.join(", ")}`; + } + if (reason.kind === "unsynced") { + return `waiting on unsynced ${reason.refs.join(", ")}`; + } + return BLOCKED_LABELS[reason.kind]; +} + +const BLOCKED_LABELS = { + dependencies: "waiting on dependencies", + license: "needs a Premium license", + capacity: "waiting for a free slot", +} as const; + +function memberNote(member: CampaignMember, view: CampaignStatusView): string | undefined { + const blocked = view.report.blocked[member.id]; + if (blocked) { + return blockedLabel(blocked); + } + if (view.report.awaiting_operator.includes(member.id)) { + return "awaiting operator"; + } + if (view.report.stalled.includes(member.id)) { + return "stalled"; + } + return undefined; +} + +function campaignNode({ campaign, members }: CampaignSummary): CampaignNode { + const node = new CampaignNode( + "campaign", + `${campaign.id} · ${campaign.title}`, + vscode.TreeItemCollapsibleState.Collapsed, + campaign.id, + ); + node.description = `${campaign.status} · ${members} ${members === 1 ? "ticket" : "tickets"}`; + node.iconPath = new vscode.ThemeIcon("milestone"); + node.contextValue = campaignContextValue(campaign.status); + return node; +} + +function waveNode(campaignId: string, index: number, size: number): CampaignNode { + const node = new CampaignNode( + "wave", + `Wave ${index + 1}`, + vscode.TreeItemCollapsibleState.Expanded, + campaignId, + index, + ); + node.description = `${size} ${size === 1 ? "ticket" : "tickets"}`; + node.iconPath = new vscode.ThemeIcon("layers"); + return node; +} + +function ticketNode(member: CampaignMember, view: CampaignStatusView): CampaignNode { + const node = new CampaignNode("ticket", member.id, vscode.TreeItemCollapsibleState.None); + const note = memberNote(member, view); + node.description = note ? `${member.column} · ${note}` : member.column; + node.tooltip = member.summary; + node.iconPath = new vscode.ThemeIcon(member.column === "completed" ? "pass" : "note"); + return node; +} + +function paywallNode(): CampaignNode { + const node = new CampaignNode("paywall", "Campaigns", vscode.TreeItemCollapsibleState.None); + node.description = "Premium license required"; + node.iconPath = new vscode.ThemeIcon("lock"); + node.command = { command: "operator.openLicense", title: "Open License" }; + return node; +} + +function messageNode(text: string): CampaignNode { + const node = new CampaignNode("message", text, vscode.TreeItemCollapsibleState.None); + node.iconPath = new vscode.ThemeIcon("warning"); + return node; +} + +export class CampaignTreeProvider implements vscode.TreeDataProvider { + private readonly changed = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changed.event; + + private client: OperatorApiClient | undefined; + private roots: CampaignNode[] = []; + private readonly views = new Map(); + + setApiClient(client: OperatorApiClient | undefined): void { + this.client = client; + } + + async refresh(): Promise { + this.views.clear(); + this.roots = await this.loadRoots(); + this.changed.fire(undefined); + } + + private async loadRoots(): Promise { + if (!this.client) { + return []; + } + try { + const license = await this.client.license(); + if (!license.premium) { + return [paywallNode()]; + } + const list = await this.client.listCampaigns(); + return list.campaigns.map(campaignNode); + } catch (err) { + return [messageNode(err instanceof Error ? err.message : "Campaigns unavailable")]; + } + } + + private async view(campaignId: string): Promise { + const cached = this.views.get(campaignId); + if (cached || !this.client) { + return cached; + } + const view = await this.client.campaignStatus(campaignId); + this.views.set(campaignId, view); + return view; + } + + getTreeItem(element: CampaignNode): vscode.TreeItem { + return element; + } + + async getChildren(element?: CampaignNode): Promise { + if (!element) { + return this.roots; + } + if (!element.campaignId || (element.kind !== "campaign" && element.kind !== "wave")) { + return []; + } + try { + const view = await this.view(element.campaignId); + if (!view) { + return []; + } + if (element.kind === "campaign") { + return view.waves.map((wave, index) => waveNode(view.id, index, wave.length)); + } + const byId = new Map(view.tickets.map((m) => [m.id, m])); + return (view.waves[element.wave ?? 0] ?? []) + .map((id) => byId.get(id)) + .filter((m): m is CampaignMember => m !== undefined) + .map((m) => ticketNode(m, view)); + } catch (err) { + return [messageNode(err instanceof Error ? err.message : "Campaign unavailable")]; + } + } +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index e05d6b41..9381e5d4 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -21,6 +21,12 @@ import { LaunchManager } from "./launch-manager"; import { IssueTypeService } from "./issuetype-service"; import type { TicketInfo } from "./types"; import { OperatorApiClient, discoverApiUrl } from "./api-client"; +import { + handleConnect, + openAgentTerminal, + resumePendingConnect, + type ConnectDeps, +} from "./agent-connect"; import { OperatorCredentials, clearCredentialProvider, @@ -51,6 +57,14 @@ import { import { startGitOnboarding, onboardGitHub, onboardGitLab } from "./git-onboarding"; import { ConfigPanel } from "./config-panel"; import { openOperatorUi } from "./open-operator-ui"; +import { CampaignTreeProvider, type CampaignNode } from "./campaign-provider"; +import { routeOperatorUri } from "./uri-handler"; +import { + launchCampaignCommand, + openCampaignCommand, + pauseCampaignCommand, + type CampaignCommandContext, +} from "./campaign-commands"; import { connectMcpServer } from "./mcp-connect"; import { configFileExists } from "./config-paths"; import { findParentTicketsDir, findTicketsDir, findOperatorServerDir } from "./tickets-dir"; @@ -321,6 +335,17 @@ function openTicketFile(filePath: string): void { }); } +/** Open a ticket by id, asking the Operator server where its file lives. */ +async function openTicketById(ticketId: string): Promise { + try { + const client = new OperatorApiClient(await discoverApiUrl(currentTicketsDir)); + openTicketFile((await client.getTicket(ticketId)).filepath); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + void vscode.window.showWarningMessage(`Operator: could not open ${ticketId}: ${msg}`); + } +} + // --------------------------------------------------------------------------- // Server commands // --------------------------------------------------------------------------- @@ -916,6 +941,10 @@ function openCreateDelegator(ctx: CommandContext, tool?: string, model?: string) // Extension activation // --------------------------------------------------------------------------- +function warn(message: string): void { + void vscode.window.showWarningMessage(message); +} + export async function activate(context: vscode.ExtensionContext): Promise { // Create output channel for logging const outputChannel = vscode.window.createOutputChannel("Operator"); @@ -934,6 +963,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const inProgressProvider = new TicketTreeProvider("in-progress", issueTypeService); const queueProvider = new TicketTreeProvider("queue", issueTypeService); const completedProvider = new TicketTreeProvider("completed", issueTypeService); + const campaignProvider = new CampaignTreeProvider(); const statusTreeView = vscode.window.createTreeView("operator-status", { treeDataProvider: statusProvider, @@ -943,6 +973,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.registerTreeDataProvider("operator-in-progress", inProgressProvider), vscode.window.registerTreeDataProvider("operator-queue", queueProvider), vscode.window.registerTreeDataProvider("operator-completed", completedProvider), + vscode.window.registerTreeDataProvider("operator-campaigns", campaignProvider), ); // Synchronous object construction - these constructors do no I/O @@ -950,28 +981,42 @@ export async function activate(context: vscode.ExtensionContext): Promise terminalManager.setIssueTypeService(issueTypeService); inProgressProvider.setTerminalManager(terminalManager); + const connectDeps: ConnectDeps = { + state: context.globalState, + fetchTerminal: async (agentId) => + new OperatorApiClient(await discoverApiUrl(currentTicketsDir)).connectTerminal(agentId), + openTerminal: (terminal) => openAgentTerminal(terminalManager, terminal, warn), + }; + void resumePendingConnect(connectDeps); + // Handle deep-links from the Operator web UI / control plane: // vscode://untra.operator-terminals/focus-session?name= + // vscode://untra.operator-terminals/connect?agent=&authority=&path= // focuses the agent's terminal tab by name. The web UI's launch panel emits this link after launching a ticket // when the operator control wrapper is VS Code, so the user can jump straight to the running agent's terminal. context.subscriptions.push( vscode.window.registerUriHandler({ handleUri(uri: vscode.Uri) { - if (uri.path !== "/focus-session") { - return; - } - const name = new URLSearchParams(uri.query).get("name"); - if (!name) { - void vscode.window.showWarningMessage( - "Operator: focus-session link is missing a terminal name", - ); - return; - } - if (terminalManager.exists(name)) { - terminalManager.focus(name); - } else { - void vscode.window.showWarningMessage(`Operator: no terminal named '${name}' to focus`); - } + routeOperatorUri(uri, { + focusSession: (name) => { + if (terminalManager.exists(name)) { + terminalManager.focus(name); + } else { + void vscode.window.showWarningMessage( + `Operator: no terminal named '${name}' to focus`, + ); + } + }, + openTicket: (id) => { + void openTicketById(id); + }, + connect: (request) => { + handleConnect(request, connectDeps).catch((err: unknown) => { + warn(`Operator: could not connect to agent: ${String(err)}`); + }); + }, + warn, + }); }, }), ); @@ -1017,6 +1062,7 @@ export async function activate(context: vscode.ExtensionContext): Promise await inProgressProvider.refresh(); await queueProvider.refresh(); await completedProvider.refresh(); + await campaignProvider.refresh(); }, setTicketsDir: async (dir) => { credentials.setTicketsDir(dir); @@ -1028,9 +1074,24 @@ export async function activate(context: vscode.ExtensionContext): Promise }, }; + const campaignCtx: CampaignCommandContext = { + getCurrentTicketsDir: () => currentTicketsDir, + refreshCampaigns: () => campaignProvider.refresh(), + }; + // Register all commands BEFORE any async work - ensures commands are // always available even if network/API initialization fails. context.subscriptions.push( + vscode.commands.registerCommand("operator.refreshCampaigns", () => campaignProvider.refresh()), + vscode.commands.registerCommand("operator.launchCampaign", (node?: CampaignNode) => + launchCampaignCommand(campaignCtx, node), + ), + vscode.commands.registerCommand("operator.pauseCampaign", (node?: CampaignNode) => + pauseCampaignCommand(campaignCtx, node), + ), + vscode.commands.registerCommand("operator.openCampaign", (node?: CampaignNode) => + openCampaignCommand(campaignCtx, node), + ), vscode.commands.registerCommand("operator.showStatus", () => showStatus(ctx)), vscode.commands.registerCommand("operator.refreshTickets", () => ctx.refreshAllProviders()), vscode.commands.registerCommand("operator.focusTicket", (name: string, ticket?: TicketInfo) => @@ -1235,6 +1296,8 @@ export async function activate(context: vscode.ExtensionContext): Promise queueProvider.setApiClient(client); inProgressProvider.setApiClient(client); completedProvider.setApiClient(client); + campaignProvider.setApiClient(client); + void campaignProvider.refresh(); } catch { ctx.attachedServer = undefined; } diff --git a/vscode-extension/src/open-operator-ui.ts b/vscode-extension/src/open-operator-ui.ts index 784443fd..2b6953b9 100644 --- a/vscode-extension/src/open-operator-ui.ts +++ b/vscode-extension/src/open-operator-ui.ts @@ -24,6 +24,7 @@ export type OperatorUiRoute = | "config" | "workflows" | "remote-targets" + | "campaigns" | "license"; const ROUTE_HASH: Record = { @@ -35,6 +36,7 @@ const ROUTE_HASH: Record = { config: "#/config", workflows: "#/workflows", "remote-targets": "#/remote-targets", + campaigns: "#/campaigns", license: "#/settings/license", }; @@ -44,10 +46,12 @@ const ROUTE_HASH: Record = { * @param ticketsDir the active `.tickets` directory, used to discover the * daemon's dynamic port via `api-session.json`. * @param route the hosted-UI section to open. + * @param id an item within that section, e.g. a campaign id. */ export async function openOperatorUi( ticketsDir: string | undefined, route: OperatorUiRoute, + id?: string, ): Promise { const apiUrl = await discoverApiUrl(ticketsDir); @@ -70,7 +74,8 @@ export async function openOperatorUi( // route is appended after mapping because asExternalUri can drop a fragment. const external = await vscode.env.asExternalUri(vscode.Uri.parse(apiUrl)); const base = external.toString().replace(/\/+$/, ""); - const url = `${base}/${ROUTE_HASH[route]}`; + const detail = id ? `/${encodeURIComponent(id)}` : ""; + const url = `${base}/${ROUTE_HASH[route]}${detail}`; try { await vscode.commands.executeCommand("simpleBrowser.show", url); diff --git a/vscode-extension/src/uri-handler.ts b/vscode-extension/src/uri-handler.ts new file mode 100644 index 00000000..eae36b46 --- /dev/null +++ b/vscode-extension/src/uri-handler.ts @@ -0,0 +1,40 @@ +/** + * Routes `vscode://untra.operator-terminals/?...` deep links from the + * Operator UI to extension actions. + */ + +import type * as vscode from "vscode"; +import { parseConnect, type ConnectRequest } from "./agent-connect"; + +export interface UriActions { + focusSession: (terminalName: string) => void; + openTicket: (ticketId: string) => void; + connect: (request: ConnectRequest) => void; + warn: (message: string) => void; +} + +export function routeOperatorUri(uri: vscode.Uri, actions: UriActions): void { + const params = new URLSearchParams(uri.query); + if (uri.path === "/focus-session") { + const name = params.get("name"); + if (name) { + actions.focusSession(name); + } else { + actions.warn("Operator: focus-session link is missing a terminal name"); + } + } else if (uri.path === "/open-ticket") { + const id = params.get("id"); + if (id) { + actions.openTicket(id); + } else { + actions.warn("Operator: open-ticket link is missing a ticket id"); + } + } else if (uri.path === "/connect") { + const request = parseConnect(params); + if (request) { + actions.connect(request); + } else { + actions.warn("Operator: connect link is missing an agent or path"); + } + } +} diff --git a/vscode-extension/test/suite/agent-connect.test.ts b/vscode-extension/test/suite/agent-connect.test.ts new file mode 100644 index 00000000..7a6d18e4 --- /dev/null +++ b/vscode-extension/test/suite/agent-connect.test.ts @@ -0,0 +1,68 @@ +/** + * Connect links: workspace matching, pending hand-off freshness, and which + * terminal an agent's connect opens. + */ + +import * as assert from "node:assert"; +import { + isFresh, + matchesWorkspace, + openAgentTerminal, + type PendingConnect, + type TerminalOps, +} from "../../src/agent-connect"; +import type { ConnectTerminal } from "../../src/generated"; + +function ops(existing: string[] = []): TerminalOps & { calls: string[] } { + const calls: string[] = []; + return { + calls, + exists: (name) => existing.includes(name), + focus: (name) => { + calls.push(`focus:${name}`); + }, + create: (o) => { + calls.push(`create:${o.name}:${o.workingDir ?? ""}`); + }, + send: (name, command) => { + calls.push(`send:${name}:${command}`); + }, + }; +} + +const live: ConnectTerminal = { + phase: "live", + ticket_id: "FEAT-12", + terminal_name: "op-FEAT-12", + cwd: "/home/me/proj", + command: "tmux attach -t op-FEAT-12", +}; + +suite("agent-connect", () => { + test("matches a remote workspace by authority and path", () => { + const req = { agent: "a", authority: "ssh-remote+devbox", path: "/home/me/proj/" }; + assert.ok(matchesWorkspace(req, [{ authority: "ssh-remote+devbox", path: "/home/me/proj" }])); + assert.ok(!matchesWorkspace(req, [{ path: "/home/me/proj" }])); + }); + + test("pending connects expire", () => { + const pending: PendingConnect = { agent: "a", path: "/p", terminal: live, at: 0 }; + assert.ok(isFresh(pending, 1000)); + assert.ok(!isFresh(pending, 10 * 60 * 1000)); + }); + + test("a live session opens an attach terminal", () => { + const o = ops(); + openAgentTerminal(o, live, () => undefined); + assert.deepStrictEqual(o.calls, [ + "create:FEAT-12 · attach:/home/me/proj", + "send:FEAT-12 · attach:tmux attach -t op-FEAT-12", + ]); + }); + + test("no command focuses the agent's own terminal", () => { + const o = ops(["op-FEAT-12"]); + openAgentTerminal(o, { ...live, command: undefined }, () => undefined); + assert.deepStrictEqual(o.calls, ["focus:op-FEAT-12"]); + }); +}); diff --git a/vscode-extension/test/suite/campaign-provider.test.ts b/vscode-extension/test/suite/campaign-provider.test.ts new file mode 100644 index 00000000..5d13e268 --- /dev/null +++ b/vscode-extension/test/suite/campaign-provider.test.ts @@ -0,0 +1,180 @@ +/** + * Campaign tree provider tests: paywall without Premium, campaign → wave → + * ticket children with it, and a context value per lifecycle state. + */ + +import * as assert from "node:assert"; +import * as sinon from "sinon"; +import { CampaignTreeProvider, type CampaignNode } from "../../src/campaign-provider"; +import { OperatorApiClient } from "../../src/api-client"; +import { launchSummary } from "../../src/campaign-commands"; +import type { + CampaignListResponse, + CampaignStatus, + CampaignStatusView, + LicenseResponse, +} from "../../src/generated"; + +function license(premium: boolean): LicenseResponse { + return { status: "valid", profile_id: "default", premium, terms: null, purchase_url: null }; +} + +function campaigns(...entries: Array<[string, CampaignStatus]>): CampaignListResponse { + return { + campaigns: entries.map(([id, status]) => ({ + campaign: { id, title: `${id} title`, status, created: "2026-10-05" }, + members: 2, + })), + total: entries.length, + }; +} + +function statusView(id: string): CampaignStatusView { + const member = (ticket: string, depends_on: string[]) => ({ + id: ticket, + summary: `${ticket} summary`, + project: "api", + column: "queue", + depends_on, + launches: 0, + }); + return { + id, + status: "draft", + tickets: [member("FEAT-0001", []), member("FEAT-0002", ["FEAT-0001"])], + waves: [["FEAT-0001"], ["FEAT-0002"]], + report: { + status: "draft", + launched: [], + awaiting_operator: [], + stalled: [], + blocked: { "FEAT-0002": { kind: "dependencies" } }, + pause_reason: null, + }, + counters: { + launched: 0, + max_launches: null, + active: 0, + max_parallel: null, + retries: 0, + max_retries: null, + }, + policy: { name: "campaign", require_confirmation: false }, + }; +} + +suite("CampaignTreeProvider", () => { + let sandbox: sinon.SinonSandbox; + let client: OperatorApiClient; + + setup(() => { + sandbox = sinon.createSandbox(); + client = new OperatorApiClient("http://localhost:1"); + }); + + teardown(() => { + sandbox.restore(); + }); + + async function roots(provider: CampaignTreeProvider): Promise { + await provider.refresh(); + return provider.getChildren(); + } + + test("is empty without a server", async () => { + const provider = new CampaignTreeProvider(); + assert.deepStrictEqual(await roots(provider), []); + }); + + test("shows one paywall row without Premium and lists nothing", async () => { + sandbox.stub(client, "license").resolves(license(false)); + const list = sandbox.stub(client, "listCampaigns").resolves(campaigns(["CAMP-0001", "draft"])); + const provider = new CampaignTreeProvider(); + provider.setApiClient(client); + + const nodes = await roots(provider); + assert.strictEqual(nodes.length, 1); + assert.strictEqual(nodes[0]?.kind, "paywall"); + assert.strictEqual(nodes[0]?.command?.command, "operator.openLicense"); + assert.ok(list.notCalled, "campaigns must not be fetched without Premium"); + }); + + test("lists campaigns with a context value per lifecycle state", async () => { + sandbox.stub(client, "license").resolves(license(true)); + sandbox + .stub(client, "listCampaigns") + .resolves( + campaigns(["CAMP-0001", "draft"], ["CAMP-0002", "running"], ["CAMP-0003", "completed"]), + ); + const provider = new CampaignTreeProvider(); + provider.setApiClient(client); + + const nodes = await roots(provider); + assert.deepStrictEqual( + nodes.map((n) => [n.label, n.contextValue]), + [ + ["CAMP-0001 · CAMP-0001 title", "campaign-launchable"], + ["CAMP-0002 · CAMP-0002 title", "campaign-running"], + ["CAMP-0003 · CAMP-0003 title", "campaign"], + ], + ); + }); + + test("expands a campaign into waves, and a wave into its tickets", async () => { + sandbox.stub(client, "license").resolves(license(true)); + sandbox.stub(client, "listCampaigns").resolves(campaigns(["CAMP-0001", "draft"])); + sandbox.stub(client, "campaignStatus").resolves(statusView("CAMP-0001")); + const provider = new CampaignTreeProvider(); + provider.setApiClient(client); + + const [campaign] = await roots(provider); + assert.ok(campaign); + const waves = await provider.getChildren(campaign); + assert.deepStrictEqual( + waves.map((w) => w.label), + ["Wave 1", "Wave 2"], + ); + const second = waves[1]; + assert.ok(second); + const tickets = await provider.getChildren(second); + assert.deepStrictEqual( + tickets.map((t) => [t.label, t.description]), + [["FEAT-0002", "queue · waiting on dependencies"]], + ); + }); + + test("shows an error row when the server refuses", async () => { + sandbox.stub(client, "license").rejects(new Error("boom")); + const provider = new CampaignTreeProvider(); + provider.setApiClient(client); + + const nodes = await roots(provider); + assert.strictEqual(nodes.length, 1); + assert.strictEqual(nodes[0]?.kind, "message"); + }); +}); + +suite("launchSummary", () => { + test("names the waves, the first launches and the policy", () => { + const summary = launchSummary({ + id: "CAMP-0001", + dry_run: true, + status: "draft", + waves: [["FEAT-0001"], ["FEAT-0002"]], + policy: { name: "careful", require_confirmation: false }, + violations: [], + report: { + status: "draft", + launched: ["FEAT-0001"], + awaiting_operator: [], + stalled: [], + blocked: {}, + pause_reason: null, + }, + }); + assert.strictEqual( + summary, + "CAMP-0001: 2 waves; the first launches FEAT-0001. Governance: careful.", + ); + }); +}); diff --git a/vscode-extension/test/suite/uri-handler.test.ts b/vscode-extension/test/suite/uri-handler.test.ts new file mode 100644 index 00000000..e7fe29d7 --- /dev/null +++ b/vscode-extension/test/suite/uri-handler.test.ts @@ -0,0 +1,76 @@ +/** + * Deep links from the Operator UI: each path reaches the right action, and a + * link missing its parameter warns instead of acting. + */ + +import * as assert from "node:assert"; +import * as vscode from "vscode"; +import { routeOperatorUri, type UriActions } from "../../src/uri-handler"; + +function recorder(): UriActions & { calls: string[] } { + const calls: string[] = []; + return { + calls, + focusSession: (name) => { + calls.push(`focus:${name}`); + }, + openTicket: (id) => { + calls.push(`open:${id}`); + }, + connect: (req) => { + calls.push(`connect:${req.agent}:${req.authority ?? ""}:${req.path}`); + }, + warn: (message) => { + calls.push(`warn:${message}`); + }, + }; +} + +function uri(path: string, query: string): vscode.Uri { + return vscode.Uri.from({ scheme: "vscode", authority: "untra.operator-terminals", path, query }); +} + +suite("routeOperatorUri", () => { + test("open-ticket opens the ticket by id", () => { + const actions = recorder(); + routeOperatorUri(uri("/open-ticket", "id=FEAT-12"), actions); + assert.deepStrictEqual(actions.calls, ["open:FEAT-12"]); + }); + + test("focus-session focuses the named terminal", () => { + const actions = recorder(); + routeOperatorUri(uri("/focus-session", "name=op%20FEAT-12"), actions); + assert.deepStrictEqual(actions.calls, ["focus:op FEAT-12"]); + }); + + test("a link without its parameter warns", () => { + const actions = recorder(); + routeOperatorUri(uri("/open-ticket", ""), actions); + assert.deepStrictEqual(actions.calls, [ + "warn:Operator: open-ticket link is missing a ticket id", + ]); + }); + + test("unknown paths are ignored", () => { + const actions = recorder(); + routeOperatorUri(uri("/something-else", "id=1"), actions); + assert.deepStrictEqual(actions.calls, []); + }); + + test("connect passes agent, authority and path", () => { + const actions = recorder(); + routeOperatorUri( + uri("/connect", "agent=a1&authority=ssh-remote%2Bdevbox&path=%2Fhome%2Fme%2Fproj"), + actions, + ); + assert.deepStrictEqual(actions.calls, ["connect:a1:ssh-remote+devbox:/home/me/proj"]); + }); + + test("connect without a path warns", () => { + const actions = recorder(); + routeOperatorUri(uri("/connect", "agent=a1"), actions); + assert.deepStrictEqual(actions.calls, [ + "warn:Operator: connect link is missing an agent or path", + ]); + }); +}); diff --git a/vscode-extension/webview-ui/components/sections/ModelProvidersSection.test.tsx b/vscode-extension/webview-ui/components/sections/ModelProvidersSection.test.tsx index 1c466a4e..0ffd8d32 100644 --- a/vscode-extension/webview-ui/components/sections/ModelProvidersSection.test.tsx +++ b/vscode-extension/webview-ui/components/sections/ModelProvidersSection.test.tsx @@ -99,6 +99,7 @@ describe("CreateDelegatorForm selects", () => { model_server: "openai", launch_config: null, remote_agent: null, + governance: null, }, }, ]); diff --git a/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx b/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx index b77e7f6f..bc5917f5 100644 --- a/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx +++ b/vscode-extension/webview-ui/components/sections/ModelProvidersSection.tsx @@ -345,6 +345,7 @@ function CreateDelegatorForm({ model_server: provider, launch_config: null, remote_agent: null, + governance: null, }, }); }, [model, name, onInvalid, pending, provider, selectedTool]); diff --git a/vscode-extension/webview-ui/types/defaults.ts b/vscode-extension/webview-ui/types/defaults.ts index bc18d4a9..8e5fc726 100644 --- a/vscode-extension/webview-ui/types/defaults.ts +++ b/vscode-extension/webview-ui/types/defaults.ts @@ -137,6 +137,7 @@ const DEFAULT_CONFIG: Config = { model_servers: [], hosts: [], targets: [], + governance: [], relay: { auto_inject_mcp: false }, mcp: { http_enabled: true, diff --git a/webcomponents/bun.lock b/webcomponents/bun.lock index 1567cd33..01b577b7 100644 --- a/webcomponents/bun.lock +++ b/webcomponents/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "@untra/naiveworkflow-react": "0.0.3", "@xyflow/react": "^12.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", }, "devDependencies": { "@storybook/addon-a11y": "10.6.0", @@ -339,12 +341,22 @@ "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/doctrine": ["@types/doctrine@0.0.9", "", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], @@ -353,6 +365,10 @@ "@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + "@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=="], @@ -399,6 +415,8 @@ "axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg=="], @@ -413,12 +431,24 @@ "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -447,6 +477,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], @@ -457,6 +489,8 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -473,14 +507,20 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "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=="], @@ -495,14 +535,32 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -515,6 +573,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -523,6 +583,96 @@ "magic-string": ["magic-string@1.3.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -547,6 +697,8 @@ "oxc-resolver": ["oxc-resolver@11.21.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.2", "@oxc-resolver/binding-android-arm64": "11.21.2", "@oxc-resolver/binding-darwin-arm64": "11.21.2", "@oxc-resolver/binding-darwin-x64": "11.21.2", "@oxc-resolver/binding-freebsd-x64": "11.21.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.2", "@oxc-resolver/binding-linux-arm64-musl": "11.21.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.2", "@oxc-resolver/binding-linux-x64-gnu": "11.21.2", "@oxc-resolver/binding-linux-x64-musl": "11.21.2", "@oxc-resolver/binding-openharmony-arm64": "11.21.2", "@oxc-resolver/binding-wasm32-wasi": "11.21.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.2", "@oxc-resolver/binding-win32-x64-msvc": "11.21.2" } }, "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], @@ -569,6 +721,8 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-docgen": ["react-docgen@8.0.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.2", "@types/babel__core": "^7.20.5", "@types/babel__traverse": "^7.20.7", "@types/doctrine": "^0.0.9", "@types/resolve": "^1.20.2", "doctrine": "^3.0.0", "resolve": "^1.22.1", "strip-indent": "^4.0.0" } }, "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w=="], @@ -579,12 +733,22 @@ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="], "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], @@ -603,16 +767,24 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "storybook": ["storybook@10.6.0", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.3", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "11.21.2", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -629,6 +801,10 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], @@ -639,10 +815,26 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "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=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "vite": ["vite@6.4.3", "", { "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-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], @@ -661,6 +853,8 @@ "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=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -691,6 +885,8 @@ "@vitest/snapshot/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], diff --git a/webcomponents/package.json b/webcomponents/package.json index 79d33086..5c9b2cb5 100644 --- a/webcomponents/package.json +++ b/webcomponents/package.json @@ -29,7 +29,9 @@ }, "dependencies": { "@untra/naiveworkflow-react": "0.0.3", - "@xyflow/react": "^12.0.0" + "@xyflow/react": "^12.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "peerDependencies": { "react": "^19.0.0", diff --git a/webcomponents/src/components/KanbanBoard.module.css b/webcomponents/src/components/KanbanBoard.module.css index a7247111..71c0f32f 100644 --- a/webcomponents/src/components/KanbanBoard.module.css +++ b/webcomponents/src/components/KanbanBoard.module.css @@ -75,6 +75,18 @@ margin-bottom: 0.25rem; } +.attention { + width: 0.5rem; + height: 0.5rem; + flex: none; + border-radius: 50%; + background: var(--warning); +} + +.attention[data-reason="orphaned"] { + background: var(--danger); +} + .statusIcon { color: var(--accent-text); font-size: 0.7rem; diff --git a/webcomponents/src/components/KanbanBoard.tsx b/webcomponents/src/components/KanbanBoard.tsx index e591f01f..a29806a9 100644 --- a/webcomponents/src/components/KanbanBoard.tsx +++ b/webcomponents/src/components/KanbanBoard.tsx @@ -47,6 +47,13 @@ function Column({ ); } +const ATTENTION_TITLE: Record, string> = { + awaiting_input: "Agent is waiting for you", + review: "Review pending", + orphaned: "Session lost", + idle: "No output for a while", +}; + function Card({ ticket, onOpen, @@ -61,6 +68,14 @@ function Card({ {STATUS_GLYPH[ticket.status]} {ticket.ticket_type} {ticket.id} + {ticket.attention && ( + + )}
{ticket.summary}
diff --git a/webcomponents/src/components/MarkdownView.module.css b/webcomponents/src/components/MarkdownView.module.css new file mode 100644 index 00000000..d766cd05 --- /dev/null +++ b/webcomponents/src/components/MarkdownView.module.css @@ -0,0 +1,38 @@ +.markdown { + line-height: 1.6; + overflow-wrap: anywhere; +} + +.markdown pre { + overflow-x: auto; + padding: 0.75rem; + border-radius: var(--radius); + background: var(--surface-alt); +} + +.markdown code { + font-family: var(--font-mono); + font-size: 0.875em; +} + +.markdown table { + border-collapse: collapse; +} + +.markdown th, +.markdown td { + border: 1px solid var(--border); + padding: 0.25rem 0.5rem; +} + +.markdown blockquote { + margin-inline: 0; + padding-left: 0.75rem; + border-left: 3px solid var(--border); + color: var(--text-muted); +} + +.markdown ul:has(> li > input[type="checkbox"]) { + list-style: none; + padding-left: 0.25rem; +} diff --git a/webcomponents/src/components/MarkdownView.test.tsx b/webcomponents/src/components/MarkdownView.test.tsx new file mode 100644 index 00000000..f55567a8 --- /dev/null +++ b/webcomponents/src/components/MarkdownView.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { MarkdownView } from "./MarkdownView"; + +const SCRIPT_SCHEME = ["java", "script:"].join(""); + +function render(source: string): string { + return renderToStaticMarkup(); +} + +describe("MarkdownView", () => { + test("renders headings, task lists and tables", () => { + const markup = render( + "# Feature\n\n- [x] plan\n- [ ] build\n\n| a | b |\n|---|---|\n| 1 | 2 |\n", + ); + expect(markup).toContain("

Feature

"); + expect(markup).toContain('type="checkbox"'); + expect(markup).toContain(""); + }); + + test("never renders raw HTML", () => { + const markup = render("before\n\n\n\n\n"); + expect(markup).not.toContain(" { + const markup = render("[docs](https://operator.untra.io)"); + expect(markup).toContain('target="_blank"'); + expect(markup).toContain('rel="noopener noreferrer"'); + }); + + test("drops javascript links", () => { + const markup = render(`[x](${SCRIPT_SCHEME}alert(1))`); + expect(markup).not.toContain(SCRIPT_SCHEME); + }); +}); diff --git a/webcomponents/src/components/MarkdownView.tsx b/webcomponents/src/components/MarkdownView.tsx new file mode 100644 index 00000000..ec32709c --- /dev/null +++ b/webcomponents/src/components/MarkdownView.tsx @@ -0,0 +1,32 @@ +import type { AnchorHTMLAttributes } from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import styles from "./MarkdownView.module.css"; + +export type MarkdownViewProps = { + source: string; + className?: string; +}; + +const REMARK_PLUGINS = [remarkGfm]; + +function ExternalLink({ children, ...props }: AnchorHTMLAttributes) { + return ( + + {children} + + ); +} + +const COMPONENTS = { a: ExternalLink }; + +/** Markdown with GitHub extensions; raw HTML is never rendered. */ +export function MarkdownView({ source, className }: MarkdownViewProps) { + return ( +
+ + {source} + +
+ ); +} diff --git a/webcomponents/src/components/PremiumGate.test.tsx b/webcomponents/src/components/PremiumGate.test.tsx new file mode 100644 index 00000000..6579fd98 --- /dev/null +++ b/webcomponents/src/components/PremiumGate.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PremiumGate } from "./PremiumGate"; + +function handleAddLicense() {} + +const LICENSE = { + status: "valid" as const, + profile_id: "default", + terms: null, + purchase_url: "https://operator.untra.io/premium", +}; + +function render(license: Parameters[0]["license"]) { + return renderToStaticMarkup( + +

premium content

+
, + ); +} + +describe("PremiumGate", () => { + test("renders nothing while the license is loading", () => { + expect(render(undefined)).toBe(""); + expect(render(null)).toBe(""); + }); + + test("renders the paywall without a premium license", () => { + const markup = render({ ...LICENSE, premium: false }); + expect(markup).toContain("Campaigns"); + expect(markup).toContain("Add license"); + expect(markup).not.toContain("premium content"); + }); + + test("renders its children with a premium license", () => { + const markup = render({ ...LICENSE, premium: true }); + expect(markup).toContain("premium content"); + expect(markup).not.toContain("Add license"); + }); +}); diff --git a/webcomponents/src/components/PremiumGate.tsx b/webcomponents/src/components/PremiumGate.tsx new file mode 100644 index 00000000..40772f96 --- /dev/null +++ b/webcomponents/src/components/PremiumGate.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import type { LicenseResponse } from "../generated/LicenseResponse"; +import { PremiumPaywall } from "./PremiumPaywall"; + +export type PremiumGateProps = { + feature: string; + description?: string; + /** Absent while the license is loading. */ + license: LicenseResponse | null | undefined; + onAddLicense: () => void; + children: ReactNode; +}; + +/** Renders `children` for a premium license, the paywall otherwise. */ +export function PremiumGate({ + feature, + description, + license, + onAddLicense, + children, +}: PremiumGateProps) { + if (!license) { + return null; + } + if (!license.premium) { + return ( + + ); + } + return children; +} diff --git a/webcomponents/src/components/StepProgress.module.css b/webcomponents/src/components/StepProgress.module.css new file mode 100644 index 00000000..ddddcb02 --- /dev/null +++ b/webcomponents/src/components/StepProgress.module.css @@ -0,0 +1,33 @@ +.steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.step { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); +} + +.step[data-state="current"] { + color: var(--text); + font-weight: 600; +} + +.step[data-state="done"] .glyph { + color: var(--color-green-l); +} + +.step[data-state="current"] .glyph { + color: var(--accent); +} + +.glyph { + width: 1rem; + text-align: center; +} diff --git a/webcomponents/src/components/StepProgress.test.tsx b/webcomponents/src/components/StepProgress.test.tsx new file mode 100644 index 00000000..25655798 --- /dev/null +++ b/webcomponents/src/components/StepProgress.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { StepProgress, stepStates } from "./StepProgress"; + +const STEPS = [{ name: "plan", display_name: "Plan" }, { name: "build" }, { name: "review" }]; + +describe("stepStates", () => { + test("marks completed, current and pending steps in order", () => { + expect(stepStates(STEPS, "build", ["plan"])).toEqual([ + { name: "plan", label: "Plan", state: "done" }, + { name: "build", label: "build", state: "current" }, + { name: "review", label: "review", state: "pending" }, + ]); + }); + + test("treats steps before the current one as done when nothing was recorded", () => { + expect(stepStates(STEPS, "review", []).map((s) => s.state)).toEqual([ + "done", + "done", + "current", + ]); + }); + + test("a finished ticket has every step done", () => { + expect(stepStates(STEPS, null, [], true).map((s) => s.state)).toEqual(["done", "done", "done"]); + }); +}); + +const NONE: string[] = []; + +test("StepProgress marks the current step for assistive tech", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('aria-current="step"'); + expect(markup).toContain("Plan"); +}); diff --git a/webcomponents/src/components/StepProgress.tsx b/webcomponents/src/components/StepProgress.tsx new file mode 100644 index 00000000..95220aa3 --- /dev/null +++ b/webcomponents/src/components/StepProgress.tsx @@ -0,0 +1,51 @@ +import styles from "./StepProgress.module.css"; + +export type StepState = "done" | "current" | "pending"; + +export type StepLike = { name: string; display_name?: string | null }; + +export type StepProgressProps = { + steps: StepLike[]; + current?: string | null; + completed: string[]; + /** The ticket is completed, so every step is done. */ + finished?: boolean; +}; + +const GLYPH: Record = { done: "✓", current: "●", pending: "○" }; + +/** Each step's state; steps before the current one count as done. */ +export function stepStates( + steps: StepLike[], + current: string | null | undefined, + completed: string[], + finished = false, +): Array<{ name: string; label: string; state: StepState }> { + const currentIndex = steps.findIndex((s) => s.name === current); + return steps.map((step, index) => { + const done = + finished || completed.includes(step.name) || (currentIndex >= 0 && index < currentIndex); + const state: StepState = done ? "done" : step.name === current ? "current" : "pending"; + return { name: step.name, label: step.display_name ?? step.name, state }; + }); +} + +export function StepProgress({ steps, current, completed, finished }: StepProgressProps) { + return ( +
    + {stepStates(steps, current, completed, finished).map((step) => ( +
  1. + + {step.label} +
  2. + ))} +
+ ); +} diff --git a/webcomponents/src/index.ts b/webcomponents/src/index.ts index 17f4640f..23100d5b 100644 --- a/webcomponents/src/index.ts +++ b/webcomponents/src/index.ts @@ -1,6 +1,15 @@ import "./styles/semantic.css"; export { PremiumPaywall, type PremiumPaywallProps } from "./components/PremiumPaywall"; +export { PremiumGate, type PremiumGateProps } from "./components/PremiumGate"; +export { MarkdownView, type MarkdownViewProps } from "./components/MarkdownView"; +export { + StepProgress, + stepStates, + type StepLike, + type StepProgressProps, + type StepState, +} from "./components/StepProgress"; export { AppShell, @@ -77,3 +86,13 @@ export { } from "./shared/kanban-filters"; export type { KanbanFacets, KanbanFilterState } from "./shared/kanban-filters"; export { PRIORITY_KEY, PRIORITY_ORDER, STATUS_GLYPH } from "./shared/ticket-fields"; +export { + focusSessionUrl, + openTargets, + sessionTargets, + ticketFileTargets, + type EditorId, + type OpenTarget, + type SessionAgent, + type SessionTarget, +} from "./shared/ticket-links"; diff --git a/webcomponents/src/shared/ticket-links.test.ts b/webcomponents/src/shared/ticket-links.test.ts new file mode 100644 index 00000000..5c616914 --- /dev/null +++ b/webcomponents/src/shared/ticket-links.test.ts @@ -0,0 +1,126 @@ +/** + * Editor and session links are pure string building, so every scheme is pinned + * here rather than discovered by clicking. + */ + +import { describe, expect, test } from "bun:test"; + +import { + focusSessionUrl, + openTargets, + sessionTargets, + ticketFileTargets, + type SessionAgent, +} from "./ticket-links"; + +function agent(over: Partial): SessionAgent { + return { + id: "agent-1", + session_wrapper: null, + session_name: null, + session_window_ref: null, + session_context_ref: null, + attach_command: null, + ...over, + }; +} + +describe("openTargets", () => { + test("links a local path into every editor", () => { + expect(openTargets("/work/api/FEAT-1 notes.md")).toEqual([ + { editor: "vscode", label: "VS Code", url: "vscode://file/work/api/FEAT-1%20notes.md" }, + { editor: "cursor", label: "Cursor", url: "cursor://file/work/api/FEAT-1%20notes.md" }, + { editor: "zed", label: "Zed", url: "zed://file/work/api/FEAT-1%20notes.md" }, + { + editor: "jetbrains", + label: "JetBrains", + url: "idea://open?file=%2Fwork%2Fapi%2FFEAT-1%20notes.md", + }, + ]); + }); + + test("normalizes a Windows path", () => { + expect(openTargets("C:\\work\\api")[0]?.url).toBe("vscode://file/C:/work/api"); + }); + + test("links a remote path over SSH and drops editors without a remote form", () => { + expect(openTargets("/srv/worktrees/feat-1", "builder")).toEqual([ + { + editor: "vscode", + label: "VS Code", + url: "vscode://vscode-remote/ssh-remote+builder/srv/worktrees/feat-1", + }, + { + editor: "cursor", + label: "Cursor", + url: "cursor://vscode-remote/ssh-remote+builder/srv/worktrees/feat-1", + }, + { editor: "zed", label: "Zed", url: "zed://ssh/builder/srv/worktrees/feat-1" }, + ]); + }); +}); + +describe("sessionTargets", () => { + test("a VS Code session focuses through the extension in VS Code and Cursor", () => { + expect(sessionTargets(agent({ session_wrapper: "vscode", session_name: "op FEAT-1" }))).toEqual( + [ + { + kind: "open-url", + label: "Focus in VS Code", + url: "vscode://untra.operator-terminals/focus-session?name=op%20FEAT-1", + }, + { + kind: "open-url", + label: "Focus in Cursor", + url: "cursor://untra.operator-terminals/focus-session?name=op%20FEAT-1", + }, + ], + ); + }); + + test("cmux focuses through the API", () => { + expect(sessionTargets(agent({ session_wrapper: "cmux" }))).toEqual([ + { kind: "focus-api", label: "Focus cmux session" }, + ]); + }); + + test("tmux focuses through the API and offers the attach command", () => { + expect( + sessionTargets( + agent({ + session_wrapper: "tmux", + session_name: "op-feat-1", + attach_command: "tmux -L operator attach -t op-feat-1", + }), + ), + ).toEqual([ + { kind: "focus-api", label: "Focus tmux session" }, + { kind: "command", label: "Attach", command: "tmux -L operator attach -t op-feat-1" }, + ]); + }); + + test("zellij names the tab to switch to", () => { + expect( + sessionTargets(agent({ session_wrapper: "zellij", session_name: "api-feat-1" })), + ).toEqual([{ kind: "display", label: "zellij tab", detail: "api-feat-1" }]); + }); + + test("an agent with no session offers nothing", () => { + expect(sessionTargets(agent({ session_wrapper: "vscode" }))).toEqual([]); + }); +}); + +test("focusSessionUrl encodes the terminal name", () => { + expect(focusSessionUrl("vscode", "a&b")).toBe( + "vscode://untra.operator-terminals/focus-session?name=a%26b", + ); +}); + +test("ticketFileTargets opens VS Code and Cursor through the Operator extension", () => { + expect(ticketFileTargets("FEAT-1", "/work/t.md").map((t) => t.url)).toEqual([ + "vscode://untra.operator-terminals/open-ticket?id=FEAT-1", + "cursor://untra.operator-terminals/open-ticket?id=FEAT-1", + "zed://file/work/t.md", + "idea://open?file=%2Fwork%2Ft.md", + ]); +}); diff --git a/webcomponents/src/shared/ticket-links.ts b/webcomponents/src/shared/ticket-links.ts new file mode 100644 index 00000000..6ab09c08 --- /dev/null +++ b/webcomponents/src/shared/ticket-links.ts @@ -0,0 +1,112 @@ +/** + * Where a ticket can be opened: its files in an editor, and its agent's + * terminal session. Pure URL building, shared by every surface. + */ + +import type { TicketAgent } from "../generated/TicketAgent"; + +export type EditorId = "vscode" | "cursor" | "zed" | "jetbrains"; + +export type OpenTarget = { editor: EditorId; label: string; url: string }; + +export type SessionTarget = + /** A deep link handed to the host's openExternal. */ + | { kind: "open-url"; label: string; url: string } + /** Focus through `POST /api/v1/agents/{id}/focus`. */ + | { kind: "focus-api"; label: string } + /** A shell command to copy. */ + | { kind: "command"; label: string; command: string } + /** A read-only reference. */ + | { kind: "display"; label: string; detail: string }; + +export type SessionAgent = Pick< + TicketAgent, + | "id" + | "session_wrapper" + | "session_name" + | "session_window_ref" + | "session_context_ref" + | "attach_command" +>; + +/** Publisher.name of the Operator VS Code extension, the URI authority. */ +const EXTENSION_ID = "untra.operator-terminals"; + +function slashPath(path: string): string { + const forward = path.replaceAll("\\", "/"); + return forward.startsWith("/") ? forward : `/${forward}`; +} + +/** Deep links that open `path` in each editor; `remoteHost` switches to SSH forms. */ +export function openTargets(path: string, remoteHost?: string | null): OpenTarget[] { + const encoded = encodeURI(slashPath(path)); + if (remoteHost) { + const host = encodeURIComponent(remoteHost); + return [ + { + editor: "vscode", + label: "VS Code", + url: `vscode://vscode-remote/ssh-remote+${host}${encoded}`, + }, + { + editor: "cursor", + label: "Cursor", + url: `cursor://vscode-remote/ssh-remote+${host}${encoded}`, + }, + { editor: "zed", label: "Zed", url: `zed://ssh/${host}${encoded}` }, + ]; + } + return [ + { editor: "vscode", label: "VS Code", url: `vscode://file${encoded}` }, + { editor: "cursor", label: "Cursor", url: `cursor://file${encoded}` }, + { editor: "zed", label: "Zed", url: `zed://file${encoded}` }, + { + editor: "jetbrains", + label: "JetBrains", + url: `idea://open?file=${encodeURIComponent(path.replaceAll("\\", "/"))}`, + }, + ]; +} + +/** Like `openTargets`, but VS Code and Cursor open the ticket through the Operator extension. */ +export function ticketFileTargets(ticketId: string, path: string): OpenTarget[] { + const id = encodeURIComponent(ticketId); + return openTargets(path).map((target) => + target.editor === "vscode" || target.editor === "cursor" + ? { ...target, url: `${target.editor}://${EXTENSION_ID}/open-ticket?id=${id}` } + : target, + ); +} + +/** The Operator extension's link that reveals a terminal by name. */ +export function focusSessionUrl(scheme: "vscode" | "cursor", name: string): string { + return `${scheme}://${EXTENSION_ID}/focus-session?name=${encodeURIComponent(name)}`; +} + +/** Ways to reach the agent's terminal, by the wrapper it runs in. */ +export function sessionTargets(agent: SessionAgent): SessionTarget[] { + const name = agent.session_name; + switch (agent.session_wrapper ?? "tmux") { + case "vscode": + return name + ? [ + { kind: "open-url", label: "Focus in VS Code", url: focusSessionUrl("vscode", name) }, + { kind: "open-url", label: "Focus in Cursor", url: focusSessionUrl("cursor", name) }, + ] + : []; + case "cmux": + return [{ kind: "focus-api", label: "Focus cmux session" }]; + case "zellij": + return name ? [{ kind: "display", label: "zellij tab", detail: name }] : []; + case "tmux": { + const targets: SessionTarget[] = name + ? [{ kind: "focus-api", label: "Focus tmux session" }] + : []; + return agent.attach_command + ? [...targets, { kind: "command", label: "Attach", command: agent.attach_command }] + : targets; + } + default: + return name ? [{ kind: "display", label: "Session", detail: name }] : []; + } +} diff --git a/webcomponents/stories/components/MarkdownView.stories.tsx b/webcomponents/stories/components/MarkdownView.stories.tsx new file mode 100644 index 00000000..cd0fef50 --- /dev/null +++ b/webcomponents/stories/components/MarkdownView.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MarkdownView } from "../../src/components/MarkdownView"; + +const TICKET_BODY = `# Feature: Serve price tiers + +Expose the new \`price_tiers\` table over the API. + +## Acceptance + +- [x] Schema migrated +- [ ] \`GET /tiers\` returns every tier +- [ ] Docs updated + +| Tier | Monthly | +|------|---------| +| Free | $0 | +| Team | $20 | + +\`\`\`rust +fn tiers() -> Vec { load() } +\`\`\` + +## History + +- 2026-10-05 12:00 plan completed +`; + +const meta = { + title: "Components/MarkdownView", + component: MarkdownView, + args: { source: TICKET_BODY }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** A ticket body: headings, a task list, a table, code and history. */ +export const TicketBody: Story = {}; + +/** Raw HTML in a ticket is shown as nothing, never executed. */ +export const IgnoresRawHtml: Story = { + args: { source: "Before\n\n\n\nAfter" }, +}; + +export const DarkTheme: Story = { globals: { theme: "dark" } }; diff --git a/webcomponents/stories/components/PremiumGate.stories.tsx b/webcomponents/stories/components/PremiumGate.stories.tsx new file mode 100644 index 00000000..7046fb43 --- /dev/null +++ b/webcomponents/stories/components/PremiumGate.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PremiumGate } from "../../src/components/PremiumGate"; + +const NOOP = () => undefined; + +const LICENSE = { + status: "valid" as const, + profile_id: "default", + terms: null, + purchase_url: "https://operator.untra.io/premium", +}; + +const meta = { + title: "Components/PremiumGate", + component: PremiumGate, + args: { + feature: "Campaigns", + onAddLicense: NOOP, + license: { ...LICENSE, premium: false }, + children:

Campaign content, shown only with Premium.

, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Without Premium the gate renders the paywall in place of its content. */ +export const Unlicensed: Story = {}; + +/** With Premium the gate renders its content untouched. */ +export const Licensed: Story = { + args: { license: { ...LICENSE, premium: true } }, +}; + +export const DarkTheme: Story = { globals: { theme: "dark" } }; diff --git a/webcomponents/stories/components/StepProgress.stories.tsx b/webcomponents/stories/components/StepProgress.stories.tsx new file mode 100644 index 00000000..c56ccbd0 --- /dev/null +++ b/webcomponents/stories/components/StepProgress.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { StepProgress } from "../../src/components/StepProgress"; + +const STEPS = [ + { name: "plan", display_name: "Plan" }, + { name: "build", display_name: "Build" }, + { name: "review", display_name: "Review" }, +]; + +const meta = { + title: "Components/StepProgress", + component: StepProgress, + args: { steps: STEPS, current: "build", completed: ["plan"] }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Mid-way: one step done, one running, one to come. */ +export const InProgress: Story = {}; + +/** Queued: nothing has started. */ +export const NotStarted: Story = { args: { current: "plan", completed: [] } }; + +/** A completed ticket shows every step done. */ +export const Finished: Story = { args: { current: null, completed: [], finished: true } }; + +export const DarkTheme: Story = { globals: { theme: "dark" } }; diff --git a/webcomponents/stories/fixtures/operator.ts b/webcomponents/stories/fixtures/operator.ts index 837f0eda..16ac22df 100644 --- a/webcomponents/stories/fixtures/operator.ts +++ b/webcomponents/stories/fixtures/operator.ts @@ -266,6 +266,7 @@ export const delegators: DelegatorResponse[] = [ model_server: null, launch_config: null, remote_agent: null, + governance: null, }, { name: "local-coder", @@ -276,6 +277,7 @@ export const delegators: DelegatorResponse[] = [ model_server: "workstation", launch_config: null, remote_agent: null, + governance: null, }, ]; @@ -289,6 +291,7 @@ export const unnamedDelegator: DelegatorResponse = { model_server: null, launch_config: null, remote_agent: null, + governance: null, }; export const healthySection: SectionDto = {